mirror of
https://github.com/Radarr/Radarr.git
synced 2026-04-16 21:15:33 -04:00
Compare commits
66 Commits
v5.10.4.92
...
v5.12.2.93
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
889d071004 | ||
|
|
0049922ab6 | ||
|
|
3c995a0fff | ||
|
|
430719baac | ||
|
|
9928d711a3 | ||
|
|
f90b43b3e1 | ||
|
|
64122b4cfb | ||
|
|
7912a942f7 | ||
|
|
0a7607bb62 | ||
|
|
beeb5204b8 | ||
|
|
ab13fb6e99 | ||
|
|
2a3d595a66 | ||
|
|
958a863d8f | ||
|
|
8b7884deb0 | ||
|
|
9a22e1c791 | ||
|
|
f0f828491b | ||
|
|
7f3d107eda | ||
|
|
ce4477eeac | ||
|
|
8b64f873f4 | ||
|
|
38bd060960 | ||
|
|
7c243cb6e8 | ||
|
|
b29dee63f4 | ||
|
|
f6542bab0a | ||
|
|
da1b53b7e2 | ||
|
|
0deae95782 | ||
|
|
75c7a3cfc6 | ||
|
|
cfdb7a15de | ||
|
|
63a7d33e7e | ||
|
|
c9836f997c | ||
|
|
d37e71415f | ||
|
|
9a5f4bef63 | ||
|
|
40551ba5a3 | ||
|
|
6e04dc894b | ||
|
|
ac767ed386 | ||
|
|
42fbb79017 | ||
|
|
c43bd77dae | ||
|
|
68dfa55b35 | ||
|
|
fa190c85a3 | ||
|
|
172dcf6f8d | ||
|
|
0736fc955f | ||
|
|
9d0b8d974d | ||
|
|
9a3e89f283 | ||
|
|
e33e45ec73 | ||
|
|
5893d88058 | ||
|
|
a81d27acda | ||
|
|
d2b279a6be | ||
|
|
6686fa0600 | ||
|
|
1d286df85d | ||
|
|
be2e1e4fdb | ||
|
|
08868e5d01 | ||
|
|
7b43c2e345 | ||
|
|
dc599b6531 | ||
|
|
1421179654 | ||
|
|
41dcf32e24 | ||
|
|
7a813a44b6 | ||
|
|
54a5059080 | ||
|
|
adaf7444d3 | ||
|
|
49d11e59b3 | ||
|
|
a7eb4a4a04 | ||
|
|
66a6a663ba | ||
|
|
f735e31835 | ||
|
|
b8f1286abb | ||
|
|
9df45199d0 | ||
|
|
a692c35b03 | ||
|
|
ddcad270c3 | ||
|
|
b06f1d7c12 |
@@ -9,7 +9,7 @@
|
||||
[](#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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ variables:
|
||||
testsFolder: './_tests'
|
||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||
majorVersion: '5.10.4'
|
||||
majorVersion: '5.12.2'
|
||||
minorVersion: $[counter('minorVersion', 2000)]
|
||||
radarrVersion: '$(majorVersion).$(minorVersion)'
|
||||
buildName: '$(Build.SourceBranchName).$(radarrVersion)'
|
||||
@@ -20,7 +20,7 @@ variables:
|
||||
innoVersion: '6.2.2'
|
||||
windowsImage: 'windows-2022'
|
||||
linuxImage: 'ubuntu-20.04'
|
||||
macImage: 'macOS-12'
|
||||
macImage: 'macOS-13'
|
||||
|
||||
trigger:
|
||||
branches:
|
||||
|
||||
@@ -131,7 +131,9 @@ class AddNewMovie extends Component {
|
||||
<div className={styles.helpText}>
|
||||
{translate('FailedLoadingSearchResults')}
|
||||
</div>
|
||||
<Alert kind={kinds.WARNING}>{getErrorMessage(error)}</Alert>
|
||||
|
||||
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
|
||||
|
||||
<div>
|
||||
<Link to="https://wiki.servarr.com/radarr/troubleshooting#invalid-response-received-from-tmdb">
|
||||
{translate('WhySearchesCouldBeFailing')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -27,6 +27,7 @@ export interface MovieIndexAppState {
|
||||
showTmdbRating: boolean;
|
||||
showImdbRating: boolean;
|
||||
showRottenTomatoesRating: boolean;
|
||||
showTraktRating: boolean;
|
||||
showTags: boolean;
|
||||
showSearchAction: boolean;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,9 @@ import IndexerFlag from 'typings/IndexerFlag';
|
||||
import Notification from 'typings/Notification';
|
||||
import QualityProfile from 'typings/QualityProfile';
|
||||
import General from 'typings/Settings/General';
|
||||
import NamingConfig from 'typings/Settings/NamingConfig';
|
||||
import NamingExample from 'typings/Settings/NamingExample';
|
||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
||||
import UiSettings from 'typings/Settings/UiSettings';
|
||||
|
||||
export interface DownloadClientAppState
|
||||
@@ -29,6 +32,13 @@ export interface GeneralAppState
|
||||
extends AppSectionItemState<General>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface NamingAppState
|
||||
extends AppSectionItemState<NamingConfig>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface NamingExamplesAppState
|
||||
extends AppSectionItemState<NamingExample> {}
|
||||
|
||||
export interface ImportListAppState
|
||||
extends AppSectionState<ImportList>,
|
||||
AppSectionDeleteState,
|
||||
@@ -49,6 +59,12 @@ export interface QualityProfilesAppState
|
||||
extends AppSectionState<QualityProfile>,
|
||||
AppSectionSchemaState<QualityProfile> {}
|
||||
|
||||
export interface ReleaseProfilesAppState
|
||||
extends AppSectionState<ReleaseProfile>,
|
||||
AppSectionSaveState {
|
||||
pendingChanges: Partial<ReleaseProfile>;
|
||||
}
|
||||
|
||||
export interface CustomFormatAppState
|
||||
extends AppSectionState<CustomFormat>,
|
||||
AppSectionDeleteState,
|
||||
@@ -81,8 +97,11 @@ interface SettingsAppState {
|
||||
indexerFlags: IndexerFlagSettingsAppState;
|
||||
indexers: IndexerAppState;
|
||||
languages: LanguageSettingsAppState;
|
||||
naming: NamingAppState;
|
||||
namingExamples: NamingExamplesAppState;
|
||||
notifications: NotificationAppState;
|
||||
qualityProfiles: QualityProfilesAppState;
|
||||
releaseProfiles: ReleaseProfilesAppState;
|
||||
ui: UiSettingsAppState;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
67
frontend/src/Components/Form/AvailabilitySelectInput.tsx
Normal file
67
frontend/src/Components/Form/AvailabilitySelectInput.tsx
Normal 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;
|
||||
@@ -1,54 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import styles from './FormInputButton.css';
|
||||
|
||||
function FormInputButton(props) {
|
||||
const {
|
||||
className,
|
||||
canSpin,
|
||||
isLastButton,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
if (canSpin) {
|
||||
return (
|
||||
<SpinnerButton
|
||||
className={classNames(
|
||||
className,
|
||||
!isLastButton && styles.middleButton
|
||||
)}
|
||||
kind={kinds.PRIMARY}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={classNames(
|
||||
className,
|
||||
!isLastButton && styles.middleButton
|
||||
)}
|
||||
kind={kinds.PRIMARY}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
FormInputButton.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
isLastButton: PropTypes.bool.isRequired,
|
||||
canSpin: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
FormInputButton.defaultProps = {
|
||||
className: styles.button,
|
||||
isLastButton: true,
|
||||
canSpin: false
|
||||
};
|
||||
|
||||
export default FormInputButton;
|
||||
38
frontend/src/Components/Form/FormInputButton.tsx
Normal file
38
frontend/src/Components/Form/FormInputButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import Button, { ButtonProps } from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import styles from './FormInputButton.css';
|
||||
|
||||
export interface FormInputButtonProps extends ButtonProps {
|
||||
canSpin?: boolean;
|
||||
isLastButton?: boolean;
|
||||
}
|
||||
|
||||
function FormInputButton({
|
||||
className = styles.button,
|
||||
canSpin = false,
|
||||
isLastButton = true,
|
||||
...otherProps
|
||||
}: FormInputButtonProps) {
|
||||
if (canSpin) {
|
||||
return (
|
||||
<SpinnerButton
|
||||
className={classNames(className, !isLastButton && styles.middleButton)}
|
||||
kind={kinds.PRIMARY}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={classNames(className, !isLastButton && styles.middleButton)}
|
||||
kind={kinds.PRIMARY}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormInputButton;
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.image {
|
||||
align-content: center;
|
||||
margin-right: 5px;
|
||||
|
||||
1
frontend/src/Components/ImdbRating.css.d.ts
vendored
1
frontend/src/Components/ImdbRating.css.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'image': string;
|
||||
'wrapper': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
||||
@@ -23,7 +23,7 @@ function ImdbRating(props: ImdbRatingProps) {
|
||||
return (
|
||||
<Tooltip
|
||||
anchor={
|
||||
<span>
|
||||
<span className={styles.wrapper}>
|
||||
{!hideIcon && (
|
||||
<img
|
||||
className={styles.image}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import Clipboard from 'clipboard';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import Icon from 'Components/Icon';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import getUniqueElememtId from 'Utilities/getUniqueElementId';
|
||||
import styles from './ClipboardButton.css';
|
||||
|
||||
class ClipboardButton extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._id = getUniqueElememtId();
|
||||
this._successTimeout = null;
|
||||
this._testResultTimeout = null;
|
||||
|
||||
this.state = {
|
||||
showSuccess: false,
|
||||
showError: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._clipboard = new Clipboard(`#${this._id}`, {
|
||||
text: () => this.props.value,
|
||||
container: document.getElementById(this._id)
|
||||
});
|
||||
|
||||
this._clipboard.on('success', this.onSuccess);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const {
|
||||
showSuccess,
|
||||
showError
|
||||
} = this.state;
|
||||
|
||||
if (showSuccess || showError) {
|
||||
this._testResultTimeout = setTimeout(this.resetState, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._clipboard) {
|
||||
this._clipboard.destroy();
|
||||
}
|
||||
|
||||
if (this._testResultTimeout) {
|
||||
clearTimeout(this._testResultTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
resetState = () => {
|
||||
this.setState({
|
||||
showSuccess: false,
|
||||
showError: false
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSuccess = () => {
|
||||
this.setState({
|
||||
showSuccess: true
|
||||
});
|
||||
};
|
||||
|
||||
onError = () => {
|
||||
this.setState({
|
||||
showError: true
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
value,
|
||||
className,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
showSuccess,
|
||||
showError
|
||||
} = this.state;
|
||||
|
||||
const showStateIcon = showSuccess || showError;
|
||||
const iconName = showError ? icons.DANGER : icons.CHECK;
|
||||
const iconKind = showError ? kinds.DANGER : kinds.SUCCESS;
|
||||
|
||||
return (
|
||||
<FormInputButton
|
||||
id={this._id}
|
||||
className={className}
|
||||
{...otherProps}
|
||||
>
|
||||
<span className={showStateIcon ? styles.showStateIcon : undefined}>
|
||||
{
|
||||
showSuccess &&
|
||||
<span className={styles.stateIconContainer}>
|
||||
<Icon
|
||||
name={iconName}
|
||||
kind={iconKind}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
|
||||
{
|
||||
<span className={styles.clipboardIconContainer}>
|
||||
<Icon name={icons.CLIPBOARD} />
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</FormInputButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ClipboardButton.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
ClipboardButton.defaultProps = {
|
||||
className: styles.button
|
||||
};
|
||||
|
||||
export default ClipboardButton;
|
||||
76
frontend/src/Components/Link/ClipboardButton.tsx
Normal file
76
frontend/src/Components/Link/ClipboardButton.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import copy from 'copy-to-clipboard';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import Icon from 'Components/Icon';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import { ButtonProps } from './Button';
|
||||
import styles from './ClipboardButton.css';
|
||||
|
||||
export interface ClipboardButtonProps extends Omit<ButtonProps, 'children'> {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type ClipboardState = 'success' | 'error' | null;
|
||||
|
||||
export default function ClipboardButton({
|
||||
id,
|
||||
value,
|
||||
className = styles.button,
|
||||
...otherProps
|
||||
}: ClipboardButtonProps) {
|
||||
const [state, setState] = useState<ClipboardState>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
setState(null);
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
try {
|
||||
if ('clipboard' in navigator) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} else {
|
||||
copy(value);
|
||||
}
|
||||
|
||||
setState('success');
|
||||
} catch (e) {
|
||||
setState('error');
|
||||
console.error(`Failed to copy to clipboard`, e);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<FormInputButton
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
{...otherProps}
|
||||
>
|
||||
<span className={state ? styles.showStateIcon : undefined}>
|
||||
{state ? (
|
||||
<span className={styles.stateIconContainer}>
|
||||
<Icon
|
||||
name={state === 'error' ? icons.DANGER : icons.CHECK}
|
||||
kind={state === 'error' ? kinds.DANGER : kinds.SUCCESS}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span className={styles.clipboardIconContainer}>
|
||||
<Icon name={icons.CLIPBOARD} />
|
||||
</span>
|
||||
</span>
|
||||
</FormInputButton>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ function getSuggestions(movies, value) {
|
||||
key: 'title'
|
||||
}
|
||||
],
|
||||
arrayIndex: 0
|
||||
refIndex: 0
|
||||
});
|
||||
if (suggestions.length > limit) {
|
||||
break;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.image {
|
||||
align-content: center;
|
||||
margin-right: 5px;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'image': string;
|
||||
'wrapper': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.image {
|
||||
align-content: center;
|
||||
margin-right: 5px;
|
||||
|
||||
1
frontend/src/Components/TmdbRating.css.d.ts
vendored
1
frontend/src/Components/TmdbRating.css.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'image': string;
|
||||
'wrapper': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
||||
@@ -22,7 +22,7 @@ function TmdbRating(props: TmdbRatingProps) {
|
||||
return (
|
||||
<Tooltip
|
||||
anchor={
|
||||
<span>
|
||||
<span className={styles.wrapper}>
|
||||
{!hideIcon && (
|
||||
<img
|
||||
className={styles.image}
|
||||
|
||||
9
frontend/src/Components/TraktRating.css
Normal file
9
frontend/src/Components/TraktRating.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.image {
|
||||
align-content: center;
|
||||
margin-right: 5px;
|
||||
vertical-align: -0.125em;
|
||||
}
|
||||
8
frontend/src/Components/TraktRating.css.d.ts
vendored
Normal file
8
frontend/src/Components/TraktRating.css.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'image': string;
|
||||
'wrapper': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
46
frontend/src/Components/TraktRating.tsx
Normal file
46
frontend/src/Components/TraktRating.tsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
.tmdbRating,
|
||||
.imdbRating,
|
||||
.rottenTomatoesRating,
|
||||
.traktRating,
|
||||
.runtime {
|
||||
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ interface CssExports {
|
||||
'status': string;
|
||||
'studio': string;
|
||||
'tmdbRating': string;
|
||||
'traktRating': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
.tmdbRating,
|
||||
.imdbRating,
|
||||
.rottenTomatoesRating,
|
||||
.traktRating,
|
||||
.runtime {
|
||||
composes: cell;
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ interface CssExports {
|
||||
'statusIcon': string;
|
||||
'studio': string;
|
||||
'tmdbRating': string;
|
||||
'traktRating': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
||||
@@ -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]}>
|
||||
|
||||
@@ -20,6 +20,7 @@ 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';
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.tagInternalInput {
|
||||
composes: internalInput from '~Components/Form/TagInput.css';
|
||||
|
||||
flex: 0 0 100%;
|
||||
.labelIcon {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -92,7 +92,8 @@
|
||||
|
||||
.imdbRating,
|
||||
.tmdbRating,
|
||||
.rottenTomatoesRating {
|
||||
.rottenTomatoesRating,
|
||||
.traktRating {
|
||||
composes: cell;
|
||||
|
||||
flex: 0 0 80px;
|
||||
|
||||
@@ -30,6 +30,7 @@ interface CssExports {
|
||||
'studio': string;
|
||||
'tags': string;
|
||||
'tmdbRating': string;
|
||||
'traktRating': string;
|
||||
'year': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
|
||||
@@ -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]}>
|
||||
|
||||
@@ -82,7 +82,8 @@
|
||||
|
||||
.imdbRating,
|
||||
.tmdbRating,
|
||||
.rottenTomatoesRating {
|
||||
.rottenTomatoesRating,
|
||||
.traktRating {
|
||||
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
|
||||
|
||||
flex: 0 0 80px;
|
||||
|
||||
@@ -27,6 +27,7 @@ interface CssExports {
|
||||
'studio': string;
|
||||
'tags': string;
|
||||
'tmdbRating': string;
|
||||
'traktRating': string;
|
||||
'year': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface Ratings {
|
||||
tmdb: RatingValues;
|
||||
metacritic: RatingValues;
|
||||
rottenTomatoes: RatingValues;
|
||||
trakt: RatingValues;
|
||||
}
|
||||
|
||||
export interface AlternativeTitle extends ModelBase {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.labelIcon {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.message {
|
||||
composes: alert from '~Components/Alert.css';
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'deleteButton': string;
|
||||
'labelIcon': string;
|
||||
'message': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { Component } from 'react';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import TagList from 'Components/TagList';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import formatShortTimeSpan from 'Utilities/Date/formatShortTimeSpan';
|
||||
import translate from 'Utilities/String/translate';
|
||||
@@ -58,6 +59,8 @@ class ImportList extends Component {
|
||||
name,
|
||||
enabled,
|
||||
enableAuto,
|
||||
tags,
|
||||
tagList,
|
||||
minRefreshInterval
|
||||
} = this.props;
|
||||
|
||||
@@ -72,7 +75,6 @@ class ImportList extends Component {
|
||||
</div>
|
||||
|
||||
<div className={styles.enabled}>
|
||||
|
||||
{
|
||||
enabled ?
|
||||
<Label kind={kinds.SUCCESS}>
|
||||
@@ -87,15 +89,21 @@ class ImportList extends Component {
|
||||
}
|
||||
|
||||
{
|
||||
enableAuto &&
|
||||
enableAuto ?
|
||||
<Label kind={kinds.SUCCESS}>
|
||||
{translate('AutomaticAdd')}
|
||||
</Label>
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
|
||||
<TagList
|
||||
tags={tags}
|
||||
tagList={tagList}
|
||||
/>
|
||||
|
||||
<div className={styles.enabled}>
|
||||
<Label kind={kinds.INFO} title='List Refresh Interval'>
|
||||
<Label kind={kinds.DEFAULT} title='List Refresh Interval'>
|
||||
{`${translate('Refresh')}: ${formatShortTimeSpan(minRefreshInterval)}`}
|
||||
</Label>
|
||||
</div>
|
||||
@@ -126,6 +134,8 @@ ImportList.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
enabled: PropTypes.bool.isRequired,
|
||||
enableAuto: PropTypes.bool.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
minRefreshInterval: PropTypes.string.isRequired,
|
||||
onConfirmDeleteImportList: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ class ImportLists extends Component {
|
||||
render() {
|
||||
const {
|
||||
items,
|
||||
tagList,
|
||||
onConfirmDeleteImportList,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
@@ -71,6 +72,7 @@ class ImportLists extends Component {
|
||||
<ImportList
|
||||
key={item.id}
|
||||
{...item}
|
||||
tagList={tagList}
|
||||
onConfirmDeleteImportList={onConfirmDeleteImportList}
|
||||
/>
|
||||
);
|
||||
@@ -109,6 +111,7 @@ ImportLists.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onConfirmDeleteImportList: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
|
||||
@@ -5,13 +5,20 @@ import { createSelector } from 'reselect';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import { deleteImportList, fetchImportLists } from 'Store/Actions/settingsActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import ImportLists from './ImportLists';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSortedSectionSelector('settings.importLists', sortByProp('name')),
|
||||
(importLists) => importLists
|
||||
createTagsSelector(),
|
||||
(importLists, tagList) => {
|
||||
return {
|
||||
...importLists,
|
||||
tagList
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||
import RootFolders from 'RootFolder/RootFolders';
|
||||
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import NamingConnector from './Naming/NamingConnector';
|
||||
import Naming from './Naming/Naming';
|
||||
import AddRootFolder from './RootFolder/AddRootFolder';
|
||||
|
||||
const rescanAfterRefreshOptions = [
|
||||
@@ -106,7 +106,7 @@ class MediaManagement extends Component {
|
||||
/>
|
||||
|
||||
<PageContentBody>
|
||||
<NamingConnector />
|
||||
<Naming />
|
||||
|
||||
{
|
||||
isFetching ?
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import NamingModal from './NamingModal';
|
||||
import styles from './Naming.css';
|
||||
|
||||
const colonReplacementOptions = [
|
||||
{
|
||||
key: 'delete',
|
||||
get value() {
|
||||
return translate('Delete');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'dash',
|
||||
get value() {
|
||||
return translate('ReplaceWithDash');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'spaceDash',
|
||||
get value() {
|
||||
return translate('ReplaceWithSpaceDash');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'spaceDashSpace',
|
||||
get value() {
|
||||
return translate('ReplaceWithSpaceDashSpace');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
class Naming extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isNamingModalOpen: false,
|
||||
namingModalOptions: null
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onStandardNamingModalOpenClick = () => {
|
||||
this.setState({
|
||||
isNamingModalOpen: true,
|
||||
namingModalOptions: {
|
||||
name: 'standardMovieFormat',
|
||||
additional: true
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMovieFolderNamingModalOpenClick = () => {
|
||||
this.setState({
|
||||
isNamingModalOpen: true,
|
||||
namingModalOptions: {
|
||||
name: 'movieFolderFormat'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onNamingModalClose = () => {
|
||||
this.setState({ isNamingModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
advancedSettings,
|
||||
isFetching,
|
||||
error,
|
||||
settings,
|
||||
hasSettings,
|
||||
examples,
|
||||
examplesPopulated,
|
||||
onInputChange
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
isNamingModalOpen,
|
||||
namingModalOptions
|
||||
} = this.state;
|
||||
|
||||
const renameMovies = hasSettings && settings.renameMovies.value;
|
||||
const replaceIllegalCharacters = hasSettings && settings.replaceIllegalCharacters.value;
|
||||
|
||||
const standardMovieFormatHelpTexts = [];
|
||||
const standardMovieFormatErrors = [];
|
||||
const movieFolderFormatHelpTexts = [];
|
||||
const movieFolderFormatErrors = [];
|
||||
|
||||
if (examplesPopulated) {
|
||||
if (examples.movieExample) {
|
||||
standardMovieFormatHelpTexts.push(`${translate('Movie')}: ${examples.movieExample}`);
|
||||
} else {
|
||||
standardMovieFormatErrors.push({ get message() {
|
||||
return translate('MovieInvalidFormat');
|
||||
} });
|
||||
}
|
||||
|
||||
if (examples.movieFolderExample) {
|
||||
movieFolderFormatHelpTexts.push(`${translate('Example')}: ${examples.movieFolderExample}`);
|
||||
} else {
|
||||
movieFolderFormatErrors.push({ get message() {
|
||||
return translate('InvalidFormat');
|
||||
} });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldSet legend={translate('MovieNaming')}>
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && error &&
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('NamingSettingsLoadError')}
|
||||
</Alert>
|
||||
}
|
||||
|
||||
{
|
||||
hasSettings && !isFetching && !error &&
|
||||
<Form>
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('RenameMovies')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="renameMovies"
|
||||
helpText={translate('RenameMoviesHelpText')}
|
||||
onChange={onInputChange}
|
||||
{...settings.renameMovies}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('ReplaceIllegalCharacters')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="replaceIllegalCharacters"
|
||||
helpText={translate('ReplaceIllegalCharactersHelpText')}
|
||||
onChange={onInputChange}
|
||||
{...settings.replaceIllegalCharacters}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
replaceIllegalCharacters &&
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ColonReplacement')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="colonReplacementFormat"
|
||||
values={colonReplacementOptions}
|
||||
helpText={translate('ColonReplacementFormatHelpText')}
|
||||
onChange={onInputChange}
|
||||
{...settings.colonReplacementFormat}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
{
|
||||
renameMovies &&
|
||||
<FormGroup size={sizes.LARGE}>
|
||||
<FormLabel>{translate('StandardMovieFormat')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
inputClassName={styles.namingInput}
|
||||
type={inputTypes.TEXT}
|
||||
name="standardMovieFormat"
|
||||
buttons={<FormInputButton onPress={this.onStandardNamingModalOpenClick}>?</FormInputButton>}
|
||||
onChange={onInputChange}
|
||||
{...settings.standardMovieFormat}
|
||||
helpTexts={standardMovieFormatHelpTexts}
|
||||
errors={[...standardMovieFormatErrors, ...settings.standardMovieFormat.errors]}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>{translate('MovieFolderFormat')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
inputClassName={styles.namingInput}
|
||||
type={inputTypes.TEXT}
|
||||
name="movieFolderFormat"
|
||||
buttons={<FormInputButton onPress={this.onMovieFolderNamingModalOpenClick}>?</FormInputButton>}
|
||||
onChange={onInputChange}
|
||||
{...settings.movieFolderFormat}
|
||||
helpTexts={[translate('MovieFolderFormatHelpText'), ...movieFolderFormatHelpTexts]}
|
||||
errors={[...movieFolderFormatErrors, ...settings.movieFolderFormat.errors]}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
namingModalOptions &&
|
||||
<NamingModal
|
||||
isOpen={isNamingModalOpen}
|
||||
advancedSettings={advancedSettings}
|
||||
{...namingModalOptions}
|
||||
value={settings[namingModalOptions.name].value}
|
||||
onInputChange={onInputChange}
|
||||
onModalClose={this.onNamingModalClose}
|
||||
/>
|
||||
}
|
||||
</Form>
|
||||
}
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Naming.propTypes = {
|
||||
advancedSettings: PropTypes.bool.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
settings: PropTypes.object.isRequired,
|
||||
hasSettings: PropTypes.bool.isRequired,
|
||||
examples: PropTypes.object.isRequired,
|
||||
examplesPopulated: PropTypes.bool.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default Naming;
|
||||
273
frontend/src/Settings/MediaManagement/Naming/Naming.tsx
Normal file
273
frontend/src/Settings/MediaManagement/Naming/Naming.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Alert from 'Components/Alert';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
|
||||
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import {
|
||||
fetchNamingExamples,
|
||||
fetchNamingSettings,
|
||||
setNamingSettingsValue,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
|
||||
import NamingConfig from 'typings/Settings/NamingConfig';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import NamingModal from './NamingModal';
|
||||
import styles from './Naming.css';
|
||||
|
||||
const SECTION = 'naming';
|
||||
|
||||
function createNamingSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.advancedSettings,
|
||||
(state: AppState) => state.settings.namingExamples,
|
||||
createSettingsSectionSelector(SECTION),
|
||||
(advancedSettings, namingExamples, sectionSettings) => {
|
||||
return {
|
||||
advancedSettings,
|
||||
examples: namingExamples.item,
|
||||
examplesPopulated: namingExamples.isPopulated,
|
||||
...sectionSettings,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface NamingModalOptions {
|
||||
name: keyof Pick<NamingConfig, 'standardMovieFormat' | 'movieFolderFormat'>;
|
||||
movie?: boolean;
|
||||
additional?: boolean;
|
||||
}
|
||||
|
||||
function Naming() {
|
||||
const {
|
||||
advancedSettings,
|
||||
isFetching,
|
||||
error,
|
||||
settings,
|
||||
hasSettings,
|
||||
examples,
|
||||
examplesPopulated,
|
||||
} = useSelector(createNamingSelector());
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [isNamingModalOpen, setNamingModalOpen, setNamingModalClosed] =
|
||||
useModalOpenState(false);
|
||||
const [namingModalOptions, setNamingModalOptions] =
|
||||
useState<NamingModalOptions | null>(null);
|
||||
const namingExampleTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchNamingSettings());
|
||||
dispatch(fetchNamingExamples());
|
||||
|
||||
return () => {
|
||||
dispatch(clearPendingChanges({ section: SECTION }));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: { name: string; value: string }) => {
|
||||
// @ts-expect-error 'setNamingSettingsValue' isn't typed yet
|
||||
dispatch(setNamingSettingsValue({ name, value }));
|
||||
|
||||
if (namingExampleTimeout.current) {
|
||||
clearTimeout(namingExampleTimeout.current);
|
||||
}
|
||||
|
||||
namingExampleTimeout.current = setTimeout(() => {
|
||||
dispatch(fetchNamingExamples());
|
||||
}, 1000);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const onStandardNamingModalOpenClick = useCallback(() => {
|
||||
setNamingModalOpen();
|
||||
|
||||
setNamingModalOptions({
|
||||
name: 'standardMovieFormat',
|
||||
movie: true,
|
||||
additional: true,
|
||||
});
|
||||
}, [setNamingModalOpen, setNamingModalOptions]);
|
||||
|
||||
const onMovieFolderNamingModalOpenClick = useCallback(() => {
|
||||
setNamingModalOpen();
|
||||
|
||||
setNamingModalOptions({
|
||||
name: 'movieFolderFormat',
|
||||
});
|
||||
}, [setNamingModalOpen, setNamingModalOptions]);
|
||||
|
||||
const renameMovies = hasSettings && settings.renameMovies.value;
|
||||
const replaceIllegalCharacters =
|
||||
hasSettings && settings.replaceIllegalCharacters.value;
|
||||
|
||||
const colonReplacementOptions = [
|
||||
{ key: 'delete', value: translate('Delete') },
|
||||
{ key: 'dash', value: translate('ReplaceWithDash') },
|
||||
{ key: 'spaceDash', value: translate('ReplaceWithSpaceDash') },
|
||||
{ key: 'spaceDashSpace', value: translate('ReplaceWithSpaceDashSpace') },
|
||||
{
|
||||
key: 'smart',
|
||||
value: translate('SmartReplace'),
|
||||
hint: translate('SmartReplaceHint'),
|
||||
},
|
||||
];
|
||||
|
||||
const standardMovieFormatHelpTexts = [];
|
||||
const standardMovieFormatErrors = [];
|
||||
const movieFolderFormatHelpTexts = [];
|
||||
const movieFolderFormatErrors = [];
|
||||
|
||||
if (examplesPopulated) {
|
||||
if (examples.movieExample) {
|
||||
standardMovieFormatHelpTexts.push(
|
||||
`${translate('Movie')}: ${examples.movieExample}`
|
||||
);
|
||||
} else {
|
||||
standardMovieFormatErrors.push({
|
||||
message: translate('MovieInvalidFormat'),
|
||||
});
|
||||
}
|
||||
|
||||
if (examples.movieFolderExample) {
|
||||
movieFolderFormatHelpTexts.push(
|
||||
`${translate('Example')}: ${examples.movieFolderExample}`
|
||||
);
|
||||
} else {
|
||||
movieFolderFormatErrors.push({ message: translate('InvalidFormat') });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldSet legend={translate('MovieNaming')}>
|
||||
{isFetching ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isFetching && error ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('NamingSettingsLoadError')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{hasSettings && !isFetching && !error ? (
|
||||
<Form>
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('RenameMovies')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="renameMovies"
|
||||
helpText={translate('RenameMoviesHelpText')}
|
||||
onChange={handleInputChange}
|
||||
{...settings.renameMovies}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('ReplaceIllegalCharacters')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="replaceIllegalCharacters"
|
||||
helpText={translate('ReplaceIllegalCharactersHelpText')}
|
||||
onChange={handleInputChange}
|
||||
{...settings.replaceIllegalCharacters}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{replaceIllegalCharacters ? (
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('ColonReplacement')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="colonReplacementFormat"
|
||||
values={colonReplacementOptions}
|
||||
helpText={translate('ColonReplacementFormatHelpText')}
|
||||
onChange={handleInputChange}
|
||||
{...settings.colonReplacementFormat}
|
||||
/>
|
||||
</FormGroup>
|
||||
) : null}
|
||||
|
||||
{renameMovies ? (
|
||||
<FormGroup size={sizes.LARGE}>
|
||||
<FormLabel>{translate('StandardMovieFormat')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
inputClassName={styles.namingInput}
|
||||
type={inputTypes.TEXT}
|
||||
name="standardMovieFormat"
|
||||
buttons={
|
||||
<FormInputButton onPress={onStandardNamingModalOpenClick}>
|
||||
?
|
||||
</FormInputButton>
|
||||
}
|
||||
onChange={handleInputChange}
|
||||
{...settings.standardMovieFormat}
|
||||
helpTexts={standardMovieFormatHelpTexts}
|
||||
errors={[
|
||||
...standardMovieFormatErrors,
|
||||
...settings.standardMovieFormat.errors,
|
||||
]}
|
||||
/>
|
||||
</FormGroup>
|
||||
) : null}
|
||||
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
size={sizes.MEDIUM}
|
||||
>
|
||||
<FormLabel>{translate('MovieFolderFormat')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
inputClassName={styles.namingInput}
|
||||
type={inputTypes.TEXT}
|
||||
name="movieFolderFormat"
|
||||
buttons={
|
||||
<FormInputButton onPress={onMovieFolderNamingModalOpenClick}>
|
||||
?
|
||||
</FormInputButton>
|
||||
}
|
||||
onChange={handleInputChange}
|
||||
{...settings.movieFolderFormat}
|
||||
helpTexts={[
|
||||
translate('MovieFolderFormatHelpText'),
|
||||
...movieFolderFormatHelpTexts,
|
||||
]}
|
||||
errors={[
|
||||
...movieFolderFormatErrors,
|
||||
...settings.movieFolderFormat.errors,
|
||||
]}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{namingModalOptions ? (
|
||||
<NamingModal
|
||||
isOpen={isNamingModalOpen}
|
||||
{...namingModalOptions}
|
||||
value={settings[namingModalOptions.name].value}
|
||||
onInputChange={handleInputChange}
|
||||
onModalClose={setNamingModalClosed}
|
||||
/>
|
||||
) : null}
|
||||
</Form>
|
||||
) : null}
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export default Naming;
|
||||
@@ -1,97 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import { fetchNamingExamples, fetchNamingSettings, setNamingSettingsValue } from 'Store/Actions/settingsActions';
|
||||
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
|
||||
import Naming from './Naming';
|
||||
|
||||
const SECTION = 'naming';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.advancedSettings,
|
||||
(state) => state.settings.namingExamples,
|
||||
createSettingsSectionSelector(SECTION),
|
||||
(advancedSettings, examples, sectionSettings) => {
|
||||
return {
|
||||
advancedSettings,
|
||||
examples: examples.item,
|
||||
examplesPopulated: !_.isEmpty(examples.item),
|
||||
...sectionSettings
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchNamingSettings,
|
||||
setNamingSettingsValue,
|
||||
fetchNamingExamples,
|
||||
clearPendingChanges
|
||||
};
|
||||
|
||||
class NamingConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._namingExampleTimeout = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchNamingSettings();
|
||||
this.props.fetchNamingExamples();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.clearPendingChanges({ section: `settings.${SECTION}` });
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_fetchNamingExamples = () => {
|
||||
this.props.fetchNamingExamples();
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.props.setNamingSettingsValue({ name, value });
|
||||
|
||||
if (this._namingExampleTimeout) {
|
||||
clearTimeout(this._namingExampleTimeout);
|
||||
}
|
||||
|
||||
this._namingExampleTimeout = setTimeout(this._fetchNamingExamples, 1000);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Naming
|
||||
onInputChange={this.onInputChange}
|
||||
onSavePress={this.onSavePress}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
NamingConnector.propTypes = {
|
||||
fetchNamingSettings: PropTypes.func.isRequired,
|
||||
setNamingSettingsValue: PropTypes.func.isRequired,
|
||||
fetchNamingExamples: PropTypes.func.isRequired,
|
||||
clearPendingChanges: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(NamingConnector);
|
||||
@@ -1,506 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import SelectInput from 'Components/Form/SelectInput';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { icons, sizes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import NamingOption from './NamingOption';
|
||||
import styles from './NamingModal.css';
|
||||
|
||||
const separatorOptions = [
|
||||
{
|
||||
key: ' ',
|
||||
get value() {
|
||||
return `${translate('Space')} ( )`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: '.',
|
||||
get value() {
|
||||
return `${translate('Period')} (.)`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: '_',
|
||||
get value() {
|
||||
return `${translate('Underscore')} (_)`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: '-',
|
||||
get value() {
|
||||
return `${translate('Dash')} (-)`;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const caseOptions = [
|
||||
{
|
||||
key: 'title',
|
||||
get value() {
|
||||
return translate('DefaultCase');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'lower',
|
||||
get value() {
|
||||
return translate('Lowercase');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'upper',
|
||||
get value() {
|
||||
return translate('Uppercase');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const fileNameTokens = [
|
||||
{
|
||||
token: '{Movie Title} - {Quality Full}',
|
||||
example: 'Movie Title (2010) - HDTV-720p Proper'
|
||||
}
|
||||
];
|
||||
|
||||
const movieTokens = [
|
||||
{ token: '{Movie Title}', example: 'Movie\'s Title', footNote: 1 },
|
||||
{ token: '{Movie Title:DE}', example: 'Titel des Films', footNote: 1 },
|
||||
{ token: '{Movie CleanTitle}', example: 'Movies Title', footNote: 1 },
|
||||
{ token: '{Movie TitleThe}', example: 'Movie\'s Title, The', footNote: 1 },
|
||||
{ token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
|
||||
{ token: '{Movie CleanOriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
|
||||
{ token: '{Movie TitleFirstCharacter}', example: 'M' },
|
||||
{ token: '{Movie TitleFirstCharacter:DE}', example: 'T' },
|
||||
{ token: '{Movie Collection}', example: 'The Movie Collection', footNote: 1 },
|
||||
{ token: '{Movie Certification}', example: 'R' },
|
||||
{ token: '{Release Year}', example: '2009' }
|
||||
];
|
||||
|
||||
const movieIdTokens = [
|
||||
{ token: '{ImdbId}', example: 'tt12345' },
|
||||
{ token: '{TmdbId}', example: '123456' }
|
||||
];
|
||||
|
||||
const qualityTokens = [
|
||||
{ token: '{Quality Full}', example: 'HDTV-720p Proper' },
|
||||
{ token: '{Quality Title}', example: 'HDTV-720p' }
|
||||
];
|
||||
|
||||
const mediaInfoTokens = [
|
||||
{ token: '{MediaInfo Simple}', example: 'x264 DTS' },
|
||||
{ token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]', footNote: 1 },
|
||||
|
||||
{ token: '{MediaInfo AudioCodec}', example: 'DTS' },
|
||||
{ token: '{MediaInfo AudioChannels}', example: '5.1' },
|
||||
{ token: '{MediaInfo AudioLanguages}', example: '[EN+DE]', footNote: 1 },
|
||||
{ token: '{MediaInfo SubtitleLanguages}', example: '[DE]', footNote: 1 },
|
||||
|
||||
{ token: '{MediaInfo VideoCodec}', example: 'x264' },
|
||||
{ token: '{MediaInfo VideoBitDepth}', example: '10' },
|
||||
{ token: '{MediaInfo VideoDynamicRange}', example: 'HDR' },
|
||||
{ token: '{MediaInfo VideoDynamicRangeType}', example: 'DV HDR10' },
|
||||
{ token: '{MediaInfo 3D}', example: '3D' }
|
||||
];
|
||||
|
||||
const releaseGroupTokens = [
|
||||
{ token: '{Release Group}', example: 'Rls Grp', footNote: 1 }
|
||||
];
|
||||
|
||||
const editionTokens = [
|
||||
{ token: '{Edition Tags}', example: 'IMAX', footNote: 1 }
|
||||
];
|
||||
|
||||
const customFormatTokens = [
|
||||
{ token: '{Custom Formats}', example: 'Surround Sound x264' },
|
||||
{ token: '{Custom Format:FormatName}', example: 'AMZN' }
|
||||
];
|
||||
|
||||
const originalTokens = [
|
||||
{ token: '{Original Title}', example: 'Movie.Title.HDTV.x264-EVOLVE' },
|
||||
{ token: '{Original Filename}', example: 'movie title hdtv.x264-Evolve' }
|
||||
];
|
||||
|
||||
class NamingModal extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._selectionStart = null;
|
||||
this._selectionEnd = null;
|
||||
|
||||
this.state = {
|
||||
separator: ' ',
|
||||
case: 'title'
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onTokenSeparatorChange = (event) => {
|
||||
this.setState({ separator: event.value });
|
||||
};
|
||||
|
||||
onTokenCaseChange = (event) => {
|
||||
this.setState({ case: event.value });
|
||||
};
|
||||
|
||||
onInputSelectionChange = (selectionStart, selectionEnd) => {
|
||||
this._selectionStart = selectionStart;
|
||||
this._selectionEnd = selectionEnd;
|
||||
};
|
||||
|
||||
onOptionPress = ({ isFullFilename, tokenValue }) => {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
onInputChange
|
||||
} = this.props;
|
||||
|
||||
const selectionStart = this._selectionStart;
|
||||
const selectionEnd = this._selectionEnd;
|
||||
|
||||
if (isFullFilename) {
|
||||
onInputChange({ name, value: tokenValue });
|
||||
} else if (selectionStart == null) {
|
||||
onInputChange({
|
||||
name,
|
||||
value: `${value}${tokenValue}`
|
||||
});
|
||||
} else {
|
||||
const start = value.substring(0, selectionStart);
|
||||
const end = value.substring(selectionEnd);
|
||||
const newValue = `${start}${tokenValue}${end}`;
|
||||
|
||||
onInputChange({ name, value: newValue });
|
||||
this._selectionStart = newValue.length - 1;
|
||||
this._selectionEnd = newValue.length - 1;
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
isOpen,
|
||||
advancedSettings,
|
||||
additional,
|
||||
onInputChange,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
separator: tokenSeparator,
|
||||
case: tokenCase
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('FileNameTokens')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className={styles.namingSelectContainer}>
|
||||
<SelectInput
|
||||
className={styles.namingSelect}
|
||||
name="separator"
|
||||
value={tokenSeparator}
|
||||
values={separatorOptions}
|
||||
onChange={this.onTokenSeparatorChange}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
className={styles.namingSelect}
|
||||
name="case"
|
||||
value={tokenCase}
|
||||
values={caseOptions}
|
||||
onChange={this.onTokenCaseChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
!advancedSettings &&
|
||||
<FieldSet legend={translate('FileNames')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
fileNameTokens.map(({ token, example }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
name={name}
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
isFullFilename={true}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
size={sizes.LARGE}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</FieldSet>
|
||||
}
|
||||
|
||||
<FieldSet legend={translate('Movie')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
movieTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
name={name}
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('MovieFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('MovieID')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
movieIdTokens.map(({ token, example }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
name={name}
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
{
|
||||
additional &&
|
||||
<div>
|
||||
<FieldSet legend={translate('Quality')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
qualityTokens.map(({ token, example }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
name={name}
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('MediaInfo')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
mediaInfoTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
name={name}
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('MediaInfoFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('ReleaseGroup')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
releaseGroupTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
name={name}
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('ReleaseGroupFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Edition')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
editionTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
name={name}
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('EditionFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('CustomFormats')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
customFormatTokens.map(({ token, example }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
name={name}
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Original')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
originalTokens.map(({ token, example }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
name={name}
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
size={sizes.LARGE}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</FieldSet>
|
||||
</div>
|
||||
}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<TextInput
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onInputChange}
|
||||
onSelectionChange={this.onInputSelectionChange}
|
||||
/>
|
||||
<Button onPress={onModalClose}>
|
||||
{translate('Close')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
NamingModal.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
advancedSettings: PropTypes.bool.isRequired,
|
||||
additional: PropTypes.bool.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
NamingModal.defaultProps = {
|
||||
additional: false
|
||||
};
|
||||
|
||||
export default NamingModal;
|
||||
469
frontend/src/Settings/MediaManagement/Naming/NamingModal.tsx
Normal file
469
frontend/src/Settings/MediaManagement/Naming/NamingModal.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import SelectInput from 'Components/Form/SelectInput';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { icons, sizes } from 'Helpers/Props';
|
||||
import NamingConfig from 'typings/Settings/NamingConfig';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import NamingOption from './NamingOption';
|
||||
import TokenCase from './TokenCase';
|
||||
import TokenSeparator from './TokenSeparator';
|
||||
import styles from './NamingModal.css';
|
||||
|
||||
const separatorOptions: { key: TokenSeparator; value: string }[] = [
|
||||
{
|
||||
key: ' ',
|
||||
get value() {
|
||||
return `${translate('Space')} ( )`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '.',
|
||||
get value() {
|
||||
return `${translate('Period')} (.)`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '_',
|
||||
get value() {
|
||||
return `${translate('Underscore')} (_)`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '-',
|
||||
get value() {
|
||||
return `${translate('Dash')} (-)`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const caseOptions: { key: TokenCase; value: string }[] = [
|
||||
{
|
||||
key: 'title',
|
||||
get value() {
|
||||
return translate('DefaultCase');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'lower',
|
||||
get value() {
|
||||
return translate('Lowercase');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'upper',
|
||||
get value() {
|
||||
return translate('Uppercase');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const fileNameTokens = [
|
||||
{
|
||||
token:
|
||||
'{Movie Title} ({Release Year}) - {Edition Tags }{[Custom Formats]}{[Quality Full]}{-Release Group}',
|
||||
example:
|
||||
'The Movie - Title (2010) - Ultimate Extended Edition [Surround Sound x264][Bluray-1080p Proper]-EVOLVE',
|
||||
},
|
||||
{
|
||||
token:
|
||||
'{Movie CleanTitle} {Release Year} - {Edition Tags }{[Custom Formats]}{[Quality Full]}{-Release Group}',
|
||||
example:
|
||||
'The Movie Title 2010 - Ultimate Extended Edition [Surround Sound x264][Bluray-1080p Proper]-EVOLVE',
|
||||
},
|
||||
{
|
||||
token:
|
||||
'{Movie.CleanTitle}{.Release.Year}{.Edition.Tags}{.Custom.Formats}{.Quality.Full}{-Release Group}',
|
||||
example:
|
||||
'The.Movie.Title.2010.Ultimate.Extended.Edition.Surround.Sound.x264.Bluray-1080p.Proper-EVOLVE',
|
||||
},
|
||||
];
|
||||
|
||||
const movieTokens = [
|
||||
{ token: '{Movie Title}', example: "Movie's Title", footNote: true },
|
||||
{ token: '{Movie Title:DE}', example: 'Titel des Films', footNote: true },
|
||||
{ token: '{Movie CleanTitle}', example: 'Movies Title', footNote: true },
|
||||
{
|
||||
token: '{Movie CleanTitle:DE}',
|
||||
example: 'Titel des Films',
|
||||
footNote: true,
|
||||
},
|
||||
{ token: '{Movie TitleThe}', example: "Movie's Title, The", footNote: true },
|
||||
{
|
||||
token: '{Movie CleanTitleThe}',
|
||||
example: 'Movies Title, The',
|
||||
footNote: true,
|
||||
},
|
||||
{ token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας', footNote: true },
|
||||
{
|
||||
token: '{Movie CleanOriginalTitle}',
|
||||
example: 'Τίτλος ταινίας',
|
||||
footNote: true,
|
||||
},
|
||||
{ token: '{Movie TitleFirstCharacter}', example: 'M' },
|
||||
{ token: '{Movie TitleFirstCharacter:DE}', example: 'T' },
|
||||
{
|
||||
token: '{Movie Collection}',
|
||||
example: 'The Movie Collection',
|
||||
footNote: true,
|
||||
},
|
||||
{ token: '{Movie Certification}', example: 'R' },
|
||||
{ token: '{Release Year}', example: '2009' },
|
||||
];
|
||||
|
||||
const movieIdTokens = [
|
||||
{ token: '{ImdbId}', example: 'tt12345' },
|
||||
{ token: '{TmdbId}', example: '123456' },
|
||||
];
|
||||
|
||||
const qualityTokens = [
|
||||
{ token: '{Quality Full}', example: 'HDTV-720p Proper' },
|
||||
{ token: '{Quality Title}', example: 'HDTV-720p' },
|
||||
];
|
||||
|
||||
const mediaInfoTokens = [
|
||||
{ token: '{MediaInfo Simple}', example: 'x264 DTS' },
|
||||
{ token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]', footNote: true },
|
||||
|
||||
{ token: '{MediaInfo AudioCodec}', example: 'DTS' },
|
||||
{ token: '{MediaInfo AudioChannels}', example: '5.1' },
|
||||
{ token: '{MediaInfo AudioLanguages}', example: '[EN+DE]', footNote: true },
|
||||
{ token: '{MediaInfo SubtitleLanguages}', example: '[DE]', footNote: true },
|
||||
|
||||
{ token: '{MediaInfo VideoCodec}', example: 'x264' },
|
||||
{ token: '{MediaInfo VideoBitDepth}', example: '10' },
|
||||
{ token: '{MediaInfo VideoDynamicRange}', example: 'HDR' },
|
||||
{ token: '{MediaInfo VideoDynamicRangeType}', example: 'DV HDR10' },
|
||||
{ token: '{MediaInfo 3D}', example: '3D' },
|
||||
];
|
||||
|
||||
const releaseGroupTokens = [
|
||||
{ token: '{Release Group}', example: 'Rls Grp', footNote: true },
|
||||
];
|
||||
|
||||
const editionTokens = [
|
||||
{ token: '{Edition Tags}', example: 'IMAX', footNote: true },
|
||||
];
|
||||
|
||||
const customFormatTokens = [
|
||||
{ token: '{Custom Formats}', example: 'Surround Sound x264' },
|
||||
{ token: '{Custom Format:FormatName}', example: 'AMZN' },
|
||||
];
|
||||
|
||||
const originalTokens = [
|
||||
{ token: '{Original Title}', example: 'Movie.Title.HDTV.x264-EVOLVE' },
|
||||
{ token: '{Original Filename}', example: 'movie title hdtv.x264-Evolve' },
|
||||
];
|
||||
|
||||
interface NamingModalProps {
|
||||
isOpen: boolean;
|
||||
name: keyof Pick<NamingConfig, 'standardMovieFormat' | 'movieFolderFormat'>;
|
||||
value: string;
|
||||
movie?: boolean;
|
||||
additional?: boolean;
|
||||
onInputChange: ({ name, value }: { name: string; value: string }) => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function NamingModal(props: NamingModalProps) {
|
||||
const {
|
||||
isOpen,
|
||||
name,
|
||||
value,
|
||||
movie = false,
|
||||
additional = false,
|
||||
onInputChange,
|
||||
onModalClose,
|
||||
} = props;
|
||||
|
||||
const [tokenSeparator, setTokenSeparator] = useState<TokenSeparator>(' ');
|
||||
const [tokenCase, setTokenCase] = useState<TokenCase>('title');
|
||||
const [selectionStart, setSelectionStart] = useState<number | null>(null);
|
||||
const [selectionEnd, setSelectionEnd] = useState<number | null>(null);
|
||||
|
||||
const handleTokenSeparatorChange = useCallback(
|
||||
({ value }: { value: TokenSeparator }) => {
|
||||
setTokenSeparator(value);
|
||||
},
|
||||
[setTokenSeparator]
|
||||
);
|
||||
|
||||
const handleTokenCaseChange = useCallback(
|
||||
({ value }: { value: TokenCase }) => {
|
||||
setTokenCase(value);
|
||||
},
|
||||
[setTokenCase]
|
||||
);
|
||||
|
||||
const handleInputSelectionChange = useCallback(
|
||||
(selectionStart: number, selectionEnd: number) => {
|
||||
setSelectionStart(selectionStart);
|
||||
setSelectionEnd(selectionEnd);
|
||||
},
|
||||
[setSelectionStart, setSelectionEnd]
|
||||
);
|
||||
|
||||
const handleOptionPress = useCallback(
|
||||
({
|
||||
isFullFilename,
|
||||
tokenValue,
|
||||
}: {
|
||||
isFullFilename: boolean;
|
||||
tokenValue: string;
|
||||
}) => {
|
||||
if (isFullFilename) {
|
||||
onInputChange({ name, value: tokenValue });
|
||||
} else if (selectionStart == null || selectionEnd == null) {
|
||||
onInputChange({
|
||||
name,
|
||||
value: `${value}${tokenValue}`,
|
||||
});
|
||||
} else {
|
||||
const start = value.substring(0, selectionStart);
|
||||
const end = value.substring(selectionEnd);
|
||||
const newValue = `${start}${tokenValue}${end}`;
|
||||
|
||||
onInputChange({ name, value: newValue });
|
||||
|
||||
setSelectionStart(newValue.length - 1);
|
||||
setSelectionEnd(newValue.length - 1);
|
||||
}
|
||||
},
|
||||
[name, value, selectionEnd, selectionStart, onInputChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{movie ? translate('FileNameTokens') : translate('FolderNameTokens')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className={styles.namingSelectContainer}>
|
||||
<SelectInput
|
||||
className={styles.namingSelect}
|
||||
name="separator"
|
||||
value={tokenSeparator}
|
||||
values={separatorOptions}
|
||||
onChange={handleTokenSeparatorChange}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
className={styles.namingSelect}
|
||||
name="case"
|
||||
value={tokenCase}
|
||||
values={caseOptions}
|
||||
onChange={handleTokenCaseChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{movie ? (
|
||||
<FieldSet legend={translate('FileNames')}>
|
||||
<div className={styles.groups}>
|
||||
{fileNameTokens.map(({ token, example }) => (
|
||||
<NamingOption
|
||||
key={token}
|
||||
token={token}
|
||||
example={example}
|
||||
isFullFilename={true}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
size={sizes.LARGE}
|
||||
onPress={handleOptionPress}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</FieldSet>
|
||||
) : null}
|
||||
|
||||
<FieldSet legend={translate('Movie')}>
|
||||
<div className={styles.groups}>
|
||||
{movieTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={handleOptionPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('MovieFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('MovieID')}>
|
||||
<div className={styles.groups}>
|
||||
{movieIdTokens.map(({ token, example }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
token={token}
|
||||
example={example}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={handleOptionPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
{additional ? (
|
||||
<div>
|
||||
<FieldSet legend={translate('Quality')}>
|
||||
<div className={styles.groups}>
|
||||
{qualityTokens.map(({ token, example }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
token={token}
|
||||
example={example}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={handleOptionPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('MediaInfo')}>
|
||||
<div className={styles.groups}>
|
||||
{mediaInfoTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={handleOptionPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('MediaInfoFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('ReleaseGroup')}>
|
||||
<div className={styles.groups}>
|
||||
{releaseGroupTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={handleOptionPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('ReleaseGroupFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Edition')}>
|
||||
<div className={styles.groups}>
|
||||
{editionTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={handleOptionPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('EditionFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('CustomFormats')}>
|
||||
<div className={styles.groups}>
|
||||
{customFormatTokens.map(({ token, example }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
token={token}
|
||||
example={example}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={handleOptionPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Original')}>
|
||||
<div className={styles.groups}>
|
||||
{originalTokens.map(({ token, example }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
token={token}
|
||||
example={example}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
size={sizes.LARGE}
|
||||
onPress={handleOptionPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FieldSet>
|
||||
</div>
|
||||
) : null}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<TextInput
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onInputChange}
|
||||
onSelectionChange={handleInputSelectionChange}
|
||||
/>
|
||||
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default NamingModal;
|
||||
@@ -46,6 +46,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.lower {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ interface CssExports {
|
||||
'lower': string;
|
||||
'option': string;
|
||||
'small': string;
|
||||
'title': string;
|
||||
'token': string;
|
||||
'upper': string;
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons, sizes } from 'Helpers/Props';
|
||||
import styles from './NamingOption.css';
|
||||
|
||||
class NamingOption extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
const {
|
||||
token,
|
||||
tokenSeparator,
|
||||
tokenCase,
|
||||
isFullFilename,
|
||||
onPress
|
||||
} = this.props;
|
||||
|
||||
let tokenValue = token;
|
||||
|
||||
tokenValue = tokenValue.replace(/ /g, tokenSeparator);
|
||||
|
||||
if (tokenCase === 'lower') {
|
||||
tokenValue = token.toLowerCase();
|
||||
} else if (tokenCase === 'upper') {
|
||||
tokenValue = token.toUpperCase();
|
||||
}
|
||||
|
||||
onPress({ isFullFilename, tokenValue });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
render() {
|
||||
const {
|
||||
token,
|
||||
tokenSeparator,
|
||||
example,
|
||||
footNote,
|
||||
tokenCase,
|
||||
isFullFilename,
|
||||
size
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={classNames(
|
||||
styles.option,
|
||||
styles[size],
|
||||
styles[tokenCase],
|
||||
isFullFilename && styles.isFullFilename
|
||||
)}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
<div className={styles.token}>
|
||||
{token.replace(/ /g, tokenSeparator)}
|
||||
</div>
|
||||
|
||||
<div className={styles.example}>
|
||||
{example.replace(/ /g, tokenSeparator)}
|
||||
|
||||
{
|
||||
footNote !== 0 &&
|
||||
<Icon className={styles.footNote} name={icons.FOOTNOTE} />
|
||||
}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
NamingOption.propTypes = {
|
||||
token: PropTypes.string.isRequired,
|
||||
example: PropTypes.string.isRequired,
|
||||
footNote: PropTypes.number.isRequired,
|
||||
tokenSeparator: PropTypes.string.isRequired,
|
||||
tokenCase: PropTypes.string.isRequired,
|
||||
isFullFilename: PropTypes.bool.isRequired,
|
||||
size: PropTypes.oneOf([sizes.SMALL, sizes.LARGE]),
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
NamingOption.defaultProps = {
|
||||
footNote: 0,
|
||||
size: sizes.SMALL,
|
||||
isFullFilename: false
|
||||
};
|
||||
|
||||
export default NamingOption;
|
||||
@@ -0,0 +1,77 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { useCallback } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { Size } from 'Helpers/Props/sizes';
|
||||
import TokenCase from './TokenCase';
|
||||
import TokenSeparator from './TokenSeparator';
|
||||
import styles from './NamingOption.css';
|
||||
|
||||
interface NamingOptionProps {
|
||||
token: string;
|
||||
tokenSeparator: TokenSeparator;
|
||||
example: string;
|
||||
tokenCase: TokenCase;
|
||||
isFullFilename?: boolean;
|
||||
footNote?: boolean;
|
||||
size?: Extract<Size, keyof typeof styles>;
|
||||
onPress: ({
|
||||
isFullFilename,
|
||||
tokenValue,
|
||||
}: {
|
||||
isFullFilename: boolean;
|
||||
tokenValue: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function NamingOption(props: NamingOptionProps) {
|
||||
const {
|
||||
token,
|
||||
tokenSeparator,
|
||||
example,
|
||||
tokenCase,
|
||||
isFullFilename = false,
|
||||
footNote = false,
|
||||
size = 'small',
|
||||
onPress,
|
||||
} = props;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
let tokenValue = token;
|
||||
|
||||
tokenValue = tokenValue.replace(/ /g, tokenSeparator);
|
||||
|
||||
if (tokenCase === 'lower') {
|
||||
tokenValue = token.toLowerCase();
|
||||
} else if (tokenCase === 'upper') {
|
||||
tokenValue = token.toUpperCase();
|
||||
}
|
||||
|
||||
onPress({ isFullFilename, tokenValue });
|
||||
}, [token, tokenCase, tokenSeparator, isFullFilename, onPress]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={classNames(
|
||||
styles.option,
|
||||
styles[size],
|
||||
styles[tokenCase],
|
||||
isFullFilename && styles.isFullFilename
|
||||
)}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<div className={styles.token}>{token.replace(/ /g, tokenSeparator)}</div>
|
||||
|
||||
<div className={styles.example}>
|
||||
{example.replace(/ /g, tokenSeparator)}
|
||||
|
||||
{footNote ? (
|
||||
<Icon className={styles.footNote} name={icons.FOOTNOTE} />
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default NamingOption;
|
||||
@@ -0,0 +1,3 @@
|
||||
type TokenCase = 'title' | 'lower' | 'upper';
|
||||
|
||||
export default TokenCase;
|
||||
@@ -0,0 +1,3 @@
|
||||
type TokenSeparator = ' ' | '.' | '_' | '-';
|
||||
|
||||
export default TokenSeparator;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
130
frontend/src/Settings/Profiles/Release/ReleaseProfileRow.tsx
Normal file
130
frontend/src/Settings/Profiles/Release/ReleaseProfileRow.tsx
Normal 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;
|
||||
@@ -4,7 +4,7 @@
|
||||
}
|
||||
|
||||
.addReleaseProfile {
|
||||
composes: releaseProfile from '~./ReleaseProfile.css';
|
||||
composes: releaseProfile from '~./ReleaseProfileRow.css';
|
||||
|
||||
background-color: var(--cardAlternateBackgroundColor);
|
||||
color: var(--gray);
|
||||
|
||||
@@ -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;
|
||||
81
frontend/src/Settings/Profiles/Release/ReleaseProfiles.tsx
Normal file
81
frontend/src/Settings/Profiles/Release/ReleaseProfiles.tsx
Normal 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;
|
||||
@@ -1,74 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { deleteReleaseProfile, fetchIndexers, fetchReleaseProfiles } from 'Store/Actions/settingsActions';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import ReleaseProfiles from './ReleaseProfiles';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.releaseProfiles,
|
||||
(state) => state.settings.indexers,
|
||||
createTagsSelector(),
|
||||
(releaseProfiles, indexers, tagList) => {
|
||||
return {
|
||||
...releaseProfiles,
|
||||
tagList,
|
||||
isIndexersPopulated: indexers.isPopulated,
|
||||
indexerList: indexers.items
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchIndexers,
|
||||
fetchReleaseProfiles,
|
||||
deleteReleaseProfile
|
||||
};
|
||||
|
||||
class ReleaseProfilesConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.isPopulated) {
|
||||
this.props.fetchReleaseProfiles();
|
||||
}
|
||||
|
||||
if (!this.props.isIndexersPopulated) {
|
||||
this.props.fetchIndexers();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onConfirmDeleteReleaseProfile = (id) => {
|
||||
this.props.deleteReleaseProfile({ id });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ReleaseProfiles
|
||||
{...this.props}
|
||||
onConfirmDeleteReleaseProfile={this.onConfirmDeleteReleaseProfile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ReleaseProfilesConnector.propTypes = {
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
isIndexersPopulated: PropTypes.bool.isRequired,
|
||||
fetchReleaseProfiles: PropTypes.func.isRequired,
|
||||
deleteReleaseProfile: PropTypes.func.isRequired,
|
||||
fetchIndexers: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(ReleaseProfilesConnector);
|
||||
@@ -61,7 +61,8 @@ export const defaultState = {
|
||||
showTitle: false,
|
||||
showTmdbRating: false,
|
||||
showImdbRating: false,
|
||||
showRottenTomatoesRating: false
|
||||
showRottenTomatoesRating: false,
|
||||
showTraktRating: false
|
||||
},
|
||||
|
||||
overviewOptions: {
|
||||
@@ -180,6 +181,12 @@ export const defaultState = {
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'traktRating',
|
||||
label: () => translate('TraktRating'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'popularity',
|
||||
label: () => translate('Popularity'),
|
||||
@@ -293,6 +300,10 @@ export const defaultState = {
|
||||
|
||||
rottenTomatoesRating: function({ ratings = {} }) {
|
||||
return ratings.rottenTomatoes ? ratings.rottenTomatoes.value : -1;
|
||||
},
|
||||
|
||||
traktRating: function({ ratings = {} }) {
|
||||
return ratings.trakt ? ratings.trakt.value : 0;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -482,6 +493,16 @@ export const defaultState = {
|
||||
label: () => translate('ImdbVotes'),
|
||||
type: filterBuilderTypes.NUMBER
|
||||
},
|
||||
{
|
||||
name: 'traktRating',
|
||||
label: () => translate('TraktRating'),
|
||||
type: filterBuilderTypes.NUMBER
|
||||
},
|
||||
{
|
||||
name: 'traktVotes',
|
||||
label: () => translate('TraktVotes'),
|
||||
type: filterBuilderTypes.NUMBER
|
||||
},
|
||||
{
|
||||
name: 'popularity',
|
||||
label: () => translate('Popularity'),
|
||||
|
||||
@@ -156,42 +156,62 @@ export const filterPredicates = {
|
||||
return dateFilterPredicate(item.digitalRelease, filterValue, type);
|
||||
},
|
||||
|
||||
tmdbRating: function(item, filterValue, type) {
|
||||
releaseDate: function(item, filterValue, type) {
|
||||
return dateFilterPredicate(item.releaseDate, filterValue, type);
|
||||
},
|
||||
|
||||
tmdbRating: function({ ratings = {} }, filterValue, type) {
|
||||
const predicate = filterTypePredicates[type];
|
||||
|
||||
const rating = item.ratings.tmdb ? item.ratings.tmdb.value : 0;
|
||||
const rating = ratings.tmdb ? ratings.tmdb.value : 0;
|
||||
|
||||
return predicate(rating * 10, filterValue);
|
||||
},
|
||||
|
||||
tmdbVotes: function(item, filterValue, type) {
|
||||
tmdbVotes: function({ ratings = {} }, filterValue, type) {
|
||||
const predicate = filterTypePredicates[type];
|
||||
|
||||
const rating = item.ratings.tmdb ? item.ratings.tmdb.votes : 0;
|
||||
const rating = ratings.tmdb ? ratings.tmdb.votes : 0;
|
||||
|
||||
return predicate(rating, filterValue);
|
||||
},
|
||||
|
||||
imdbRating: function(item, filterValue, type) {
|
||||
imdbRating: function({ ratings = {} }, filterValue, type) {
|
||||
const predicate = filterTypePredicates[type];
|
||||
|
||||
const rating = item.ratings.imdb ? item.ratings.imdb.value : 0;
|
||||
const rating = ratings.imdb ? ratings.imdb.value : 0;
|
||||
|
||||
return predicate(rating, filterValue);
|
||||
},
|
||||
|
||||
rottenTomatoesRating: function(item, filterValue, type) {
|
||||
imdbVotes: function({ ratings = {} }, filterValue, type) {
|
||||
const predicate = filterTypePredicates[type];
|
||||
|
||||
const rating = item.ratings.rottenTomatoes ? item.ratings.rottenTomatoes.value : 0;
|
||||
const rating = ratings.imdb ? ratings.imdb.votes : 0;
|
||||
|
||||
return predicate(rating, filterValue);
|
||||
},
|
||||
|
||||
imdbVotes: function(item, filterValue, type) {
|
||||
rottenTomatoesRating: function({ ratings = {} }, filterValue, type) {
|
||||
const predicate = filterTypePredicates[type];
|
||||
|
||||
const rating = item.ratings.imdb ? item.ratings.imdb.votes : 0;
|
||||
const rating = ratings.rottenTomatoes ? ratings.rottenTomatoes.value : 0;
|
||||
|
||||
return predicate(rating, filterValue);
|
||||
},
|
||||
|
||||
traktRating: function({ ratings = {} }, filterValue, type) {
|
||||
const predicate = filterTypePredicates[type];
|
||||
|
||||
const rating = ratings.trakt ? ratings.trakt.value : 0;
|
||||
|
||||
return predicate(rating * 10, filterValue);
|
||||
},
|
||||
|
||||
traktVotes: function({ ratings = {} }, filterValue, type) {
|
||||
const predicate = filterTypePredicates[type];
|
||||
|
||||
const rating = ratings.trakt ? ratings.trakt.votes : 0;
|
||||
|
||||
return predicate(rating, filterValue);
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@ export const defaultState = {
|
||||
showTmdbRating: false,
|
||||
showImdbRating: false,
|
||||
showRottenTomatoesRating: false,
|
||||
showTraktRating: false,
|
||||
showTags: false,
|
||||
showSearchAction: false
|
||||
},
|
||||
@@ -158,7 +159,7 @@ export const defaultState = {
|
||||
},
|
||||
{
|
||||
name: 'minimumAvailability',
|
||||
label: () => translate('MinAvailability'),
|
||||
label: () => translate('MinimumAvailability'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
@@ -204,6 +205,12 @@ export const defaultState = {
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'traktRating',
|
||||
label: () => translate('TraktRating'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'popularity',
|
||||
label: () => translate('Popularity'),
|
||||
@@ -278,6 +285,10 @@ export const defaultState = {
|
||||
|
||||
rottenTomatoesRating: function({ ratings = {} }) {
|
||||
return ratings.rottenTomatoes ? ratings.rottenTomatoes.value : -1;
|
||||
},
|
||||
|
||||
traktRating: function({ ratings = {} }) {
|
||||
return ratings.trakt ? ratings.trakt.value : 0;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -492,14 +503,24 @@ export const defaultState = {
|
||||
type: filterBuilderTypes.NUMBER,
|
||||
numberFractionDigits: 1
|
||||
},
|
||||
{
|
||||
name: 'imdbVotes',
|
||||
label: () => translate('ImdbVotes'),
|
||||
type: filterBuilderTypes.NUMBER
|
||||
},
|
||||
{
|
||||
name: 'rottenTomatoesRating',
|
||||
label: () => translate('RottenTomatoesRating'),
|
||||
type: filterBuilderTypes.NUMBER
|
||||
},
|
||||
{
|
||||
name: 'imdbVotes',
|
||||
label: () => translate('ImdbVotes'),
|
||||
name: 'traktRating',
|
||||
label: () => translate('TraktRating'),
|
||||
type: filterBuilderTypes.NUMBER
|
||||
},
|
||||
{
|
||||
name: 'traktVotes',
|
||||
label: () => translate('TraktVotes'),
|
||||
type: filterBuilderTypes.NUMBER
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
let i = 0;
|
||||
|
||||
// returns a HTML 4.0 compliant element IDs (http://stackoverflow.com/a/79022)
|
||||
|
||||
/**
|
||||
* @deprecated Use React's useId() instead
|
||||
* @returns An HTML 4.0 compliant element IDs (http://stackoverflow.com/a/79022)
|
||||
*/
|
||||
export default function getUniqueElementId() {
|
||||
return `id-${i++}`;
|
||||
}
|
||||
|
||||
14
frontend/src/typings/Settings/NamingConfig.ts
Normal file
14
frontend/src/typings/Settings/NamingConfig.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
type ColonReplacementFormat =
|
||||
| 'delete'
|
||||
| 'dash'
|
||||
| 'spaceDash'
|
||||
| 'spaceDashSpace'
|
||||
| 'smart';
|
||||
|
||||
export default interface NamingConfig {
|
||||
renameMovies: boolean;
|
||||
replaceIllegalCharacters: boolean;
|
||||
colonReplacementFormat: ColonReplacementFormat;
|
||||
standardMovieFormat: string;
|
||||
movieFolderFormat: string;
|
||||
}
|
||||
4
frontend/src/typings/Settings/NamingExample.ts
Normal file
4
frontend/src/typings/Settings/NamingExample.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default interface NamingExample {
|
||||
movieExample: string;
|
||||
movieFolderExample: string;
|
||||
}
|
||||
12
frontend/src/typings/Settings/ReleaseProfile.ts
Normal file
12
frontend/src/typings/Settings/ReleaseProfile.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
|
||||
interface ReleaseProfile extends ModelBase {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
required: string[];
|
||||
ignored: string[];
|
||||
indexerId: number;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
export default ReleaseProfile;
|
||||
98
package.json
98
package.json
@@ -22,48 +22,47 @@
|
||||
"defaults"
|
||||
],
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "6.4.0",
|
||||
"@fortawesome/fontawesome-svg-core": "6.4.0",
|
||||
"@fortawesome/fontawesome-free": "6.6.0",
|
||||
"@fortawesome/fontawesome-svg-core": "6.6.0",
|
||||
"@fortawesome/free-brands-svg-icons": "6.6.0",
|
||||
"@fortawesome/free-regular-svg-icons": "6.4.0",
|
||||
"@fortawesome/free-solid-svg-icons": "6.4.0",
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@fortawesome/free-regular-svg-icons": "6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "6.6.0",
|
||||
"@fortawesome/react-fontawesome": "0.2.2",
|
||||
"@juggle/resize-observer": "3.4.0",
|
||||
"@microsoft/signalr": "6.0.25",
|
||||
"@sentry/browser": "7.100.0",
|
||||
"@sentry/integrations": "7.100.0",
|
||||
"@types/node": "18.19.31",
|
||||
"@sentry/browser": "7.119.1",
|
||||
"@sentry/integrations": "7.119.1",
|
||||
"@types/node": "20.16.11",
|
||||
"@types/react": "18.2.79",
|
||||
"@types/react-dom": "18.2.25",
|
||||
"classnames": "2.3.2",
|
||||
"clipboard": "2.0.11",
|
||||
"classnames": "2.5.1",
|
||||
"connected-react-router": "6.9.3",
|
||||
"copy-to-clipboard": "3.3.3",
|
||||
"element-class": "0.2.2",
|
||||
"filesize": "10.0.7",
|
||||
"filesize": "10.1.6",
|
||||
"fuse.js": "6.6.2",
|
||||
"history": "4.10.1",
|
||||
"https-browserify": "1.0.0",
|
||||
"jdu": "1.0.0",
|
||||
"jquery": "3.7.0",
|
||||
"jquery": "3.7.1",
|
||||
"lodash": "4.17.21",
|
||||
"mobile-detect": "1.4.5",
|
||||
"moment": "2.29.4",
|
||||
"moment": "2.30.1",
|
||||
"mousetrap": "1.6.5",
|
||||
"normalize.css": "8.0.1",
|
||||
"prop-types": "15.8.1",
|
||||
"qs": "6.11.1",
|
||||
"qs": "6.13.0",
|
||||
"react": "17.0.2",
|
||||
"react-addons-shallow-compare": "15.6.3",
|
||||
"react-async-script": "1.2.0",
|
||||
"react-autosuggest": "10.1.0",
|
||||
"react-custom-scrollbars-2": "4.5.0",
|
||||
"react-dnd": "14.0.2",
|
||||
"react-dnd-html5-backend": "14.0.0",
|
||||
"react-dnd": "14.0.4",
|
||||
"react-dnd-html5-backend": "14.0.2",
|
||||
"react-dnd-multi-backend": "6.0.2",
|
||||
"react-dnd-touch-backend": "14.0.0",
|
||||
"react-dnd-touch-backend": "14.1.1",
|
||||
"react-document-title": "2.0.3",
|
||||
"react-dom": "17.0.2",
|
||||
"react-focus-lock": "2.5.0",
|
||||
"react-focus-lock": "2.9.4",
|
||||
"react-google-recaptcha": "2.1.0",
|
||||
"react-lazyload": "3.2.0",
|
||||
"react-measure": "1.4.7",
|
||||
@@ -72,61 +71,60 @@
|
||||
"react-router": "5.2.0",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-slider": "1.1.4",
|
||||
"react-tabs": "3.2.2",
|
||||
"react-text-truncate": "0.18.0",
|
||||
"react-tabs": "4.3.0",
|
||||
"react-text-truncate": "0.19.0",
|
||||
"react-use-measure": "2.1.1",
|
||||
"react-virtualized": "9.21.1",
|
||||
"react-window": "1.8.9",
|
||||
"react-window": "1.8.10",
|
||||
"redux": "4.2.1",
|
||||
"redux-actions": "2.6.5",
|
||||
"redux-batched-actions": "0.5.0",
|
||||
"redux-localstorage": "0.4.1",
|
||||
"redux-thunk": "2.3.0",
|
||||
"redux-thunk": "2.4.2",
|
||||
"reselect": "4.1.8",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"swiper": "8.3.2",
|
||||
"typescript": "5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.25.2",
|
||||
"@babel/eslint-parser": "7.25.1",
|
||||
"@babel/plugin-proposal-export-default-from": "7.24.7",
|
||||
"@babel/core": "7.25.8",
|
||||
"@babel/eslint-parser": "7.25.8",
|
||||
"@babel/plugin-proposal-export-default-from": "7.25.8",
|
||||
"@babel/plugin-syntax-dynamic-import": "7.8.3",
|
||||
"@babel/preset-env": "7.25.3",
|
||||
"@babel/preset-react": "7.24.7",
|
||||
"@babel/preset-typescript": "7.24.7",
|
||||
"@babel/preset-env": "7.25.8",
|
||||
"@babel/preset-react": "7.25.7",
|
||||
"@babel/preset-typescript": "7.25.7",
|
||||
"@types/lodash": "4.14.195",
|
||||
"@types/react-lazyload": "3.2.0",
|
||||
"@types/react-lazyload": "3.2.3",
|
||||
"@types/react-router-dom": "5.3.3",
|
||||
"@types/react-text-truncate": "0.14.1",
|
||||
"@types/react-window": "1.8.5",
|
||||
"@types/redux-actions": "2.6.2",
|
||||
"@types/webpack-livereload-plugin": "2.3.3",
|
||||
"@types/react-text-truncate": "0.19.0",
|
||||
"@types/react-window": "1.8.8",
|
||||
"@types/redux-actions": "2.6.5",
|
||||
"@types/webpack-livereload-plugin": "2.3.6",
|
||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||
"@typescript-eslint/parser": "6.21.0",
|
||||
"autoprefixer": "10.4.20",
|
||||
"babel-loader": "9.1.3",
|
||||
"babel-loader": "9.2.1",
|
||||
"babel-plugin-inline-classnames": "2.0.1",
|
||||
"babel-plugin-transform-react-remove-prop-types": "0.4.24",
|
||||
"core-js": "3.38.1",
|
||||
"css-loader": "6.7.3",
|
||||
"css-modules-typescript-loader": "4.0.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-prettier": "8.10.0",
|
||||
"eslint-plugin-filenames": "1.3.2",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-json": "3.1.0",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-react": "7.34.1",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.0",
|
||||
"eslint-plugin-react": "7.37.1",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
"file-loader": "6.2.0",
|
||||
"filemanager-webpack-plugin": "8.0.0",
|
||||
"fork-ts-checker-webpack-plugin": "8.0.0",
|
||||
"html-webpack-plugin": "5.5.3",
|
||||
"html-webpack-plugin": "5.6.0",
|
||||
"loader-utils": "^3.2.1",
|
||||
"mini-css-extract-plugin": "2.7.6",
|
||||
"postcss": "8.4.41",
|
||||
"mini-css-extract-plugin": "2.9.1",
|
||||
"postcss": "8.4.47",
|
||||
"postcss-color-function": "4.1.0",
|
||||
"postcss-loader": "7.3.0",
|
||||
"postcss-mixins": "9.0.4",
|
||||
@@ -135,17 +133,15 @@
|
||||
"postcss-url": "10.1.3",
|
||||
"prettier": "2.8.8",
|
||||
"require-nocache": "1.0.0",
|
||||
"rimraf": "4.4.1",
|
||||
"run-sequence": "2.2.1",
|
||||
"streamqueue": "1.1.2",
|
||||
"rimraf": "6.0.1",
|
||||
"style-loader": "3.3.2",
|
||||
"stylelint": "15.6.1",
|
||||
"stylelint-order": "6.0.3",
|
||||
"terser-webpack-plugin": "5.3.9",
|
||||
"ts-loader": "9.4.2",
|
||||
"stylelint-order": "6.0.4",
|
||||
"terser-webpack-plugin": "5.3.10",
|
||||
"ts-loader": "9.5.1",
|
||||
"typescript-plugin-css-modules": "5.0.1",
|
||||
"url-loader": "4.1.1",
|
||||
"webpack": "5.88.2",
|
||||
"webpack": "5.95.0",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-livereload-plugin": "3.0.2",
|
||||
"worker-loader": "3.0.8"
|
||||
|
||||
@@ -221,7 +221,7 @@
|
||||
<PropertyGroup Condition="'$(IsOSX)' == 'true' and
|
||||
'$(RuntimeIdentifier)' == ''">
|
||||
<_UsingDefaultRuntimeIdentifier>true</_UsingDefaultRuntimeIdentifier>
|
||||
<RuntimeIdentifier>osx-x64</RuntimeIdentifier>
|
||||
<RuntimeIdentifier>osx-$(Architecture)</RuntimeIdentifier>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -92,6 +92,10 @@ namespace NzbDrone.Common.Test.InstrumentationTests
|
||||
[TestCase(@"https://discord.com/api/webhooks/mySecret")]
|
||||
[TestCase(@"https://discord.com/api/webhooks/mySecret/01233210")]
|
||||
|
||||
// Telegram
|
||||
[TestCase(@"https://api.telegram.org/bot1234567890:mySecret/sendmessage: chat_id=123456&parse_mode=HTML&text=<text>")]
|
||||
[TestCase(@"https://api.telegram.org/bot1234567890:mySecret/")]
|
||||
|
||||
public void should_clean_message(string message)
|
||||
{
|
||||
var cleansedMessage = CleanseLogMessage.Cleanse(message);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user