Merge d10285e40d
into d04bb5333a
commit
a120c0da29
@ -0,0 +1,4 @@
|
|||||||
|
.row {
|
||||||
|
composes: link from '~Components/Link/Link.css';
|
||||||
|
composes: row from '~./VirtualTableRow.css';
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
// This file is automatically generated.
|
||||||
|
// Please do not change this file!
|
||||||
|
interface CssExports {
|
||||||
|
'row': string;
|
||||||
|
}
|
||||||
|
export const cssExports: CssExports;
|
||||||
|
export default cssExports;
|
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import VirtualTableRow from './VirtualTableRow';
|
||||||
|
import styles from './VirtualTableRowButton.css';
|
||||||
|
|
||||||
|
function VirtualTableRowButton(props) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className={styles.row}
|
||||||
|
component={VirtualTableRow}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VirtualTableRowButton;
|
@ -0,0 +1,7 @@
|
|||||||
|
enum DownloadProtocol {
|
||||||
|
Unknown = 'unknown',
|
||||||
|
Usenet = 'usenet',
|
||||||
|
Torrent = 'torrent',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DownloadProtocol;
|
@ -1,283 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
|
|
||||||
import AlbumFormats from 'Album/AlbumFormats';
|
|
||||||
import TrackQuality from 'Album/TrackQuality';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import Link from 'Components/Link/Link';
|
|
||||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
|
||||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
|
||||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
|
||||||
import TableRow from 'Components/Table/TableRow';
|
|
||||||
import Popover from 'Components/Tooltip/Popover';
|
|
||||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
|
||||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
|
||||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
|
||||||
import formatAge from 'Utilities/Number/formatAge';
|
|
||||||
import formatBytes from 'Utilities/Number/formatBytes';
|
|
||||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import Peers from './Peers';
|
|
||||||
import styles from './InteractiveSearchRow.css';
|
|
||||||
|
|
||||||
function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
|
|
||||||
if (isGrabbing) {
|
|
||||||
return icons.SPINNER;
|
|
||||||
} else if (isGrabbed) {
|
|
||||||
return icons.DOWNLOADING;
|
|
||||||
} else if (grabError) {
|
|
||||||
return icons.DOWNLOADING;
|
|
||||||
}
|
|
||||||
|
|
||||||
return icons.DOWNLOAD;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDownloadKind(isGrabbed, grabError, downloadAllowed) {
|
|
||||||
if (isGrabbed) {
|
|
||||||
return kinds.SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (grabError || !downloadAllowed) {
|
|
||||||
return kinds.DANGER;
|
|
||||||
}
|
|
||||||
|
|
||||||
return kinds.DEFAULT;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
|
|
||||||
if (isGrabbing) {
|
|
||||||
return '';
|
|
||||||
} else if (isGrabbed) {
|
|
||||||
return 'Added to downloaded queue';
|
|
||||||
} else if (grabError) {
|
|
||||||
return grabError;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Add to downloaded queue';
|
|
||||||
}
|
|
||||||
|
|
||||||
class InteractiveSearchRow extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isConfirmGrabModalOpen: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onGrabPress = () => {
|
|
||||||
const {
|
|
||||||
guid,
|
|
||||||
indexerId,
|
|
||||||
onGrabPress
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
onGrabPress({
|
|
||||||
guid,
|
|
||||||
indexerId
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onConfirmGrabPress = () => {
|
|
||||||
this.setState({ isConfirmGrabModalOpen: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
onGrabConfirm = () => {
|
|
||||||
this.setState({ isConfirmGrabModalOpen: false });
|
|
||||||
|
|
||||||
const {
|
|
||||||
guid,
|
|
||||||
indexerId,
|
|
||||||
searchPayload,
|
|
||||||
onGrabPress
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
onGrabPress({
|
|
||||||
guid,
|
|
||||||
indexerId,
|
|
||||||
...searchPayload
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onGrabCancel = () => {
|
|
||||||
this.setState({ isConfirmGrabModalOpen: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
protocol,
|
|
||||||
age,
|
|
||||||
ageHours,
|
|
||||||
ageMinutes,
|
|
||||||
publishDate,
|
|
||||||
title,
|
|
||||||
infoUrl,
|
|
||||||
indexer,
|
|
||||||
size,
|
|
||||||
seeders,
|
|
||||||
leechers,
|
|
||||||
quality,
|
|
||||||
customFormatScore,
|
|
||||||
customFormats,
|
|
||||||
rejections,
|
|
||||||
downloadAllowed,
|
|
||||||
isGrabbing,
|
|
||||||
isGrabbed,
|
|
||||||
longDateFormat,
|
|
||||||
timeFormat,
|
|
||||||
grabError
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableRowCell className={styles.protocol}>
|
|
||||||
<ProtocolLabel
|
|
||||||
protocol={protocol}
|
|
||||||
/>
|
|
||||||
</TableRowCell>
|
|
||||||
|
|
||||||
<TableRowCell
|
|
||||||
className={styles.age}
|
|
||||||
title={formatDateTime(publishDate, longDateFormat, timeFormat, { includeSeconds: true })}
|
|
||||||
>
|
|
||||||
{formatAge(age, ageHours, ageMinutes)}
|
|
||||||
</TableRowCell>
|
|
||||||
|
|
||||||
<TableRowCell className={styles.title}>
|
|
||||||
<Link to={infoUrl}>
|
|
||||||
{title}
|
|
||||||
</Link>
|
|
||||||
</TableRowCell>
|
|
||||||
|
|
||||||
<TableRowCell className={styles.indexer}>
|
|
||||||
{indexer}
|
|
||||||
</TableRowCell>
|
|
||||||
|
|
||||||
<TableRowCell className={styles.size}>
|
|
||||||
{formatBytes(size)}
|
|
||||||
</TableRowCell>
|
|
||||||
|
|
||||||
<TableRowCell className={styles.peers}>
|
|
||||||
{
|
|
||||||
protocol === 'torrent' &&
|
|
||||||
<Peers
|
|
||||||
seeders={seeders}
|
|
||||||
leechers={leechers}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</TableRowCell>
|
|
||||||
|
|
||||||
<TableRowCell className={styles.quality}>
|
|
||||||
<TrackQuality quality={quality} showRevision={true} />
|
|
||||||
</TableRowCell>
|
|
||||||
|
|
||||||
<TableRowCell className={styles.customFormatScore}>
|
|
||||||
<Tooltip
|
|
||||||
anchor={
|
|
||||||
formatCustomFormatScore(customFormatScore, customFormats.length)
|
|
||||||
}
|
|
||||||
tooltip={<AlbumFormats formats={customFormats} />}
|
|
||||||
position={tooltipPositions.BOTTOM}
|
|
||||||
/>
|
|
||||||
</TableRowCell>
|
|
||||||
|
|
||||||
<TableRowCell className={styles.rejected}>
|
|
||||||
{
|
|
||||||
!!rejections.length &&
|
|
||||||
<Popover
|
|
||||||
anchor={
|
|
||||||
<Icon
|
|
||||||
name={icons.DANGER}
|
|
||||||
kind={kinds.DANGER}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
title={translate('ReleaseRejected')}
|
|
||||||
body={
|
|
||||||
<ul>
|
|
||||||
{
|
|
||||||
rejections.map((rejection, index) => {
|
|
||||||
return (
|
|
||||||
<li key={index}>
|
|
||||||
{rejection}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
position={tooltipPositions.LEFT}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</TableRowCell>
|
|
||||||
|
|
||||||
<TableRowCell className={styles.download}>
|
|
||||||
{
|
|
||||||
<SpinnerIconButton
|
|
||||||
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
|
|
||||||
kind={getDownloadKind(isGrabbed, grabError, downloadAllowed)}
|
|
||||||
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
|
|
||||||
isSpinning={isGrabbing}
|
|
||||||
onPress={downloadAllowed ? this.onGrabPress : this.onConfirmGrabPress}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</TableRowCell>
|
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={this.state.isConfirmGrabModalOpen}
|
|
||||||
kind={kinds.WARNING}
|
|
||||||
title={translate('GrabRelease')}
|
|
||||||
message={translate('GrabReleaseMessageText', [title])}
|
|
||||||
confirmLabel={translate('Grab')}
|
|
||||||
onConfirm={this.onGrabConfirm}
|
|
||||||
onCancel={this.onGrabCancel}
|
|
||||||
/>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
InteractiveSearchRow.propTypes = {
|
|
||||||
guid: PropTypes.string.isRequired,
|
|
||||||
protocol: PropTypes.string.isRequired,
|
|
||||||
age: PropTypes.number.isRequired,
|
|
||||||
ageHours: PropTypes.number.isRequired,
|
|
||||||
ageMinutes: PropTypes.number.isRequired,
|
|
||||||
publishDate: PropTypes.string.isRequired,
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
infoUrl: PropTypes.string.isRequired,
|
|
||||||
indexer: PropTypes.string.isRequired,
|
|
||||||
indexerId: PropTypes.number.isRequired,
|
|
||||||
size: PropTypes.number.isRequired,
|
|
||||||
seeders: PropTypes.number,
|
|
||||||
leechers: PropTypes.number,
|
|
||||||
quality: PropTypes.object.isRequired,
|
|
||||||
customFormats: PropTypes.arrayOf(PropTypes.object),
|
|
||||||
customFormatScore: PropTypes.number.isRequired,
|
|
||||||
rejections: PropTypes.arrayOf(PropTypes.string).isRequired,
|
|
||||||
downloadAllowed: PropTypes.bool.isRequired,
|
|
||||||
isGrabbing: PropTypes.bool.isRequired,
|
|
||||||
isGrabbed: PropTypes.bool.isRequired,
|
|
||||||
grabError: PropTypes.string,
|
|
||||||
longDateFormat: PropTypes.string.isRequired,
|
|
||||||
timeFormat: PropTypes.string.isRequired,
|
|
||||||
searchPayload: PropTypes.object.isRequired,
|
|
||||||
onGrabPress: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
InteractiveSearchRow.defaultProps = {
|
|
||||||
rejections: [],
|
|
||||||
isGrabbing: false,
|
|
||||||
isGrabbed: false
|
|
||||||
};
|
|
||||||
|
|
||||||
export default InteractiveSearchRow;
|
|
@ -0,0 +1,298 @@
|
|||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
|
||||||
|
import AlbumFormats from 'Album/AlbumFormats';
|
||||||
|
import TrackQuality from 'Album/TrackQuality';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||||
|
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||||
|
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||||
|
import TableRow from 'Components/Table/TableRow';
|
||||||
|
import Popover from 'Components/Tooltip/Popover';
|
||||||
|
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||||
|
import type DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||||
|
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||||
|
import { QualityModel } from 'Quality/Quality';
|
||||||
|
import CustomFormat from 'typings/CustomFormat';
|
||||||
|
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||||
|
import formatAge from 'Utilities/Number/formatAge';
|
||||||
|
import formatBytes from 'Utilities/Number/formatBytes';
|
||||||
|
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import OverrideMatchModal from './OverrideMatch/OverrideMatchModal';
|
||||||
|
import Peers from './Peers';
|
||||||
|
import ReleaseAlbum from './ReleaseAlbum';
|
||||||
|
import styles from './InteractiveSearchRow.css';
|
||||||
|
|
||||||
|
function getDownloadIcon(
|
||||||
|
isGrabbing: boolean,
|
||||||
|
isGrabbed: boolean,
|
||||||
|
grabError?: string
|
||||||
|
) {
|
||||||
|
if (isGrabbing) {
|
||||||
|
return icons.SPINNER;
|
||||||
|
} else if (isGrabbed) {
|
||||||
|
return icons.DOWNLOADING;
|
||||||
|
} else if (grabError) {
|
||||||
|
return icons.DOWNLOADING;
|
||||||
|
}
|
||||||
|
|
||||||
|
return icons.DOWNLOAD;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDownloadKind(isGrabbed: boolean, grabError?: string) {
|
||||||
|
if (isGrabbed) {
|
||||||
|
return kinds.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grabError) {
|
||||||
|
return kinds.DANGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
return kinds.DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDownloadTooltip(
|
||||||
|
isGrabbing: boolean,
|
||||||
|
isGrabbed: boolean,
|
||||||
|
grabError?: string
|
||||||
|
) {
|
||||||
|
if (isGrabbing) {
|
||||||
|
return '';
|
||||||
|
} else if (isGrabbed) {
|
||||||
|
return translate('AddedToDownloadQueue');
|
||||||
|
} else if (grabError) {
|
||||||
|
return grabError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return translate('AddToDownloadQueue');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InteractiveSearchRowProps {
|
||||||
|
guid: string;
|
||||||
|
protocol: DownloadProtocol;
|
||||||
|
age: number;
|
||||||
|
ageHours: number;
|
||||||
|
ageMinutes: number;
|
||||||
|
publishDate: string;
|
||||||
|
title: string;
|
||||||
|
infoUrl: string;
|
||||||
|
indexerId: number;
|
||||||
|
indexer: string;
|
||||||
|
size: number;
|
||||||
|
seeders?: number;
|
||||||
|
leechers?: number;
|
||||||
|
quality: QualityModel;
|
||||||
|
customFormats: CustomFormat[];
|
||||||
|
customFormatScore: number;
|
||||||
|
mappedArtistId?: number;
|
||||||
|
mappedAlbumInfo: ReleaseAlbum[];
|
||||||
|
rejections: string[];
|
||||||
|
downloadAllowed: boolean;
|
||||||
|
isGrabbing: boolean;
|
||||||
|
isGrabbed: boolean;
|
||||||
|
grabError?: string;
|
||||||
|
longDateFormat: string;
|
||||||
|
timeFormat: string;
|
||||||
|
searchPayload: object;
|
||||||
|
onGrabPress(...args: unknown[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InteractiveSearchRow(props: InteractiveSearchRowProps) {
|
||||||
|
const {
|
||||||
|
guid,
|
||||||
|
indexerId,
|
||||||
|
protocol,
|
||||||
|
age,
|
||||||
|
ageHours,
|
||||||
|
ageMinutes,
|
||||||
|
publishDate,
|
||||||
|
title,
|
||||||
|
infoUrl,
|
||||||
|
indexer,
|
||||||
|
size,
|
||||||
|
seeders,
|
||||||
|
leechers,
|
||||||
|
quality,
|
||||||
|
customFormatScore,
|
||||||
|
customFormats,
|
||||||
|
mappedArtistId,
|
||||||
|
mappedAlbumInfo,
|
||||||
|
rejections = [],
|
||||||
|
downloadAllowed,
|
||||||
|
isGrabbing = false,
|
||||||
|
isGrabbed = false,
|
||||||
|
longDateFormat,
|
||||||
|
timeFormat,
|
||||||
|
grabError,
|
||||||
|
searchPayload,
|
||||||
|
onGrabPress,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [isConfirmGrabModalOpen, setIsConfirmGrabModalOpen] = useState(false);
|
||||||
|
const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const onGrabPressWrapper = useCallback(() => {
|
||||||
|
if (downloadAllowed) {
|
||||||
|
onGrabPress({
|
||||||
|
guid,
|
||||||
|
indexerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConfirmGrabModalOpen(true);
|
||||||
|
}, [
|
||||||
|
guid,
|
||||||
|
indexerId,
|
||||||
|
downloadAllowed,
|
||||||
|
onGrabPress,
|
||||||
|
setIsConfirmGrabModalOpen,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onGrabConfirm = useCallback(() => {
|
||||||
|
setIsConfirmGrabModalOpen(false);
|
||||||
|
|
||||||
|
onGrabPress({
|
||||||
|
guid,
|
||||||
|
indexerId,
|
||||||
|
...searchPayload,
|
||||||
|
});
|
||||||
|
}, [guid, indexerId, searchPayload, onGrabPress, setIsConfirmGrabModalOpen]);
|
||||||
|
|
||||||
|
const onGrabCancel = useCallback(() => {
|
||||||
|
setIsConfirmGrabModalOpen(false);
|
||||||
|
}, [setIsConfirmGrabModalOpen]);
|
||||||
|
|
||||||
|
const onOverridePress = useCallback(() => {
|
||||||
|
setIsOverrideModalOpen(true);
|
||||||
|
}, [setIsOverrideModalOpen]);
|
||||||
|
|
||||||
|
const onOverrideModalClose = useCallback(() => {
|
||||||
|
setIsOverrideModalOpen(false);
|
||||||
|
}, [setIsOverrideModalOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableRowCell className={styles.protocol}>
|
||||||
|
<ProtocolLabel protocol={protocol} />
|
||||||
|
</TableRowCell>
|
||||||
|
|
||||||
|
<TableRowCell
|
||||||
|
className={styles.age}
|
||||||
|
title={formatDateTime(publishDate, longDateFormat, timeFormat, {
|
||||||
|
includeSeconds: true,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{formatAge(age, ageHours, ageMinutes)}
|
||||||
|
</TableRowCell>
|
||||||
|
|
||||||
|
<TableRowCell>
|
||||||
|
<div className={styles.titleContent}>
|
||||||
|
<Link to={infoUrl}>{title}</Link>
|
||||||
|
</div>
|
||||||
|
</TableRowCell>
|
||||||
|
|
||||||
|
<TableRowCell className={styles.indexer}>{indexer}</TableRowCell>
|
||||||
|
|
||||||
|
<TableRowCell className={styles.size}>{formatBytes(size)}</TableRowCell>
|
||||||
|
|
||||||
|
<TableRowCell className={styles.peers}>
|
||||||
|
{protocol === 'torrent' ? (
|
||||||
|
<Peers seeders={seeders} leechers={leechers} />
|
||||||
|
) : null}
|
||||||
|
</TableRowCell>
|
||||||
|
|
||||||
|
<TableRowCell className={styles.quality}>
|
||||||
|
<TrackQuality quality={quality} showRevision={true} />
|
||||||
|
</TableRowCell>
|
||||||
|
|
||||||
|
<TableRowCell className={styles.customFormatScore}>
|
||||||
|
<Tooltip
|
||||||
|
anchor={formatCustomFormatScore(
|
||||||
|
customFormatScore,
|
||||||
|
customFormats.length
|
||||||
|
)}
|
||||||
|
tooltip={<AlbumFormats formats={customFormats} />}
|
||||||
|
position={tooltipPositions.BOTTOM}
|
||||||
|
/>
|
||||||
|
</TableRowCell>
|
||||||
|
|
||||||
|
<TableRowCell className={styles.rejected}>
|
||||||
|
{rejections.length ? (
|
||||||
|
<Popover
|
||||||
|
anchor={<Icon name={icons.DANGER} kind={kinds.DANGER} />}
|
||||||
|
title={translate('ReleaseRejected')}
|
||||||
|
body={
|
||||||
|
<ul>
|
||||||
|
{rejections.map((rejection, index) => {
|
||||||
|
return <li key={index}>{rejection}</li>;
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
position={tooltipPositions.LEFT}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</TableRowCell>
|
||||||
|
|
||||||
|
<TableRowCell className={styles.download}>
|
||||||
|
<SpinnerIconButton
|
||||||
|
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
|
||||||
|
kind={getDownloadKind(isGrabbed, grabError)}
|
||||||
|
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
|
||||||
|
isSpinning={isGrabbing}
|
||||||
|
onPress={onGrabPressWrapper}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
className={styles.manualDownloadContent}
|
||||||
|
title={translate('OverrideAndAddToDownloadQueue')}
|
||||||
|
onPress={onOverridePress}
|
||||||
|
>
|
||||||
|
<div className={styles.manualDownloadContent}>
|
||||||
|
<Icon
|
||||||
|
className={styles.interactiveIcon}
|
||||||
|
name={icons.INTERACTIVE}
|
||||||
|
size={12}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
className={styles.downloadIcon}
|
||||||
|
name={icons.CIRCLE_DOWN}
|
||||||
|
size={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</TableRowCell>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={isConfirmGrabModalOpen}
|
||||||
|
kind={kinds.WARNING}
|
||||||
|
title={translate('GrabRelease')}
|
||||||
|
message={translate('GrabReleaseUnknownArtistOrAlbumMessageText', {
|
||||||
|
title,
|
||||||
|
})}
|
||||||
|
confirmLabel={translate('Grab')}
|
||||||
|
onConfirm={onGrabConfirm}
|
||||||
|
onCancel={onGrabCancel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OverrideMatchModal
|
||||||
|
isOpen={isOverrideModalOpen}
|
||||||
|
title={title}
|
||||||
|
indexerId={indexerId}
|
||||||
|
guid={guid}
|
||||||
|
artistId={mappedArtistId}
|
||||||
|
albums={mappedAlbumInfo}
|
||||||
|
quality={quality}
|
||||||
|
protocol={protocol}
|
||||||
|
isGrabbing={isGrabbing}
|
||||||
|
grabError={grabError}
|
||||||
|
onModalClose={onOverrideModalClose}
|
||||||
|
/>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InteractiveSearchRow;
|
@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||||
|
import { sizes } from 'Helpers/Props';
|
||||||
|
import SelectDownloadClientModalContent from './SelectDownloadClientModalContent';
|
||||||
|
|
||||||
|
interface SelectDownloadClientModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
protocol: DownloadProtocol;
|
||||||
|
modalTitle: string;
|
||||||
|
onDownloadClientSelect(downloadClientId: number): void;
|
||||||
|
onModalClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectDownloadClientModal(props: SelectDownloadClientModalProps) {
|
||||||
|
const { isOpen, protocol, modalTitle, onDownloadClientSelect, onModalClose } =
|
||||||
|
props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}>
|
||||||
|
<SelectDownloadClientModalContent
|
||||||
|
protocol={protocol}
|
||||||
|
modalTitle={modalTitle}
|
||||||
|
onDownloadClientSelect={onDownloadClientSelect}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectDownloadClientModal;
|
@ -0,0 +1,74 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import Alert from 'Components/Alert';
|
||||||
|
import Form from 'Components/Form/Form';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
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 DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||||
|
import { kinds } from 'Helpers/Props';
|
||||||
|
import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import SelectDownloadClientRow from './SelectDownloadClientRow';
|
||||||
|
|
||||||
|
interface SelectDownloadClientModalContentProps {
|
||||||
|
protocol: DownloadProtocol;
|
||||||
|
modalTitle: string;
|
||||||
|
onDownloadClientSelect(downloadClientId: number): void;
|
||||||
|
onModalClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectDownloadClientModalContent(
|
||||||
|
props: SelectDownloadClientModalContentProps
|
||||||
|
) {
|
||||||
|
const { modalTitle, protocol, onDownloadClientSelect, onModalClose } = props;
|
||||||
|
|
||||||
|
const { isFetching, isPopulated, error, items } = useSelector(
|
||||||
|
createEnabledDownloadClientsSelector(protocol)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
<ModalHeader>
|
||||||
|
{translate('SelectDownloadClientModalTitle', { modalTitle })}
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
{isFetching ? <LoadingIndicator /> : null}
|
||||||
|
|
||||||
|
{!isFetching && error ? (
|
||||||
|
<Alert kind={kinds.DANGER}>
|
||||||
|
{translate('DownloadClientsLoadError')}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isPopulated && !error ? (
|
||||||
|
<Form>
|
||||||
|
{items.map((downloadClient) => {
|
||||||
|
const { id, name, priority } = downloadClient;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectDownloadClientRow
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
priority={priority}
|
||||||
|
onDownloadClientSelect={onDownloadClientSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Form>
|
||||||
|
) : null}
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectDownloadClientModalContent;
|
@ -0,0 +1,6 @@
|
|||||||
|
.downloadClient {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--borderColor);
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
// This file is automatically generated.
|
||||||
|
// Please do not change this file!
|
||||||
|
interface CssExports {
|
||||||
|
'downloadClient': string;
|
||||||
|
}
|
||||||
|
export const cssExports: CssExports;
|
||||||
|
export default cssExports;
|
@ -0,0 +1,32 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import styles from './SelectDownloadClientRow.css';
|
||||||
|
|
||||||
|
interface SelectDownloadClientRowProps {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
priority: number;
|
||||||
|
onDownloadClientSelect(downloadClientId: number): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectDownloadClientRow(props: SelectDownloadClientRowProps) {
|
||||||
|
const { id, name, priority, onDownloadClientSelect } = props;
|
||||||
|
|
||||||
|
const onDownloadClientSelectWrapper = useCallback(() => {
|
||||||
|
onDownloadClientSelect(id);
|
||||||
|
}, [id, onDownloadClientSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className={styles.downloadClient}
|
||||||
|
component="div"
|
||||||
|
onPress={onDownloadClientSelectWrapper}
|
||||||
|
>
|
||||||
|
<div>{name}</div>
|
||||||
|
<div>{translate('PrioritySettings', { priority })}</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectDownloadClientRow;
|
@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import SelectAlbumModalContent, {
|
||||||
|
SelectedAlbum,
|
||||||
|
} from './SelectAlbumModalContent';
|
||||||
|
|
||||||
|
interface SelectAlbumModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
selectedIds: number[] | string[];
|
||||||
|
artistId?: number;
|
||||||
|
selectedDetails?: string;
|
||||||
|
modalTitle: string;
|
||||||
|
onAlbumsSelect(selectedAlbums: SelectedAlbum[]): void;
|
||||||
|
onModalClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectAlbumModal(props: SelectAlbumModalProps) {
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
selectedIds,
|
||||||
|
artistId,
|
||||||
|
selectedDetails,
|
||||||
|
modalTitle,
|
||||||
|
onAlbumsSelect,
|
||||||
|
onModalClose,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||||
|
<SelectAlbumModalContent
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
artistId={artistId}
|
||||||
|
selectedDetails={selectedDetails}
|
||||||
|
modalTitle={modalTitle}
|
||||||
|
onAlbumsSelect={onAlbumsSelect}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectAlbumModal;
|
@ -0,0 +1,52 @@
|
|||||||
|
.modalBody {
|
||||||
|
composes: modalBody from '~Components/Modal/ModalBody.css';
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterInput {
|
||||||
|
composes: input from '~Components/Form/TextInput.css';
|
||||||
|
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
composes: modalFooter from '~Components/Modal/ModalFooter.css';
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
margin-right: 20px;
|
||||||
|
color: var(--dimColor);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: $breakpointSmall) {
|
||||||
|
.footer {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
// This file is automatically generated.
|
||||||
|
// Please do not change this file!
|
||||||
|
interface CssExports {
|
||||||
|
'buttons': string;
|
||||||
|
'details': string;
|
||||||
|
'filterInput': string;
|
||||||
|
'footer': string;
|
||||||
|
'modalBody': string;
|
||||||
|
'scroller': string;
|
||||||
|
}
|
||||||
|
export const cssExports: CssExports;
|
||||||
|
export default cssExports;
|
@ -0,0 +1,288 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import Album from 'Album/Album';
|
||||||
|
import AlbumAppState from 'App/State/AlbumAppState';
|
||||||
|
import TextInput from 'Components/Form/TextInput';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
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 Scroller from 'Components/Scroller/Scroller';
|
||||||
|
import Table from 'Components/Table/Table';
|
||||||
|
import TableBody from 'Components/Table/TableBody';
|
||||||
|
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||||
|
import { kinds, scrollDirections } from 'Helpers/Props';
|
||||||
|
import SortDirection from 'Helpers/Props/SortDirection';
|
||||||
|
import {
|
||||||
|
clearAlbums,
|
||||||
|
fetchAlbums,
|
||||||
|
setAlbumsSort,
|
||||||
|
} from 'Store/Actions/albumSelectionActions';
|
||||||
|
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
|
||||||
|
import { CheckInputChanged } from 'typings/inputs';
|
||||||
|
import { SelectStateInputProps } from 'typings/props';
|
||||||
|
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||||
|
import SelectAlbumRow from './SelectAlbumRow';
|
||||||
|
import styles from './SelectAlbumModalContent.css';
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
label: () => translate('AlbumTitle'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'albumType',
|
||||||
|
label: () => translate('AlbumType'),
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'releaseDate',
|
||||||
|
label: () => translate('ReleaseDate'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
label: () => translate('AlbumStatus'),
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'foreignAlbumId',
|
||||||
|
label: () => translate('MusicbrainzId'),
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function albumsSelector() {
|
||||||
|
return createSelector(
|
||||||
|
createClientSideCollectionSelector('albumSelection'),
|
||||||
|
(albums: AlbumAppState) => {
|
||||||
|
return albums;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectedAlbum {
|
||||||
|
id: number;
|
||||||
|
albums: Album[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectAlbumModalContentProps {
|
||||||
|
selectedIds: number[] | string[];
|
||||||
|
artistId?: number;
|
||||||
|
selectedDetails?: string;
|
||||||
|
modalTitle: string;
|
||||||
|
onAlbumsSelect(selectedAlbums: SelectedAlbum[]): unknown;
|
||||||
|
onModalClose(): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
function SelectAlbumModalContent(props: SelectAlbumModalContentProps) {
|
||||||
|
const {
|
||||||
|
selectedIds,
|
||||||
|
artistId,
|
||||||
|
selectedDetails,
|
||||||
|
modalTitle,
|
||||||
|
onAlbumsSelect,
|
||||||
|
onModalClose,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [selectState, setSelectState] = useSelectState();
|
||||||
|
|
||||||
|
const { allSelected, allUnselected, selectedState } = selectState;
|
||||||
|
const { isFetching, isPopulated, items, error, sortKey, sortDirection } =
|
||||||
|
useSelector(albumsSelector());
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const errorMessage = getErrorMessage(error, translate('AlbumsLoadError'));
|
||||||
|
const selectedCount = selectedIds.length;
|
||||||
|
const selectedAlbumsCount = getSelectedIds(selectedState).length;
|
||||||
|
const selectionIsValid =
|
||||||
|
selectedAlbumsCount > 0 && selectedAlbumsCount % selectedCount === 0;
|
||||||
|
|
||||||
|
const onFilterChange = useCallback(
|
||||||
|
({ value }: { value: string }) => {
|
||||||
|
setFilter(value.toLowerCase());
|
||||||
|
},
|
||||||
|
[setFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSelectAllChange = useCallback(
|
||||||
|
({ value }: CheckInputChanged) => {
|
||||||
|
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
|
||||||
|
},
|
||||||
|
[items, setSelectState]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSelectedChange = useCallback(
|
||||||
|
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
||||||
|
setSelectState({
|
||||||
|
type: 'toggleSelected',
|
||||||
|
items,
|
||||||
|
id,
|
||||||
|
isSelected: value,
|
||||||
|
shiftKey,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[items, setSelectState]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSortPress = useCallback(
|
||||||
|
(newSortKey: string, newSortDirection: SortDirection) => {
|
||||||
|
dispatch(
|
||||||
|
setAlbumsSort({
|
||||||
|
sortKey: newSortKey,
|
||||||
|
sortDirection: newSortDirection,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onAlbumsSelectWrapper = useCallback(() => {
|
||||||
|
const albumIds: number[] = getSelectedIds(selectedState);
|
||||||
|
|
||||||
|
const selectedAlbums = items.reduce((acc: Album[], item) => {
|
||||||
|
if (albumIds.indexOf(item.id) > -1) {
|
||||||
|
acc.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const albumsPerFile = selectedAlbums.length / selectedIds.length;
|
||||||
|
const sortedAlbums = selectedAlbums.sort((a, b) =>
|
||||||
|
a.title.localeCompare(b.title)
|
||||||
|
);
|
||||||
|
|
||||||
|
const mappedAlbums = selectedIds.map((id, index): SelectedAlbum => {
|
||||||
|
const startingIndex = index * albumsPerFile;
|
||||||
|
const albums = sortedAlbums.slice(
|
||||||
|
startingIndex,
|
||||||
|
startingIndex + albumsPerFile
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id as number,
|
||||||
|
albums,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onAlbumsSelect(mappedAlbums);
|
||||||
|
}, [selectedIds, items, selectedState, onAlbumsSelect]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
dispatch(fetchAlbums({ artistId }));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dispatch(clearAlbums());
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
let details = selectedDetails;
|
||||||
|
|
||||||
|
if (!details) {
|
||||||
|
details =
|
||||||
|
selectedCount > 1
|
||||||
|
? translate('CountSelectedFiles', { selectedCount })
|
||||||
|
: translate('CountSelectedFile', { selectedCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
<ModalHeader>
|
||||||
|
{translate('SelectAlbumsModalTitle', { modalTitle })}
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody
|
||||||
|
className={styles.modalBody}
|
||||||
|
scrollDirection={scrollDirections.NONE}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
className={styles.filterInput}
|
||||||
|
placeholder={translate('FilterAlbumsPlaceholder')}
|
||||||
|
name="filter"
|
||||||
|
value={filter}
|
||||||
|
autoFocus={true}
|
||||||
|
onChange={onFilterChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Scroller className={styles.scroller} autoFocus={false}>
|
||||||
|
{isFetching ? <LoadingIndicator /> : null}
|
||||||
|
|
||||||
|
{error ? <div>{errorMessage}</div> : null}
|
||||||
|
|
||||||
|
{isPopulated && !!items.length ? (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
selectAll={true}
|
||||||
|
allSelected={allSelected}
|
||||||
|
allUnselected={allUnselected}
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSortPress={onSortPress}
|
||||||
|
onSelectAllChange={onSelectAllChange}
|
||||||
|
>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item) => {
|
||||||
|
return item.title.toLowerCase().includes(filter) ||
|
||||||
|
item.foreignAlbumId.toLowerCase().includes(filter) ? (
|
||||||
|
<SelectAlbumRow
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
foreignAlbumId={item.foreignAlbumId}
|
||||||
|
title={item.title}
|
||||||
|
disambiguation={item.disambiguation}
|
||||||
|
albumType={item.albumType}
|
||||||
|
releaseDate={item.releaseDate}
|
||||||
|
statistics={item.statistics}
|
||||||
|
monitored={item.monitored}
|
||||||
|
isSelected={selectedState[item.id]}
|
||||||
|
onSelectedChange={onSelectedChange}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isPopulated && !items.length
|
||||||
|
? translate('NoAlbumsFoundForSelectedArtist')
|
||||||
|
: null}
|
||||||
|
</Scroller>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter className={styles.footer}>
|
||||||
|
<div className={styles.details}>{details}</div>
|
||||||
|
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
kind={kinds.SUCCESS}
|
||||||
|
isDisabled={!selectionIsValid}
|
||||||
|
onPress={onAlbumsSelectWrapper}
|
||||||
|
>
|
||||||
|
{translate('SelectAlbums')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectAlbumModalContent;
|
@ -0,0 +1,5 @@
|
|||||||
|
.foreignAlbumId {
|
||||||
|
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
|
||||||
|
|
||||||
|
font-family: $monoSpaceFontFamily;
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
// This file is automatically generated.
|
||||||
|
// Please do not change this file!
|
||||||
|
interface CssExports {
|
||||||
|
'foreignAlbumId': string;
|
||||||
|
}
|
||||||
|
export const cssExports: CssExports;
|
||||||
|
export default cssExports;
|
@ -0,0 +1,112 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Label from 'Components/Label';
|
||||||
|
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||||
|
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||||
|
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
|
||||||
|
import TableRowButton from 'Components/Table/TableRowButton';
|
||||||
|
import { kinds, sizes } from 'Helpers/Props';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import styles from './SelectAlbumRow.css';
|
||||||
|
|
||||||
|
function getTrackCountKind(monitored, trackFileCount, trackCount) {
|
||||||
|
if (trackFileCount === trackCount && trackCount > 0) {
|
||||||
|
return kinds.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!monitored) {
|
||||||
|
return kinds.WARNING;
|
||||||
|
}
|
||||||
|
|
||||||
|
return kinds.DANGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SelectAlbumRow extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onPress = () => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
isSelected
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
this.props.onSelectedChange({ id, value: !isSelected });
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
foreignAlbumId,
|
||||||
|
title,
|
||||||
|
disambiguation,
|
||||||
|
albumType,
|
||||||
|
releaseDate,
|
||||||
|
statistics = {},
|
||||||
|
monitored,
|
||||||
|
isSelected,
|
||||||
|
onSelectedChange
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
trackCount = 0,
|
||||||
|
trackFileCount = 0,
|
||||||
|
totalTrackCount = 0
|
||||||
|
} = statistics;
|
||||||
|
|
||||||
|
const extendedTitle = disambiguation ? `${title} (${disambiguation})` : title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRowButton onPress={this.onPress}>
|
||||||
|
<TableSelectCell
|
||||||
|
id={id}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onSelectedChange={onSelectedChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TableRowCell>
|
||||||
|
{extendedTitle}
|
||||||
|
</TableRowCell>
|
||||||
|
|
||||||
|
<TableRowCell>
|
||||||
|
{albumType}
|
||||||
|
</TableRowCell>
|
||||||
|
|
||||||
|
<RelativeDateCellConnector date={releaseDate} />
|
||||||
|
|
||||||
|
<TableRowCell>
|
||||||
|
<Label
|
||||||
|
title={translate('TotalTrackCountTracksTotalTrackFileCountTracksWithFilesInterp', { totalTrackCount, trackFileCount })}
|
||||||
|
kind={getTrackCountKind(monitored, trackFileCount, trackCount)}
|
||||||
|
size={sizes.MEDIUM}
|
||||||
|
>
|
||||||
|
<span>{trackFileCount} / {trackCount}</span>
|
||||||
|
</Label>
|
||||||
|
</TableRowCell>
|
||||||
|
|
||||||
|
<TableRowCell className={styles.foreignAlbumId}>
|
||||||
|
<Label>{foreignAlbumId}</Label>
|
||||||
|
</TableRowCell>
|
||||||
|
</TableRowButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectAlbumRow.propTypes = {
|
||||||
|
id: PropTypes.number.isRequired,
|
||||||
|
foreignAlbumId: PropTypes.string.isRequired,
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
disambiguation: PropTypes.string.isRequired,
|
||||||
|
albumType: PropTypes.string.isRequired,
|
||||||
|
releaseDate: PropTypes.string.isRequired,
|
||||||
|
statistics: PropTypes.object.isRequired,
|
||||||
|
monitored: PropTypes.bool.isRequired,
|
||||||
|
isSelected: PropTypes.bool,
|
||||||
|
onSelectedChange: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectAlbumRow;
|
@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Artist from 'Artist/Artist';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import SelectArtistModalContent from './SelectArtistModalContent';
|
||||||
|
|
||||||
|
interface SelectArtistModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
modalTitle: string;
|
||||||
|
onArtistSelect(artist: Artist): void;
|
||||||
|
onModalClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectArtistModal(props: SelectArtistModalProps) {
|
||||||
|
const { isOpen, modalTitle, onArtistSelect, onModalClose } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||||
|
<SelectArtistModalContent
|
||||||
|
modalTitle={modalTitle}
|
||||||
|
onArtistSelect={onArtistSelect}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectArtistModal;
|
@ -0,0 +1,18 @@
|
|||||||
|
.modalBody {
|
||||||
|
composes: modalBody from '~Components/Modal/ModalBody.css';
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterInput {
|
||||||
|
composes: input from '~Components/Form/TextInput.css';
|
||||||
|
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
// This file is automatically generated.
|
||||||
|
// Please do not change this file!
|
||||||
|
interface CssExports {
|
||||||
|
'filterInput': string;
|
||||||
|
'modalBody': string;
|
||||||
|
'scroller': string;
|
||||||
|
}
|
||||||
|
export const cssExports: CssExports;
|
||||||
|
export default cssExports;
|
@ -0,0 +1,217 @@
|
|||||||
|
import { throttle } from 'lodash';
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
|
||||||
|
import Artist from 'Artist/Artist';
|
||||||
|
import TextInput from 'Components/Form/TextInput';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import ModalBody from 'Components/Modal/ModalBody';
|
||||||
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
|
import Scroller from 'Components/Scroller/Scroller';
|
||||||
|
import Column from 'Components/Table/Column';
|
||||||
|
import VirtualTableRowButton from 'Components/Table/VirtualTableRowButton';
|
||||||
|
import { scrollDirections } from 'Helpers/Props';
|
||||||
|
import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
|
||||||
|
import dimensions from 'Styles/Variables/dimensions';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import SelectArtistModalTableHeader from './SelectArtistModalTableHeader';
|
||||||
|
import SelectArtistRow from './SelectArtistRow';
|
||||||
|
import styles from './SelectArtistModalContent.css';
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
name: 'artistName',
|
||||||
|
label: () => translate('Artist'),
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'foreignArtistId',
|
||||||
|
label: () => translate('MusicbrainzId'),
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const bodyPadding = parseInt(dimensions.pageContentBodyPadding);
|
||||||
|
|
||||||
|
interface SelectArtistModalContentProps {
|
||||||
|
modalTitle: string;
|
||||||
|
onArtistSelect(artist: Artist): void;
|
||||||
|
onModalClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowItemData {
|
||||||
|
items: Artist[];
|
||||||
|
columns: Column[];
|
||||||
|
onArtistSelect(artistId: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
|
||||||
|
index,
|
||||||
|
style,
|
||||||
|
data,
|
||||||
|
}) => {
|
||||||
|
const { items, columns, onArtistSelect } = data;
|
||||||
|
|
||||||
|
if (index >= items.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const artist = items[index];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualTableRowButton
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
onPress={() => onArtistSelect(artist.id)}
|
||||||
|
>
|
||||||
|
<SelectArtistRow
|
||||||
|
key={artist.id}
|
||||||
|
id={artist.id}
|
||||||
|
artistName={artist.artistName}
|
||||||
|
foreignArtistId={artist.foreignArtistId}
|
||||||
|
columns={columns}
|
||||||
|
onArtistSelect={onArtistSelect}
|
||||||
|
/>
|
||||||
|
</VirtualTableRowButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function SelectArtistModalContent(props: SelectArtistModalContentProps) {
|
||||||
|
const { modalTitle, onArtistSelect, onModalClose } = props;
|
||||||
|
|
||||||
|
const listRef = useRef<List<RowItemData>>(null);
|
||||||
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const allArtist: Artist[] = useSelector(createAllArtistSelector());
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const current = scrollerRef?.current as HTMLElement;
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
const width = current.clientWidth;
|
||||||
|
const height = current.clientHeight;
|
||||||
|
const padding = bodyPadding - 5;
|
||||||
|
|
||||||
|
setSize({
|
||||||
|
width: width - padding * 2,
|
||||||
|
height: height + padding,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [windowHeight, scrollerRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentScrollerRef = scrollerRef.current as HTMLElement;
|
||||||
|
const currentScrollListener = currentScrollerRef;
|
||||||
|
|
||||||
|
const handleScroll = throttle(() => {
|
||||||
|
const { offsetTop = 0 } = currentScrollerRef;
|
||||||
|
const scrollTop = currentScrollerRef.scrollTop - offsetTop;
|
||||||
|
|
||||||
|
listRef.current?.scrollTo(scrollTop);
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
currentScrollListener.addEventListener('scroll', handleScroll);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
handleScroll.cancel();
|
||||||
|
|
||||||
|
if (currentScrollListener) {
|
||||||
|
currentScrollListener.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [listRef, scrollerRef]);
|
||||||
|
|
||||||
|
const onFilterChange = useCallback(
|
||||||
|
({ value }: { value: string }) => {
|
||||||
|
setFilter(value);
|
||||||
|
},
|
||||||
|
[setFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onArtistSelectWrapper = useCallback(
|
||||||
|
(artistId: number) => {
|
||||||
|
const artist = allArtist.find((s) => s.id === artistId) as Artist;
|
||||||
|
|
||||||
|
onArtistSelect(artist);
|
||||||
|
},
|
||||||
|
[allArtist, onArtistSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const sorted = [...allArtist].sort((a, b) =>
|
||||||
|
a.sortName.localeCompare(b.sortName)
|
||||||
|
);
|
||||||
|
|
||||||
|
return sorted.filter(
|
||||||
|
(item) =>
|
||||||
|
item.artistName.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
item.foreignArtistId.toLowerCase().includes(filter.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [allArtist, filter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
<ModalHeader>{modalTitle} - Select Artist</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody
|
||||||
|
className={styles.modalBody}
|
||||||
|
scrollDirection={scrollDirections.NONE}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
className={styles.filterInput}
|
||||||
|
placeholder={translate('FilterArtistPlaceholder')}
|
||||||
|
name="filter"
|
||||||
|
value={filter}
|
||||||
|
autoFocus={true}
|
||||||
|
onChange={onFilterChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Scroller
|
||||||
|
className={styles.scroller}
|
||||||
|
autoFocus={false}
|
||||||
|
ref={scrollerRef}
|
||||||
|
>
|
||||||
|
<SelectArtistModalTableHeader columns={columns} />
|
||||||
|
<List<RowItemData>
|
||||||
|
ref={listRef}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'none',
|
||||||
|
}}
|
||||||
|
width={size.width}
|
||||||
|
height={size.height}
|
||||||
|
itemCount={items.length}
|
||||||
|
itemSize={38}
|
||||||
|
itemData={{
|
||||||
|
items,
|
||||||
|
columns,
|
||||||
|
onArtistSelect: onArtistSelectWrapper,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Row}
|
||||||
|
</List>
|
||||||
|
</Scroller>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectArtistModalContent;
|
@ -0,0 +1,11 @@
|
|||||||
|
.artistName {
|
||||||
|
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
|
||||||
|
|
||||||
|
flex: 4 0 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreignArtistId {
|
||||||
|
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
|
||||||
|
|
||||||
|
flex: 0 0 250px;
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
// This file is automatically generated.
|
||||||
|
// Please do not change this file!
|
||||||
|
interface CssExports {
|
||||||
|
'artistName': string;
|
||||||
|
'foreignArtistId': string;
|
||||||
|
}
|
||||||
|
export const cssExports: CssExports;
|
||||||
|
export default cssExports;
|
@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Column from 'Components/Table/Column';
|
||||||
|
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
|
||||||
|
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
|
||||||
|
import styles from './SelectArtistModalTableHeader.css';
|
||||||
|
|
||||||
|
interface SelectArtistModalTableHeaderProps {
|
||||||
|
columns: Column[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectArtistModalTableHeader(
|
||||||
|
props: SelectArtistModalTableHeaderProps
|
||||||
|
) {
|
||||||
|
const { columns } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualTableHeader>
|
||||||
|
{columns.map((column) => {
|
||||||
|
const { name, label, isVisible } = column;
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualTableHeaderCell
|
||||||
|
key={name}
|
||||||
|
className={
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
styles[name]
|
||||||
|
}
|
||||||
|
name={name}
|
||||||
|
>
|
||||||
|
{typeof label === 'function' ? label() : label}
|
||||||
|
</VirtualTableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</VirtualTableHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectArtistModalTableHeader;
|
@ -0,0 +1,19 @@
|
|||||||
|
.cell {
|
||||||
|
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artistName {
|
||||||
|
composes: cell;
|
||||||
|
|
||||||
|
flex: 4 0 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreignArtistId {
|
||||||
|
composes: cell;
|
||||||
|
|
||||||
|
flex: 0 0 250px;
|
||||||
|
font-family: $monoSpaceFontFamily;
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
// This file is automatically generated.
|
||||||
|
// Please do not change this file!
|
||||||
|
interface CssExports {
|
||||||
|
'artistName': string;
|
||||||
|
'cell': string;
|
||||||
|
'foreignArtistId': string;
|
||||||
|
}
|
||||||
|
export const cssExports: CssExports;
|
||||||
|
export default cssExports;
|
@ -0,0 +1,41 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Label from 'Components/Label';
|
||||||
|
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
|
||||||
|
import styles from './SelectArtistRow.css';
|
||||||
|
|
||||||
|
class SelectArtistRow extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onPress = () => {
|
||||||
|
this.props.onArtistSelect(this.props.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<VirtualTableRowCell className={styles.artistName}>
|
||||||
|
{this.props.artistName}
|
||||||
|
</VirtualTableRowCell>
|
||||||
|
|
||||||
|
<VirtualTableRowCell className={styles.foreignArtistId}>
|
||||||
|
<Label>{this.props.foreignArtistId}</Label>
|
||||||
|
</VirtualTableRowCell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectArtistRow.propTypes = {
|
||||||
|
id: PropTypes.number.isRequired,
|
||||||
|
artistName: PropTypes.string.isRequired,
|
||||||
|
foreignArtistId: PropTypes.string.isRequired,
|
||||||
|
onArtistSelect: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectArtistRow;
|
@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import { QualityModel } from 'Quality/Quality';
|
||||||
|
import SelectQualityModalContent from './SelectQualityModalContent';
|
||||||
|
|
||||||
|
interface SelectQualityModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
qualityId: number;
|
||||||
|
proper: boolean;
|
||||||
|
real: boolean;
|
||||||
|
modalTitle: string;
|
||||||
|
onQualitySelect(quality: QualityModel): void;
|
||||||
|
onModalClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectQualityModal(props: SelectQualityModalProps) {
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
qualityId,
|
||||||
|
proper,
|
||||||
|
real,
|
||||||
|
modalTitle,
|
||||||
|
onQualitySelect,
|
||||||
|
onModalClose,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||||
|
<SelectQualityModalContent
|
||||||
|
qualityId={qualityId}
|
||||||
|
proper={proper}
|
||||||
|
real={real}
|
||||||
|
modalTitle={modalTitle}
|
||||||
|
onQualitySelect={onQualitySelect}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectQualityModal;
|
@ -0,0 +1,185 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { Error } from 'App/State/AppSectionState';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
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 Button from 'Components/Link/Button';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
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 Quality, { QualityModel } from 'Quality/Quality';
|
||||||
|
import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
|
||||||
|
import { CheckInputChanged } from 'typings/inputs';
|
||||||
|
import getQualities from 'Utilities/Quality/getQualities';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
|
interface QualitySchemaState {
|
||||||
|
isFetching: boolean;
|
||||||
|
isPopulated: boolean;
|
||||||
|
error: Error;
|
||||||
|
items: Quality[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createQualitySchemaSelector() {
|
||||||
|
return createSelector(
|
||||||
|
(state: AppState) => state.settings.qualityProfiles,
|
||||||
|
(qualityProfiles): QualitySchemaState => {
|
||||||
|
const { isSchemaFetching, isSchemaPopulated, schemaError, schema } =
|
||||||
|
qualityProfiles;
|
||||||
|
|
||||||
|
const items = getQualities(schema.items) as Quality[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
isFetching: isSchemaFetching,
|
||||||
|
isPopulated: isSchemaPopulated,
|
||||||
|
error: schemaError,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectQualityModalContentProps {
|
||||||
|
qualityId: number;
|
||||||
|
proper: boolean;
|
||||||
|
real: boolean;
|
||||||
|
modalTitle: string;
|
||||||
|
onQualitySelect(quality: QualityModel): void;
|
||||||
|
onModalClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectQualityModalContent(props: SelectQualityModalContentProps) {
|
||||||
|
const { modalTitle, onQualitySelect, onModalClose } = props;
|
||||||
|
|
||||||
|
const [qualityId, setQualityId] = useState(props.qualityId);
|
||||||
|
const [proper, setProper] = useState(props.proper);
|
||||||
|
const [real, setReal] = useState(props.real);
|
||||||
|
|
||||||
|
const { isFetching, isPopulated, error, items } = useSelector(
|
||||||
|
createQualitySchemaSelector()
|
||||||
|
);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
dispatch(fetchQualityProfileSchema());
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const qualityOptions = useMemo(() => {
|
||||||
|
return items.map(({ id, name }) => {
|
||||||
|
return {
|
||||||
|
key: id,
|
||||||
|
value: name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const onQualityChange = useCallback(
|
||||||
|
({ value }: { value: string }) => {
|
||||||
|
setQualityId(parseInt(value));
|
||||||
|
},
|
||||||
|
[setQualityId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onProperChange = useCallback(
|
||||||
|
({ value }: CheckInputChanged) => {
|
||||||
|
setProper(value);
|
||||||
|
},
|
||||||
|
[setProper]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRealChange = useCallback(
|
||||||
|
({ value }: CheckInputChanged) => {
|
||||||
|
setReal(value);
|
||||||
|
},
|
||||||
|
[setReal]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onQualitySelectWrapper = useCallback(() => {
|
||||||
|
const quality = items.find((item) => item.id === qualityId) as Quality;
|
||||||
|
|
||||||
|
const revision = {
|
||||||
|
version: proper ? 2 : 1,
|
||||||
|
real: real ? 1 : 0,
|
||||||
|
isRepack: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
onQualitySelect({
|
||||||
|
quality,
|
||||||
|
revision,
|
||||||
|
});
|
||||||
|
}, [items, qualityId, proper, real, onQualitySelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
<ModalHeader>{modalTitle} - Select Quality</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
{isFetching && <LoadingIndicator />}
|
||||||
|
|
||||||
|
{!isFetching && error ? (
|
||||||
|
<Alert kind={kinds.DANGER}>{translate('QualitiesLoadError')}</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isPopulated && !error ? (
|
||||||
|
<Form>
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('Quality')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.SELECT}
|
||||||
|
name="quality"
|
||||||
|
value={qualityId}
|
||||||
|
values={qualityOptions}
|
||||||
|
onChange={onQualityChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('Proper')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="proper"
|
||||||
|
value={proper}
|
||||||
|
onChange={onProperChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('Real')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="real"
|
||||||
|
value={real}
|
||||||
|
onChange={onRealChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
) : null}
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button onPress={onModalClose}>Cancel</Button>
|
||||||
|
|
||||||
|
<Button kind={kinds.SUCCESS} onPress={onQualitySelectWrapper}>
|
||||||
|
{translate('SelectQuality')}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectQualityModalContent;
|
@ -0,0 +1,17 @@
|
|||||||
|
.link {
|
||||||
|
composes: link from '~Components/Link/Link.css';
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
display: inline-block;
|
||||||
|
margin: -2px 0;
|
||||||
|
width: 100%;
|
||||||
|
outline: 2px dashed var(--dangerColor);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional {
|
||||||
|
outline: 2px dashed var(--gray);
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
// This file is automatically generated.
|
||||||
|
// Please do not change this file!
|
||||||
|
interface CssExports {
|
||||||
|
'link': string;
|
||||||
|
'optional': string;
|
||||||
|
'placeholder': string;
|
||||||
|
}
|
||||||
|
export const cssExports: CssExports;
|
||||||
|
export default cssExports;
|
@ -0,0 +1,35 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import styles from './OverrideMatchData.css';
|
||||||
|
|
||||||
|
interface OverrideMatchDataProps {
|
||||||
|
value?: string | number | JSX.Element | JSX.Element[];
|
||||||
|
isDisabled?: boolean;
|
||||||
|
isOptional?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverrideMatchData(props: OverrideMatchDataProps) {
|
||||||
|
const { value, isDisabled = false, isOptional, onPress } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link className={styles.link} isDisabled={isDisabled} onPress={onPress}>
|
||||||
|
{(value == null || (Array.isArray(value) && value.length === 0)) &&
|
||||||
|
!isDisabled ? (
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
styles.placeholder,
|
||||||
|
isOptional && styles.optional
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
value
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OverrideMatchData;
|
@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||||
|
import { sizes } from 'Helpers/Props';
|
||||||
|
import ReleaseAlbum from 'InteractiveSearch/ReleaseAlbum';
|
||||||
|
import { QualityModel } from 'Quality/Quality';
|
||||||
|
import OverrideMatchModalContent from './OverrideMatchModalContent';
|
||||||
|
|
||||||
|
interface OverrideMatchModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
indexerId: number;
|
||||||
|
guid: string;
|
||||||
|
artistId?: number;
|
||||||
|
albums: ReleaseAlbum[];
|
||||||
|
quality: QualityModel;
|
||||||
|
protocol: DownloadProtocol;
|
||||||
|
isGrabbing: boolean;
|
||||||
|
grabError?: string;
|
||||||
|
onModalClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverrideMatchModal(props: OverrideMatchModalProps) {
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
indexerId,
|
||||||
|
guid,
|
||||||
|
artistId,
|
||||||
|
albums,
|
||||||
|
quality,
|
||||||
|
protocol,
|
||||||
|
isGrabbing,
|
||||||
|
grabError,
|
||||||
|
onModalClose,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} size={sizes.LARGE} onModalClose={onModalClose}>
|
||||||
|
<OverrideMatchModalContent
|
||||||
|
title={title}
|
||||||
|
indexerId={indexerId}
|
||||||
|
guid={guid}
|
||||||
|
artistId={artistId}
|
||||||
|
albums={albums}
|
||||||
|
quality={quality}
|
||||||
|
protocol={protocol}
|
||||||
|
isGrabbing={isGrabbing}
|
||||||
|
grabError={grabError}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OverrideMatchModal;
|
@ -0,0 +1,49 @@
|
|||||||
|
.label {
|
||||||
|
composes: label from '~Components/Label.css';
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
margin-left: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
composes: modalFooter from '~Components/Modal/ModalFooter.css';
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin-right: 20px;
|
||||||
|
color: var(--dangerColor);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: $breakpointSmall) {
|
||||||
|
.item {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
// This file is automatically generated.
|
||||||
|
// Please do not change this file!
|
||||||
|
interface CssExports {
|
||||||
|
'buttons': string;
|
||||||
|
'error': string;
|
||||||
|
'footer': string;
|
||||||
|
'item': string;
|
||||||
|
'label': string;
|
||||||
|
}
|
||||||
|
export const cssExports: CssExports;
|
||||||
|
export default cssExports;
|
@ -0,0 +1,310 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import Album from 'Album/Album';
|
||||||
|
import TrackQuality from 'Album/TrackQuality';
|
||||||
|
import Artist from 'Artist/Artist';
|
||||||
|
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||||
|
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||||
|
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 DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||||
|
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||||
|
import ReleaseAlbum from 'InteractiveSearch/ReleaseAlbum';
|
||||||
|
import { QualityModel } from 'Quality/Quality';
|
||||||
|
import { grabRelease } from 'Store/Actions/releaseActions';
|
||||||
|
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
|
||||||
|
import { createArtistSelectorForHook } from 'Store/Selectors/createArtistSelector';
|
||||||
|
import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import SelectDownloadClientModal from './DownloadClient/SelectDownloadClientModal';
|
||||||
|
import SelectAlbumModal from './InterarctiveSearch/Album/SelectAlbumModal';
|
||||||
|
import SelectArtistModal from './InterarctiveSearch/Artist/SelectArtistModal';
|
||||||
|
import SelectQualityModal from './InterarctiveSearch/Quality/SelectQualityModal';
|
||||||
|
import OverrideMatchData from './OverrideMatchData';
|
||||||
|
import styles from './OverrideMatchModalContent.css';
|
||||||
|
|
||||||
|
type SelectType =
|
||||||
|
| 'select'
|
||||||
|
| 'artist'
|
||||||
|
| 'album'
|
||||||
|
| 'quality'
|
||||||
|
| 'language'
|
||||||
|
| 'downloadClient';
|
||||||
|
|
||||||
|
interface SelectedAlbum {
|
||||||
|
id: number;
|
||||||
|
albums: Album[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverrideMatchModalContentProps {
|
||||||
|
indexerId: number;
|
||||||
|
title: string;
|
||||||
|
guid: string;
|
||||||
|
artistId?: number;
|
||||||
|
albums: ReleaseAlbum[];
|
||||||
|
quality: QualityModel;
|
||||||
|
protocol: DownloadProtocol;
|
||||||
|
isGrabbing: boolean;
|
||||||
|
grabError?: string;
|
||||||
|
onModalClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverrideMatchModalContent(props: OverrideMatchModalContentProps) {
|
||||||
|
const modalTitle = translate('ManualGrab');
|
||||||
|
const {
|
||||||
|
indexerId,
|
||||||
|
title,
|
||||||
|
guid,
|
||||||
|
protocol,
|
||||||
|
isGrabbing,
|
||||||
|
grabError,
|
||||||
|
onModalClose,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [artistId, setArtistId] = useState(props.artistId);
|
||||||
|
const [albums, setAlbums] = useState(props.albums);
|
||||||
|
const [quality, setQuality] = useState(props.quality);
|
||||||
|
const [downloadClientId, setDownloadClientId] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectModalOpen, setSelectModalOpen] = useState<SelectType | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const previousIsGrabbing = usePrevious(isGrabbing);
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const artist: Artist | undefined = useSelector(
|
||||||
|
createArtistSelectorForHook(artistId)
|
||||||
|
);
|
||||||
|
const { items: downloadClients } = useSelector(
|
||||||
|
createEnabledDownloadClientsSelector(protocol)
|
||||||
|
);
|
||||||
|
|
||||||
|
const albumInfo = useMemo(() => {
|
||||||
|
return albums.map((album) => {
|
||||||
|
return <div key={album.id}>{album.title}</div>;
|
||||||
|
});
|
||||||
|
}, [albums]);
|
||||||
|
|
||||||
|
const onSelectModalClose = useCallback(() => {
|
||||||
|
setSelectModalOpen(null);
|
||||||
|
}, [setSelectModalOpen]);
|
||||||
|
|
||||||
|
const onSelectArtistPress = useCallback(() => {
|
||||||
|
setSelectModalOpen('artist');
|
||||||
|
}, [setSelectModalOpen]);
|
||||||
|
|
||||||
|
const onArtistSelect = useCallback(
|
||||||
|
(s: Artist) => {
|
||||||
|
setArtistId(s.id);
|
||||||
|
setAlbums([]);
|
||||||
|
setSelectModalOpen(null);
|
||||||
|
},
|
||||||
|
[setArtistId, setAlbums, setSelectModalOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSelectAlbumPress = useCallback(() => {
|
||||||
|
setSelectModalOpen('album');
|
||||||
|
}, [setSelectModalOpen]);
|
||||||
|
|
||||||
|
const onAlbumsSelect = useCallback(
|
||||||
|
(albumMap: SelectedAlbum[]) => {
|
||||||
|
setAlbums(albumMap[0].albums);
|
||||||
|
setSelectModalOpen(null);
|
||||||
|
},
|
||||||
|
[setAlbums, setSelectModalOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSelectQualityPress = useCallback(() => {
|
||||||
|
setSelectModalOpen('quality');
|
||||||
|
}, [setSelectModalOpen]);
|
||||||
|
|
||||||
|
const onQualitySelect = useCallback(
|
||||||
|
(quality: QualityModel) => {
|
||||||
|
setQuality(quality);
|
||||||
|
setSelectModalOpen(null);
|
||||||
|
},
|
||||||
|
[setQuality, setSelectModalOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSelectDownloadClientPress = useCallback(() => {
|
||||||
|
setSelectModalOpen('downloadClient');
|
||||||
|
}, [setSelectModalOpen]);
|
||||||
|
|
||||||
|
const onDownloadClientSelect = useCallback(
|
||||||
|
(downloadClientId: number) => {
|
||||||
|
setDownloadClientId(downloadClientId);
|
||||||
|
setSelectModalOpen(null);
|
||||||
|
},
|
||||||
|
[setDownloadClientId, setSelectModalOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onGrabPress = useCallback(() => {
|
||||||
|
if (!artistId) {
|
||||||
|
setError(translate('OverrideGrabNoArtist'));
|
||||||
|
return;
|
||||||
|
} else if (!albums.length) {
|
||||||
|
setError(translate('OverrideGrabNoAlbum'));
|
||||||
|
return;
|
||||||
|
} else if (!quality) {
|
||||||
|
setError(translate('OverrideGrabNoQuality'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
grabRelease({
|
||||||
|
indexerId,
|
||||||
|
guid,
|
||||||
|
artistId,
|
||||||
|
albumsIds: albums.map((a) => a.id),
|
||||||
|
quality,
|
||||||
|
downloadClientId,
|
||||||
|
shouldOverride: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
indexerId,
|
||||||
|
guid,
|
||||||
|
artistId,
|
||||||
|
albums,
|
||||||
|
quality,
|
||||||
|
downloadClientId,
|
||||||
|
setError,
|
||||||
|
dispatch,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGrabbing && previousIsGrabbing) {
|
||||||
|
onModalClose();
|
||||||
|
}
|
||||||
|
}, [isGrabbing, previousIsGrabbing, onModalClose]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
dispatch(fetchDownloadClients());
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
<ModalHeader>
|
||||||
|
{translate('OverrideGrabModalTitle', { title })}
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
<DescriptionList>
|
||||||
|
<DescriptionListItem
|
||||||
|
className={styles.item}
|
||||||
|
title={translate('Artist')}
|
||||||
|
data={
|
||||||
|
<OverrideMatchData
|
||||||
|
value={artist?.artistName}
|
||||||
|
onPress={onSelectArtistPress}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DescriptionListItem
|
||||||
|
className={styles.item}
|
||||||
|
title={translate('Albums')}
|
||||||
|
data={
|
||||||
|
<OverrideMatchData
|
||||||
|
value={albumInfo}
|
||||||
|
isDisabled={!artist}
|
||||||
|
onPress={onSelectAlbumPress}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DescriptionListItem
|
||||||
|
className={styles.item}
|
||||||
|
title={translate('Quality')}
|
||||||
|
data={
|
||||||
|
<OverrideMatchData
|
||||||
|
value={
|
||||||
|
<TrackQuality className={styles.label} quality={quality} />
|
||||||
|
}
|
||||||
|
onPress={onSelectQualityPress}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{downloadClients.length > 1 ? (
|
||||||
|
<DescriptionListItem
|
||||||
|
className={styles.item}
|
||||||
|
title={translate('DownloadClient')}
|
||||||
|
data={
|
||||||
|
<OverrideMatchData
|
||||||
|
value={
|
||||||
|
downloadClients.find(
|
||||||
|
(downloadClient) => downloadClient.id === downloadClientId
|
||||||
|
)?.name ?? translate('Default')
|
||||||
|
}
|
||||||
|
onPress={onSelectDownloadClientPress}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</DescriptionList>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter className={styles.footer}>
|
||||||
|
<div className={styles.error}>{error || grabError}</div>
|
||||||
|
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||||
|
|
||||||
|
<SpinnerErrorButton
|
||||||
|
isSpinning={isGrabbing}
|
||||||
|
error={grabError}
|
||||||
|
onPress={onGrabPress}
|
||||||
|
>
|
||||||
|
{translate('GrabRelease')}
|
||||||
|
</SpinnerErrorButton>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
|
||||||
|
<SelectArtistModal
|
||||||
|
isOpen={selectModalOpen === 'artist'}
|
||||||
|
modalTitle={modalTitle}
|
||||||
|
onArtistSelect={onArtistSelect}
|
||||||
|
onModalClose={onSelectModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectAlbumModal
|
||||||
|
isOpen={selectModalOpen === 'album'}
|
||||||
|
selectedIds={[guid]}
|
||||||
|
artistId={artistId}
|
||||||
|
selectedDetails={title}
|
||||||
|
modalTitle={modalTitle}
|
||||||
|
onAlbumsSelect={onAlbumsSelect}
|
||||||
|
onModalClose={onSelectModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectQualityModal
|
||||||
|
isOpen={selectModalOpen === 'quality'}
|
||||||
|
qualityId={quality ? quality.quality.id : 0}
|
||||||
|
proper={quality ? quality.revision.version > 1 : false}
|
||||||
|
real={quality ? quality.revision.real > 0 : false}
|
||||||
|
modalTitle={modalTitle}
|
||||||
|
onQualitySelect={onQualitySelect}
|
||||||
|
onModalClose={onSelectModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectDownloadClientModal
|
||||||
|
isOpen={selectModalOpen === 'downloadClient'}
|
||||||
|
protocol={protocol}
|
||||||
|
modalTitle={modalTitle}
|
||||||
|
onDownloadClientSelect={onDownloadClientSelect}
|
||||||
|
onModalClose={onSelectModalClose}
|
||||||
|
/>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OverrideMatchModalContent;
|
@ -0,0 +1,6 @@
|
|||||||
|
interface ReleaseAlbum {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReleaseAlbum;
|
@ -0,0 +1,61 @@
|
|||||||
|
import { createAction } from 'redux-actions';
|
||||||
|
import { sortDirections } from 'Helpers/Props';
|
||||||
|
import { createThunk, handleThunks } from 'Store/thunks';
|
||||||
|
import updateSectionState from 'Utilities/State/updateSectionState';
|
||||||
|
import createFetchHandler from './Creators/createFetchHandler';
|
||||||
|
import createHandleActions from './Creators/createHandleActions';
|
||||||
|
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
|
||||||
|
|
||||||
|
//
|
||||||
|
// Variables
|
||||||
|
|
||||||
|
export const section = 'albumSelection';
|
||||||
|
|
||||||
|
//
|
||||||
|
// State
|
||||||
|
|
||||||
|
export const defaultState = {
|
||||||
|
isFetching: false,
|
||||||
|
isReprocessing: false,
|
||||||
|
isPopulated: false,
|
||||||
|
error: null,
|
||||||
|
sortKey: 'releaseDate',
|
||||||
|
sortDirection: sortDirections.DESCENDING,
|
||||||
|
items: []
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Actions Types
|
||||||
|
|
||||||
|
export const FETCH_ALBUMS = 'albumSelection/fetchAlbums';
|
||||||
|
export const SET_ALBUMS_SORT = 'albumSelection/setAlbumsSort';
|
||||||
|
export const CLEAR_ALBUMS = 'albumSelection/clearAlbums';
|
||||||
|
|
||||||
|
//
|
||||||
|
// Action Creators
|
||||||
|
|
||||||
|
export const fetchAlbums = createThunk(FETCH_ALBUMS);
|
||||||
|
export const setAlbumsSort = createAction(SET_ALBUMS_SORT);
|
||||||
|
export const clearAlbums = createAction(CLEAR_ALBUMS);
|
||||||
|
|
||||||
|
//
|
||||||
|
// Action Handlers
|
||||||
|
|
||||||
|
export const actionHandlers = handleThunks({
|
||||||
|
[FETCH_ALBUMS]: createFetchHandler(section, '/album')
|
||||||
|
});
|
||||||
|
|
||||||
|
//
|
||||||
|
// Reducers
|
||||||
|
|
||||||
|
export const reducers = createHandleActions({
|
||||||
|
|
||||||
|
[SET_ALBUMS_SORT]: createSetClientSideCollectionSortReducer(section),
|
||||||
|
|
||||||
|
[CLEAR_ALBUMS]: (state) => {
|
||||||
|
return updateSectionState(state, section, {
|
||||||
|
...defaultState
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}, defaultState, section);
|
@ -0,0 +1,22 @@
|
|||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { DownloadClientAppState } from 'App/State/SettingsAppState';
|
||||||
|
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||||
|
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||||
|
import sortByName from 'Utilities/Array/sortByName';
|
||||||
|
|
||||||
|
export default function createEnabledDownloadClientsSelector(
|
||||||
|
protocol: DownloadProtocol
|
||||||
|
) {
|
||||||
|
return createSelector(
|
||||||
|
createSortedSectionSelector('settings.downloadClients', sortByName),
|
||||||
|
(downloadClients: DownloadClientAppState) => {
|
||||||
|
const { isFetching, isPopulated, error, items } = downloadClients;
|
||||||
|
|
||||||
|
const clients = items.filter(
|
||||||
|
(item) => item.protocol === protocol && item.enable
|
||||||
|
);
|
||||||
|
|
||||||
|
return { isFetching, isPopulated, error, items: clients };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in new issue