New: Plugin support

This commit is contained in:
ta264 2022-07-19 21:08:53 +01:00
commit 311322651a
167 changed files with 3178 additions and 773 deletions

View file

@ -46,7 +46,7 @@ class BlocklistDetailsModal extends Component {
<DescriptionListItem
title={translate('Protocol')}
data={protocol}
data={protocol.replace('DownloadProtocol', '')}
/>
{

View file

@ -4,7 +4,8 @@ import Label from 'Components/Label';
import styles from './ProtocolLabel.css';
function ProtocolLabel({ protocol }) {
const protocolName = protocol === 'usenet' ? 'nzb' : protocol;
const strippedName = protocol.replace('DownloadProtocol', '').toLowerCase();
const protocolName = strippedName === 'usenet' ? 'nzb' : strippedName;
return (
<Label className={styles[protocol]}>

View file

@ -28,6 +28,7 @@ import UISettingsConnector from 'Settings/UI/UISettingsConnector';
import BackupsConnector from 'System/Backup/BackupsConnector';
import LogsTableConnector from 'System/Events/LogsTableConnector';
import Logs from 'System/Logs/Logs';
import PluginsConnector from 'System/Plugins/PluginsConnector';
import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks';
import UpdatesConnector from 'System/Updates/UpdatesConnector';
@ -231,6 +232,11 @@ function AppRoutes(props) {
component={UpdatesConnector}
/>
<Route
path="/system/plugins"
component={PluginsConnector}
/>
<Route
path="/system/events"
component={LogsTableConnector}

View file

@ -8,6 +8,7 @@ export const DELETE_LOG_FILES = 'DeleteLogFiles';
export const DELETE_UPDATE_LOG_FILES = 'DeleteUpdateLogFiles';
export const DOWNLOADED_ALBUMS_SCAN = 'DownloadedAlbumsScan';
export const ALBUM_SEARCH = 'AlbumSearch';
export const INSTALL_PLUGIN = 'InstallPlugin';
export const INTERACTIVE_IMPORT = 'ManualImport';
export const MISSING_ALBUM_SEARCH = 'MissingAlbumSearch';
export const MOVE_ARTIST = 'MoveArtist';
@ -21,3 +22,4 @@ export const RESET_API_KEY = 'ResetApiKey';
export const RSS_SYNC = 'RssSync';
export const SEASON_SEARCH = 'AlbumSearch';
export const ARTIST_SEARCH = 'ArtistSearch';
export const UNINSTALL_PLUGIN = 'UninstallPlugin';

View file

@ -35,6 +35,13 @@ class FilterBuilderModalContent extends Component {
};
}
componentDidMount() {
if (!this.props.isPopulated) {
this.props.dispatchFetchDownloadClients();
this.props.dispatchFetchIndexers();
}
}
componentDidUpdate(prevProps) {
const {
id,
@ -218,9 +225,12 @@ FilterBuilderModalContent.propTypes = {
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
isPopulated: PropTypes.bool.isRequired,
dispatchDeleteCustomFilter: PropTypes.func.isRequired,
onSaveCustomFilterPress: PropTypes.func.isRequired,
dispatchSetFilter: PropTypes.func.isRequired,
dispatchFetchDownloadClients: PropTypes.func.isRequired,
dispatchFetchIndexers: PropTypes.func.isRequired,
onCancelPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View file

@ -1,6 +1,7 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteCustomFilter, saveCustomFilter } from 'Store/Actions/customFilterActions';
import { fetchDownloadClients, fetchIndexers } from 'Store/Actions/settingsActions';
import FilterBuilderModalContent from './FilterBuilderModalContent';
function createMapStateToProps() {
@ -9,7 +10,11 @@ function createMapStateToProps() {
(state, { id }) => id,
(state) => state.customFilters.isSaving,
(state) => state.customFilters.saveError,
(customFilters, id, isSaving, saveError) => {
(state) => state.settings.downloadClients.isPopulated,
(state) => state.settings.indexers.isPopulated,
(customFilters, id, isSaving, saveError, downloadClientsPopulated, indexersPopulated) => {
const isPopulated = downloadClientsPopulated && indexersPopulated;
if (id) {
const customFilter = customFilters.find((c) => c.id === id);
@ -19,7 +24,8 @@ function createMapStateToProps() {
filters: customFilter.filters,
customFilters,
isSaving,
saveError
saveError,
isPopulated
};
}
@ -28,7 +34,8 @@ function createMapStateToProps() {
filters: [],
customFilters,
isSaving,
saveError
saveError,
isPopulated
};
}
);
@ -36,7 +43,9 @@ function createMapStateToProps() {
const mapDispatchToProps = {
onSaveCustomFilterPress: saveCustomFilter,
dispatchDeleteCustomFilter: deleteCustomFilter
dispatchDeleteCustomFilter: deleteCustomFilter,
dispatchFetchDownloadClients: fetchDownloadClients,
dispatchFetchIndexers: fetchIndexers
};
export default connect(createMapStateToProps, mapDispatchToProps)(FilterBuilderModalContent);

View file

@ -9,7 +9,7 @@ import DateFilterBuilderRowValue from './DateFilterBuilderRowValue';
import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector';
import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector';
import MetadataProfileFilterBuilderRowValueConnector from './MetadataProfileFilterBuilderRowValueConnector';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValueConnector';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector';

View file

@ -1,18 +0,0 @@
import React from 'react';
import FilterBuilderRowValue from './FilterBuilderRowValue';
const protocols = [
{ id: 'torrent', name: 'Torrent' },
{ id: 'usenet', name: 'Usenet' }
];
function ProtocolFilterBuilderRowValue(props) {
return (
<FilterBuilderRowValue
tagList={protocols}
{...props}
/>
);
}
export default ProtocolFilterBuilderRowValue;

View file

@ -0,0 +1,30 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.downloadClients,
(state) => state.settings.indexers,
(downloadClients, indexers) => {
const protocols = Array.from(new Set([
...downloadClients.items.map((i) => i.protocol),
...indexers.items.map((i) => i.protocol)
]));
console.log(protocols);
const tagList = protocols.map((protocol) => {
return {
id: protocol,
name: protocol.replace('DownloadProtocol', '')
};
});
return {
tagList
};
}
);
}
export default connect(createMapStateToProps)(FilterBuilderRowValue);

View file

@ -160,6 +160,10 @@ const links = [
title: 'Updates',
to: '/system/updates'
},
{
title: 'Plugins',
to: '/system/plugins'
},
{
title: 'Events',
to: '/system/events'

View file

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setAppValue, setVersion } from 'Store/Actions/appActions';
import { clearMessages, setAppValue, setVersion } from 'Store/Actions/appActions';
import { fetchArtist } from 'Store/Actions/artistActions';
import { removeItem, update, updateItem } from 'Store/Actions/baseActions';
import { fetchCommands, finishCommand, updateCommand } from 'Store/Actions/commandActions';
@ -40,6 +40,7 @@ const mapDispatchToProps = {
dispatchFetchCommands: fetchCommands,
dispatchUpdateCommand: updateCommand,
dispatchFinishCommand: finishCommand,
dispatchClearMessages: clearMessages,
dispatchSetAppValue: setAppValue,
dispatchSetVersion: setVersion,
dispatchUpdate: update,
@ -333,6 +334,7 @@ class SignalRConnector extends Component {
const {
dispatchFetchCommands,
dispatchFetchArtist,
dispatchClearMessages,
dispatchSetAppValue
} = this.props;
@ -346,7 +348,9 @@ class SignalRConnector extends Component {
// Repopulate the page (if a repopulator is set) to ensure things
// are in sync after reconnecting.
dispatchFetchArtist();
dispatchClearMessages();
dispatchFetchCommands();
repopulatePage();
};
@ -375,6 +379,7 @@ SignalRConnector.propTypes = {
dispatchFetchCommands: PropTypes.func.isRequired,
dispatchUpdateCommand: PropTypes.func.isRequired,
dispatchFinishCommand: PropTypes.func.isRequired,
dispatchClearMessages: PropTypes.func.isRequired,
dispatchSetAppValue: PropTypes.func.isRequired,
dispatchSetVersion: PropTypes.func.isRequired,
dispatchUpdate: PropTypes.func.isRequired,

View file

@ -5,9 +5,9 @@ import { kinds } from 'Helpers/Props';
import Label from './Label';
import styles from './TagList.css';
function TagList({ tags, tagList }) {
function TagList({ className, tags, tagList }) {
return (
<div className={styles.tags}>
<div className={className}>
{
tags.map((t) => {
const tag = _.find(tagList, { id: t });
@ -31,8 +31,13 @@ function TagList({ tags, tagList }) {
}
TagList.propTypes = {
className: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired
};
TagList.defaultProps = {
className: styles.tags
};
export default TagList;

View file

@ -1,3 +1,4 @@
export const QUALITY_PROFILE_ITEM = 'qualityProfileItem';
export const DELAY_PROFILE = 'delayProfile';
export const DOWNLOAD_PROTOCOL_ITEM = 'downloadProtocolItem';
export const TABLE_COLUMN = 'tableColumn';

View file

@ -153,7 +153,7 @@ class InteractiveSearchRow extends Component {
<TableRowCell className={styles.peers}>
{
protocol === 'torrent' &&
protocol === 'TorrentDownloadProtocol' &&
<Peers
seeders={seeders}
leechers={leechers}

View file

@ -13,6 +13,19 @@ import translate from 'Utilities/String/translate';
import AddDownloadClientItem from './AddDownloadClientItem';
import styles from './AddDownloadClientModalContent.css';
function mapDownloadClients(clients, onDownloadClientSelect) {
return clients.map((downloadClient) => {
return (
<AddDownloadClientItem
key={downloadClient.implementation}
implementation={downloadClient.implementation}
{...downloadClient}
onDownloadClientSelect={onDownloadClientSelect}
/>
);
});
}
class AddDownloadClientModalContent extends Component {
//
@ -25,6 +38,7 @@ class AddDownloadClientModalContent extends Component {
schemaError,
usenetDownloadClients,
torrentDownloadClients,
otherDownloadClients,
onDownloadClientSelect,
onModalClose
} = this.props;
@ -64,16 +78,7 @@ class AddDownloadClientModalContent extends Component {
<FieldSet legend={translate('Usenet')}>
<div className={styles.downloadClients}>
{
usenetDownloadClients.map((downloadClient) => {
return (
<AddDownloadClientItem
key={downloadClient.implementation}
implementation={downloadClient.implementation}
{...downloadClient}
onDownloadClientSelect={onDownloadClientSelect}
/>
);
})
mapDownloadClients(usenetDownloadClients, onDownloadClientSelect)
}
</div>
</FieldSet>
@ -81,19 +86,22 @@ class AddDownloadClientModalContent extends Component {
<FieldSet legend={translate('Torrents')}>
<div className={styles.downloadClients}>
{
torrentDownloadClients.map((downloadClient) => {
return (
<AddDownloadClientItem
key={downloadClient.implementation}
implementation={downloadClient.implementation}
{...downloadClient}
onDownloadClientSelect={onDownloadClientSelect}
/>
);
})
mapDownloadClients(torrentDownloadClients, onDownloadClientSelect)
}
</div>
</FieldSet>
{
otherDownloadClients.length ?
<FieldSet legend="Other">
<div className={styles.downloadClients}>
{
mapDownloadClients(otherDownloadClients, onDownloadClientSelect)
}
</div>
</FieldSet> :
null
}
</div>
}
</ModalBody>
@ -115,6 +123,7 @@ AddDownloadClientModalContent.propTypes = {
schemaError: PropTypes.object,
usenetDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
torrentDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
otherDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
onDownloadClientSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View file

@ -17,15 +17,18 @@ function createMapStateToProps() {
schema
} = downloadClients;
const usenetDownloadClients = _.filter(schema, { protocol: 'usenet' });
const torrentDownloadClients = _.filter(schema, { protocol: 'torrent' });
const usenetDownloadClients = _.filter(schema, { protocol: 'UsenetDownloadProtocol' });
const torrentDownloadClients = _.filter(schema, { protocol: 'TorrentDownloadProtocol' });
const otherDownloadClients = _.filter(schema, (x) => x.protocol !== 'UsenetDownloadProtocol' &&
x.protocol !== 'TorrentDownloadProtocol');
return {
isSchemaFetching,
isSchemaPopulated,
schemaError,
usenetDownloadClients,
torrentDownloadClients
torrentDownloadClients,
otherDownloadClients
};
}
);

View file

@ -13,6 +13,19 @@ import translate from 'Utilities/String/translate';
import AddIndexerItem from './AddIndexerItem';
import styles from './AddIndexerModalContent.css';
function mapIndexers(indexers, onIndexerSelect) {
return indexers.map((indexer) => {
return (
<AddIndexerItem
key={indexer.implementation}
implementation={indexer.implementation}
{...indexer}
onIndexerSelect={onIndexerSelect}
/>
);
});
}
class AddIndexerModalContent extends Component {
//
@ -25,6 +38,7 @@ class AddIndexerModalContent extends Component {
schemaError,
usenetIndexers,
torrentIndexers,
otherIndexers,
onIndexerSelect,
onModalClose
} = this.props;
@ -64,16 +78,7 @@ class AddIndexerModalContent extends Component {
<FieldSet legend={translate('Usenet')}>
<div className={styles.indexers}>
{
usenetIndexers.map((indexer) => {
return (
<AddIndexerItem
key={indexer.implementation}
implementation={indexer.implementation}
{...indexer}
onIndexerSelect={onIndexerSelect}
/>
);
})
mapIndexers(usenetIndexers, onIndexerSelect)
}
</div>
</FieldSet>
@ -81,19 +86,22 @@ class AddIndexerModalContent extends Component {
<FieldSet legend={translate('Torrents')}>
<div className={styles.indexers}>
{
torrentIndexers.map((indexer) => {
return (
<AddIndexerItem
key={indexer.implementation}
implementation={indexer.implementation}
{...indexer}
onIndexerSelect={onIndexerSelect}
/>
);
})
mapIndexers(torrentIndexers, onIndexerSelect)
}
</div>
</FieldSet>
{
otherIndexers.length ?
<FieldSet legend="Other">
<div className={styles.indexers}>
{
mapIndexers(otherIndexers, onIndexerSelect)
}
</div>
</FieldSet> :
null
}
</div>
}
</ModalBody>
@ -115,6 +123,7 @@ AddIndexerModalContent.propTypes = {
schemaError: PropTypes.object,
usenetIndexers: PropTypes.arrayOf(PropTypes.object).isRequired,
torrentIndexers: PropTypes.arrayOf(PropTypes.object).isRequired,
otherIndexers: PropTypes.arrayOf(PropTypes.object).isRequired,
onIndexerSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View file

@ -17,15 +17,18 @@ function createMapStateToProps() {
schema
} = indexers;
const usenetIndexers = _.filter(schema, { protocol: 'usenet' });
const torrentIndexers = _.filter(schema, { protocol: 'torrent' });
const usenetIndexers = _.filter(schema, { protocol: 'UsenetDownloadProtocol' });
const torrentIndexers = _.filter(schema, { protocol: 'TorrentDownloadProtocol' });
const otherIndexers = _.filter(schema, (x) => x.protocol !== 'UsenetDownloadProtocol' &&
x.protocol !== 'TorrentDownloadProtocol');
return {
isSchemaFetching,
isSchemaPopulated,
schemaError,
usenetIndexers,
torrentIndexers
torrentIndexers,
otherIndexers
};
}
);

View file

@ -7,10 +7,14 @@
line-height: 30px;
}
.column {
.name {
flex: 0 0 200px;
}
.fillcolumn {
flex: 1 0 0;
}
.actions {
display: flex;
}

View file

@ -6,28 +6,11 @@ import Link from 'Components/Link/Link';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { icons, kinds } from 'Helpers/Props';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import DelayProfileItem from './DelayProfileItem';
import EditDelayProfileModalConnector from './EditDelayProfileModalConnector';
import styles from './DelayProfile.css';
function getDelay(enabled, delay) {
if (!enabled) {
return '-';
}
if (!delay) {
return 'No Delay';
}
if (delay === 1) {
return '1 Minute';
}
// TODO: use better units of time than just minutes
return `${delay} Minutes`;
}
class DelayProfile extends Component {
//
@ -74,25 +57,14 @@ class DelayProfile extends Component {
render() {
const {
id,
enableUsenet,
enableTorrent,
preferredProtocol,
usenetDelay,
torrentDelay,
name,
items,
tags,
tagList,
isDragging,
connectDragSource
} = this.props;
let preferred = titleCase(preferredProtocol);
if (!enableUsenet) {
preferred = 'Only Torrent';
} else if (!enableTorrent) {
preferred = 'Only Usenet';
}
return (
<div
className={classNames(
@ -100,11 +72,26 @@ class DelayProfile extends Component {
isDragging && styles.isDragging
)}
>
<div className={styles.column}>{preferred}</div>
<div className={styles.column}>{getDelay(enableUsenet, usenetDelay)}</div>
<div className={styles.column}>{getDelay(enableTorrent, torrentDelay)}</div>
<div className={styles.name}>{name}</div>
<div className={styles.fillcolumn}>
{
items.map((x) => {
return (
<DelayProfileItem
key={x.protocol}
name={x.name}
allowed={x.allowed}
delay={x.delay}
/>
);
})
}
</div>
<TagList
className={styles.fillcolumn}
tags={tags}
tagList={tagList}
/>
@ -153,11 +140,8 @@ class DelayProfile extends Component {
DelayProfile.propTypes = {
id: PropTypes.number.isRequired,
enableUsenet: PropTypes.bool.isRequired,
enableTorrent: PropTypes.bool.isRequired,
preferredProtocol: PropTypes.string.isRequired,
usenetDelay: PropTypes.number.isRequired,
torrentDelay: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
isDragging: PropTypes.bool.isRequired,

View file

@ -0,0 +1,43 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
function getDelay(item) {
if (!item.allowed) {
return '-';
}
if (!item.delay) {
return 'No Delay';
}
if (item.delay === 1) {
return '1 Minute';
}
// TODO: use better units of time than just minutes
return `${item.delay} Minutes`;
}
function DelayProfileItem(props) {
const {
name,
allowed
} = props;
return (
<Label
kind={allowed ? kinds.INFO : kinds.DANGER}
>
{name}: {getDelay(props)}
</Label>
);
}
DelayProfileItem.propTypes = {
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired
};
export default DelayProfileItem;

View file

@ -12,14 +12,18 @@
font-weight: bold;
}
.column {
.name {
flex: 0 0 200px;
}
.tags {
.fillcolumn {
flex: 1 0 auto;
}
.actions {
flex: 0 0 80px;
}
.addDelayProfile {
display: flex;
justify-content: flex-end;

View file

@ -82,10 +82,10 @@ class DelayProfiles extends Component {
>
<div>
<div className={styles.delayProfilesHeader}>
<div className={styles.column}>Protocol</div>
<div className={styles.column}>Usenet Delay</div>
<div className={styles.column}>Torrent Delay</div>
<div className={styles.tags}>Tags</div>
<div className={styles.name}>Name</div>
<div className={styles.fillcolumn}>Protocols</div>
<div className={styles.fillcolumn}>Tags</div>
<div className={styles.actions} />
</div>
<div className={styles.delayProfiles}>

View file

@ -0,0 +1,82 @@
.qualityProfileItem {
display: flex;
align-items: stretch;
width: 100%;
border: 1px solid #aaa;
border-radius: 4px;
background: #fafafa;
&.isInGroup {
border-style: dashed;
}
}
.checkInputContainer {
position: relative;
margin-right: 4px;
margin-bottom: 5px;
margin-left: 8px;
}
.checkInput {
composes: input from '~Components/Form/CheckInput.css';
margin-top: 5px;
}
.delayContainer {
display: flex;
flex-grow: 0;
}
.delayInput {
composes: input from '~Components/Form/Input.css';
width: 150px;
height: 30px;
border: unset;
border-radius: unset;
background-color: unset;
box-shadow: unset;
}
.qualityNameContainer {
display: flex;
flex-grow: 1;
margin-bottom: 0;
margin-left: 2px;
font-weight: normal;
line-height: $qualityProfileItemHeight;
cursor: pointer;
}
.qualityName {
&.notAllowed {
color: #c6c6c6;
}
}
.dragHandle {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: auto;
width: $dragHandleWidth;
text-align: center;
cursor: grab;
}
.dragIcon {
top: 0;
}
.isDragging {
opacity: 0.25;
}
.isPreview {
.qualityName {
margin-left: 14px;
}
}

View file

@ -0,0 +1,113 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import CheckInput from 'Components/Form/CheckInput';
import NumberInput from 'Components/Form/NumberInput';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import styles from './DownloadProtocolItem.css';
class DownloadProtocolItem extends Component {
//
// Listeners
onChange = ({ name, value }) => {
const {
protocol,
onDownloadProtocolItemFieldChange
} = this.props;
onDownloadProtocolItemFieldChange(protocol, name, value);
};
//
// Render
render() {
const {
isPreview,
name,
allowed,
delay,
isDragging,
isOverCurrent,
connectDragSource
} = this.props;
return (
<div
className={classNames(
styles.qualityProfileItem,
isDragging && styles.isDragging,
isPreview && styles.isPreview,
isOverCurrent && styles.isOverCurrent
)}
>
<label
className={styles.qualityNameContainer}
>
<CheckInput
className={styles.checkInput}
containerClassName={styles.checkInputContainer}
name={'allowed'}
value={allowed}
onChange={this.onChange}
/>
<div className={classNames(
styles.qualityName,
!allowed && styles.notAllowed
)}
>
{name}
</div>
</label>
<NumberInput
containerClassName={styles.delayContainer}
className={styles.delayInput}
name={'delay'}
value={delay}
min={0}
max={9999999}
onChange={this.onChange}
/>
{
connectDragSource(
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
title="Create group"
name={icons.REORDER}
/>
</div>
)
}
</div>
);
}
}
DownloadProtocolItem.propTypes = {
isPreview: PropTypes.bool,
protocol: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
delay: PropTypes.number.isRequired,
isDragging: PropTypes.bool.isRequired,
isOverCurrent: PropTypes.bool.isRequired,
connectDragSource: PropTypes.func,
onDownloadProtocolItemFieldChange: PropTypes.func
};
DownloadProtocolItem.defaultProps = {
isPreview: false,
isOverCurrent: false,
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};
export default DownloadProtocolItem;

View file

@ -0,0 +1,4 @@
.dragPreview {
width: 480px;
opacity: 0.75;
}

View file

@ -0,0 +1,89 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { DragLayer } from 'react-dnd';
import DragPreviewLayer from 'Components/DragPreviewLayer';
import { DOWNLOAD_PROTOCOL_ITEM } from 'Helpers/dragTypes';
import dimensions from 'Styles/Variables/dimensions.js';
import DownloadProtocolItem from './DownloadProtocolItem';
import styles from './DownloadProtocolItemDragPreview.css';
const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
const formLabelSmallWidth = parseInt(dimensions.formLabelSmallWidth);
const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
function collectDragLayer(monitor) {
return {
item: monitor.getItem(),
itemType: monitor.getItemType(),
currentOffset: monitor.getSourceClientOffset()
};
}
class DownloadProtocolItemDragPreview extends Component {
//
// Render
render() {
const {
item,
itemType,
currentOffset
} = this.props;
if (!currentOffset || itemType !== DOWNLOAD_PROTOCOL_ITEM) {
return null;
}
// The offset is shifted because the drag handle is on the right edge of the
// list item and the preview is wider than the drag handle.
const { x, y } = currentOffset;
const handleOffset = formGroupSmallWidth - formLabelSmallWidth - formLabelRightMarginWidth - dragHandleWidth;
const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
const style = {
position: 'absolute',
WebkitTransform: transform,
msTransform: transform,
transform
};
const {
id,
name,
allowed,
delay
} = item;
return (
<DragPreviewLayer>
<div
className={styles.dragPreview}
style={style}
>
<DownloadProtocolItem
isPreview={true}
id={id}
name={name}
allowed={allowed}
delay={delay}
isDragging={false}
/>
</div>
</DragPreviewLayer>
);
}
}
DownloadProtocolItemDragPreview.propTypes = {
item: PropTypes.object,
itemType: PropTypes.string,
currentOffset: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired
})
};
export default DragLayer(collectDragLayer)(DownloadProtocolItemDragPreview);

View file

@ -0,0 +1,18 @@
.downloadProtocolItemDragSource {
padding: $qualityProfileItemDragSourcePadding 0;
}
.downloadProtocolItemPlaceholder {
width: 100%;
height: $qualityProfileItemHeight;
border: 1px dotted #aaa;
border-radius: 4px;
}
.downloadProtocolItemPlaceholderBefore {
margin-bottom: 8px;
}
.downloadProtocolItemPlaceholderAfter {
margin-top: 8px;
}

View file

@ -0,0 +1,188 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { DragSource, DropTarget } from 'react-dnd';
import { findDOMNode } from 'react-dom';
import { DOWNLOAD_PROTOCOL_ITEM } from 'Helpers/dragTypes';
import DownloadProtocolItem from './DownloadProtocolItem';
import styles from './DownloadProtocolItemDragSource.css';
const downloadProtocolItemDragSource = {
beginDrag(props) {
const {
index,
protocol,
name,
allowed,
delay
} = props;
return {
index,
protocol,
name,
allowed,
delay
};
},
endDrag(props, monitor, component) {
props.onDownloadProtocolItemDragEnd(monitor.didDrop());
}
};
const downloadProtocolItemDropTarget = {
hover(props, monitor, component) {
const {
index: dragIndex
} = monitor.getItem();
const dropIndex = props.index;
// Use childNodeIndex to select the correct node to get the middle of so
// we don't bounce between above and below causing rapid setState calls.
const childNodeIndex = component.props.isOverCurrent && component.props.isDraggingUp ? 1 :0;
const componentDOMNode = findDOMNode(component).children[childNodeIndex];
const hoverBoundingRect = componentDOMNode.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
// If we're hovering over a child don't trigger on the parent
if (!monitor.isOver({ shallow: true })) {
return;
}
// Don't show targets for dropping on self
if (dragIndex === dropIndex) {
return;
}
let dropPosition = null;
// Determine drop position based on position over target
if (hoverClientY > hoverMiddleY) {
dropPosition = 'below';
} else if (hoverClientY < hoverMiddleY) {
dropPosition = 'above';
} else {
return;
}
props.onDownloadProtocolItemDragMove({
dragIndex,
dropIndex,
dropPosition
});
}
};
function collectDragSource(connect, monitor) {
return {
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging()
};
}
function collectDropTarget(connect, monitor) {
return {
connectDropTarget: connect.dropTarget(),
isOver: monitor.isOver(),
isOverCurrent: monitor.isOver({ shallow: true })
};
}
class DownloadProtocolItemDragSource extends Component {
//
// Render
render() {
const {
protocol,
name,
allowed,
delay,
index,
isDragging,
isDraggingUp,
isDraggingDown,
isOverCurrent,
connectDragSource,
connectDropTarget,
onDownloadProtocolItemFieldChange
} = this.props;
const isBefore = !isDragging && isDraggingUp && isOverCurrent;
const isAfter = !isDragging && isDraggingDown && isOverCurrent;
return connectDropTarget(
<div
className={classNames(
styles.downloadProtocolItemDragSource,
isBefore && styles.isDraggingUp,
isAfter && styles.isDraggingDown
)}
>
{
isBefore &&
<div
className={classNames(
styles.downloadProtocolItemPlaceholder,
styles.downloadProtocolItemPlaceholderBefore
)}
/>
}
<DownloadProtocolItem
protocol={protocol}
name={name}
allowed={allowed}
delay={delay}
index={index}
isDragging={isDragging}
isOverCurrent={isOverCurrent}
connectDragSource={connectDragSource}
onDownloadProtocolItemFieldChange={onDownloadProtocolItemFieldChange}
/>
{
isAfter &&
<div
className={classNames(
styles.downloadProtocolItemPlaceholder,
styles.downloadProtocolItemPlaceholderAfter
)}
/>
}
</div>
);
}
}
DownloadProtocolItemDragSource.propTypes = {
protocol: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
delay: PropTypes.number.isRequired,
index: PropTypes.number.isRequired,
isDragging: PropTypes.bool,
isDraggingUp: PropTypes.bool,
isDraggingDown: PropTypes.bool,
isOverCurrent: PropTypes.bool,
connectDragSource: PropTypes.func,
connectDropTarget: PropTypes.func,
onDownloadProtocolItemFieldChange: PropTypes.func.isRequired,
onDownloadProtocolItemDragMove: PropTypes.func.isRequired,
onDownloadProtocolItemDragEnd: PropTypes.func.isRequired
};
export default DropTarget(
DOWNLOAD_PROTOCOL_ITEM,
downloadProtocolItemDropTarget,
collectDropTarget
)(DragSource(
DOWNLOAD_PROTOCOL_ITEM,
downloadProtocolItemDragSource,
collectDragSource
)(DownloadProtocolItemDragSource));

View file

@ -0,0 +1,24 @@
.qualities {
margin-top: 10px;
transition: min-height 200ms;
user-select: none;
}
.headerContainer {
display: flex;
font-weight: bold;
line-height: 35px;
}
.headerTitle {
display: flex;
flex-grow: 1;
}
.headerDelay {
display: flex;
flex-grow: 0;
margin-right: 40px;
padding-left: 16px;
width: 150px;
}

View file

@ -0,0 +1,150 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputHelpText from 'Components/Form/FormInputHelpText';
import FormLabel from 'Components/Form/FormLabel';
import Measure from 'Components/Measure';
import { sizes } from 'Helpers/Props';
import DownloadProtocolItemDragPreview from './DownloadProtocolItemDragPreview';
import DownloadProtocolItemDragSource from './DownloadProtocolItemDragSource';
import styles from './DownloadProtocolItems.css';
class DownloadProtocolItems extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
height: 0
};
}
//
// Listeners
onMeasure = ({ height }) => {
this.setState({ height });
};
//
// Render
render() {
const {
dropIndex,
dropPosition,
items,
errors,
warnings,
...otherProps
} = this.props;
const {
height
} = this.state;
const isDragging = dropIndex !== null;
const isDraggingUp = isDragging && dropPosition === 'above';
const isDraggingDown = isDragging && dropPosition === 'below';
return (
<FormGroup size={sizes.SMALL}>
<FormLabel size={sizes.SMALL}>
Download Protocols
</FormLabel>
<div>
<FormInputHelpText
text="Protocols higher in the list are more preferred. Only checked protocols are allowed"
/>
{
errors.map((error, index) => {
return (
<FormInputHelpText
key={index}
text={error.message}
isError={true}
isCheckInput={false}
/>
);
})
}
{
warnings.map((warning, index) => {
return (
<FormInputHelpText
key={index}
text={warning.message}
isWarning={true}
isCheckInput={false}
/>
);
})
}
<Measure
whitelist={['height']}
includeMargin={false}
onMeasure={this.onMeasure}
>
<div
className={styles.qualities}
style={{ minHeight: `${height}px` }}
>
<div className={styles.headerContainer}>
<div className={styles.headerTitle}>
Protocol
</div>
<div className={styles.headerDelay}>
Delay (minutes)
</div>
</div>
{
items.map(({ protocol, name, allowed, delay }, index) => {
return (
<DownloadProtocolItemDragSource
key={protocol}
protocol={protocol}
name={name}
allowed={allowed}
delay={delay}
index={index}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
{...otherProps}
/>
);
})
}
<DownloadProtocolItemDragPreview />
</div>
</Measure>
</div>
</FormGroup>
);
}
}
DownloadProtocolItems.propTypes = {
dragIndex: PropTypes.number,
dropIndex: PropTypes.number,
dropPosition: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object)
};
DownloadProtocolItems.defaultProps = {
errors: [],
warnings: []
};
export default DownloadProtocolItems;

View file

@ -12,23 +12,22 @@ 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 { boolSettingShape, numberSettingShape, tagSettingShape } from 'Helpers/Props/Shapes/settingShape';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import { numberSettingShape, stringSettingShape, tagSettingShape } from 'Helpers/Props/Shapes/settingShape';
import translate from 'Utilities/String/translate';
import DownloadProtocolItems from './DownloadProtocolItems';
import styles from './EditDelayProfileModalContent.css';
function EditDelayProfileModalContent(props) {
const {
id,
isFetching,
isPopulated,
error,
isSaving,
saveError,
item,
protocol,
protocolOptions,
onInputChange,
onProtocolChange,
onSavePress,
onModalClose,
onDeleteDelayProfilePress,
@ -36,10 +35,8 @@ function EditDelayProfileModalContent(props) {
} = props;
const {
enableUsenet,
enableTorrent,
usenetDelay,
torrentDelay,
name,
items,
tags
} = item;
@ -58,72 +55,43 @@ function EditDelayProfileModalContent(props) {
{
!isFetching && !!error &&
<div>
{translate('UnableToAddANewQualityProfilePleaseTryAgain')}
{translate('UnableToAddANewDelayProfilePleaseTryAgain')}
</div>
}
{
!isFetching && !error &&
!isFetching && isPopulated && !error &&
<Form {...otherProps}>
<FormGroup>
<FormLabel>
{translate('Protocol')}
<FormGroup size={sizes.SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('Name')}
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="protocol"
value={protocol}
values={protocolOptions}
helpText={translate('ProtocolHelpText')}
onChange={onProtocolChange}
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
{
enableUsenet.value &&
<FormGroup>
<FormLabel>
{translate('UsenetDelay')}
</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="usenetDelay"
unit="minutes"
{...usenetDelay}
helpText={translate('UsenetDelayHelpText')}
onChange={onInputChange}
/>
</FormGroup>
}
{
enableTorrent.value &&
<FormGroup>
<FormLabel>
{translate('TorrentDelay')}
</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="torrentDelay"
unit="minutes"
{...torrentDelay}
helpText={translate('TorrentDelayHelpText')}
onChange={onInputChange}
/>
</FormGroup>
}
<div className={styles.formGroupWrapper}>
<DownloadProtocolItems
items={items.value}
errors={items.errors}
warnings={items.warnings}
{...otherProps}
/>
</div>
{
id === 1 ?
<Alert>
This is the default profile. It applies to all artist that don't have an explicit profile.
This is the default profile. It applies to all artists that don't have an explicit profile.
</Alert> :
<FormGroup>
<FormLabel>
<FormGroup size={sizes.SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('Tags')}
</FormLabel>
@ -170,10 +138,8 @@ function EditDelayProfileModalContent(props) {
}
const delayProfileShape = {
enableUsenet: PropTypes.shape(boolSettingShape).isRequired,
enableTorrent: PropTypes.shape(boolSettingShape).isRequired,
usenetDelay: PropTypes.shape(numberSettingShape).isRequired,
torrentDelay: PropTypes.shape(numberSettingShape).isRequired,
name: PropTypes.shape(stringSettingShape).isRequired,
items: PropTypes.object.isRequired,
order: PropTypes.shape(numberSettingShape),
tags: PropTypes.shape(tagSettingShape).isRequired
};
@ -181,14 +147,12 @@ const delayProfileShape = {
EditDelayProfileModalContent.propTypes = {
id: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.shape(delayProfileShape).isRequired,
protocol: PropTypes.string.isRequired,
protocolOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
onInputChange: PropTypes.func.isRequired,
onProtocolChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteDelayProfilePress: PropTypes.func

View file

@ -3,82 +3,15 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveDelayProfile, setDelayProfileValue } from 'Store/Actions/settingsActions';
import selectSettings from 'Store/Selectors/selectSettings';
import { fetchDelayProfileSchema, saveDelayProfile, setDelayProfileValue } from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import EditDelayProfileModalContent from './EditDelayProfileModalContent';
const newDelayProfile = {
enableUsenet: true,
enableTorrent: true,
preferredProtocol: 'usenet',
usenetDelay: 0,
torrentDelay: 0,
tags: []
};
const protocolOptions = [
{ key: 'preferUsenet', value: 'Prefer Usenet' },
{ key: 'preferTorrent', value: 'Prefer Torrent' },
{ key: 'onlyUsenet', value: 'Only Usenet' },
{ key: 'onlyTorrent', value: 'Only Torrent' }
];
function createDelayProfileSelector() {
return createSelector(
(state, { id }) => id,
(state) => state.settings.delayProfiles,
(id, delayProfiles) => {
const {
isFetching,
error,
isSaving,
saveError,
pendingChanges,
items
} = delayProfiles;
const profile = id ? _.find(items, { id }) : newDelayProfile;
const settings = selectSettings(profile, pendingChanges, saveError);
return {
id,
isFetching,
error,
isSaving,
saveError,
item: settings.settings,
...settings
};
}
);
}
function createMapStateToProps() {
return createSelector(
createDelayProfileSelector(),
createProviderSettingsSelector('delayProfiles'),
(delayProfile) => {
const enableUsenet = delayProfile.item.enableUsenet.value;
const enableTorrent = delayProfile.item.enableTorrent.value;
const preferredProtocol = delayProfile.item.preferredProtocol.value;
let protocol = 'preferUsenet';
if (preferredProtocol === 'usenet') {
protocol = 'preferUsenet';
} else {
protocol = 'preferTorrent';
}
if (!enableUsenet) {
protocol = 'onlyTorrent';
}
if (!enableTorrent) {
protocol = 'onlyUsenet';
}
return {
protocol,
protocolOptions,
...delayProfile
};
}
@ -86,6 +19,7 @@ function createMapStateToProps() {
}
const mapDispatchToProps = {
fetchDelayProfileSchema,
setDelayProfileValue,
saveDelayProfile
};
@ -95,14 +29,19 @@ class EditDelayProfileModalContentConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
dragIndex: null,
dropIndex: null,
dropPosition: null
};
}
componentDidMount() {
if (!this.props.id) {
Object.keys(newDelayProfile).forEach((name) => {
this.props.setDelayProfileValue({
name,
value: newDelayProfile[name]
});
});
if (!this.props.id && !this.props.isPopulated) {
this.props.fetchDelayProfileSchema();
}
}
@ -119,35 +58,77 @@ class EditDelayProfileModalContentConnector extends Component {
this.props.setDelayProfileValue({ name, value });
};
onProtocolChange = ({ value }) => {
switch (value) {
case 'preferUsenet':
this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' });
break;
case 'preferTorrent':
this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' });
break;
case 'onlyUsenet':
this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
this.props.setDelayProfileValue({ name: 'enableTorrent', value: false });
this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' });
break;
case 'onlyTorrent':
this.props.setDelayProfileValue({ name: 'enableUsenet', value: false });
this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' });
break;
default:
throw Error(`Unknown protocol option: ${value}`);
onSavePress = () => {
this.props.saveDelayProfile({ id: this.props.id });
};
onDownloadProtocolItemFieldChange = (protocol, name, value) => {
const delayProfile = _.cloneDeep(this.props.item);
const items = delayProfile.items.value;
const item = _.find(delayProfile.items.value, (i) => i.protocol === protocol);
item[name] = value;
this.props.setDelayProfileValue({
name: 'items',
value: items
});
};
onDownloadProtocolItemDragMove = ({ dragIndex, dropIndex, dropPosition }) => {
if (
(dropPosition === 'below' && dropIndex + 1 === dragIndex) ||
(dropPosition === 'above' && dropIndex - 1 === dragIndex)
) {
if (
this.state.dragIndex != null &&
this.state.dropIndex != null &&
this.state.dropPosition != null
) {
this.setState({
dragIndex: null,
dropIndex: null,
dropPosition: null
});
}
return;
}
if (this.state.dragIndex !== dragIndex ||
this.state.dropIndex !== dropIndex ||
this.state.dropPosition !== dropPosition) {
this.setState({
dragIndex,
dropIndex,
dropPosition
});
}
};
onSavePress = () => {
this.props.saveDelayProfile({ id: this.props.id });
onDownloadProtocolItemDragEnd = (didDrop) => {
const {
dragIndex,
dropIndex
} = this.state;
if (didDrop && dropIndex !== null) {
const delayProfile = _.cloneDeep(this.props.item);
const items = delayProfile.items.value;
const item = items.splice(dragIndex, 1)[0];
items.splice(dropIndex, 0, item);
this.props.setDelayProfileValue({
name: 'items',
value: items
});
}
this.setState({
dragIndex: null,
dropIndex: null
});
};
//
@ -156,10 +137,13 @@ class EditDelayProfileModalContentConnector extends Component {
render() {
return (
<EditDelayProfileModalContent
{...this.state}
{...this.props}
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
onProtocolChange={this.onProtocolChange}
onDownloadProtocolItemFieldChange={this.onDownloadProtocolItemFieldChange}
onDownloadProtocolItemDragMove={this.onDownloadProtocolItemDragMove}
onDownloadProtocolItemDragEnd={this.onDownloadProtocolItemDragEnd}
/>
);
}
@ -167,9 +151,11 @@ class EditDelayProfileModalContentConnector extends Component {
EditDelayProfileModalContentConnector.propTypes = {
id: PropTypes.number,
isPopulated: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
fetchDelayProfileSchema: PropTypes.func.isRequired,
setDelayProfileValue: PropTypes.func.isRequired,
saveDelayProfile: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired

View file

@ -0,0 +1,15 @@
.delayProfile {
display: flex;
align-items: stretch;
margin-bottom: 10px;
height: 30px;
line-height: 30px;
}
.name {
flex: 0 0 200px;
}
.tags {
flex: 1 0 auto;
}

View file

@ -1,35 +1,41 @@
import PropTypes from 'prop-types';
import React from 'react';
import titleCase from 'Utilities/String/titleCase';
import DelayProfileItem from 'Settings/Profiles/Delay/DelayProfileItem';
import styles from './TagDetailsDelayProfile.css';
function TagDetailsDelayProfile(props) {
const {
preferredProtocol,
enableUsenet,
enableTorrent,
usenetDelay,
torrentDelay
name: profileName,
items
} = props;
return (
<div>
<div>
Protocol: {titleCase(preferredProtocol)}
<div
className={styles.delayProfile}
>
<div
className={styles.name}
>
{profileName}
</div>
<div>
<div className={styles.tags}>
{
enableUsenet ?
`Usenet Delay: ${usenetDelay}` :
'Usenet disabled'
}
</div>
items.map((item) => {
const {
protocol,
name,
allowed
} = item;
<div>
{
enableTorrent ?
`Torrent Delay: ${torrentDelay}` :
'Torrents disabled'
return (
<DelayProfileItem
key={protocol}
name={name}
allowed={allowed}
/>
);
})
}
</div>
</div>
@ -37,11 +43,8 @@ function TagDetailsDelayProfile(props) {
}
TagDetailsDelayProfile.propTypes = {
preferredProtocol: PropTypes.string.isRequired,
enableUsenet: PropTypes.bool.isRequired,
enableTorrent: PropTypes.bool.isRequired,
usenetDelay: PropTypes.number.isRequired,
torrentDelay: PropTypes.number.isRequired
name: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default TagDetailsDelayProfile;

View file

@ -13,7 +13,7 @@ function TagDetailsModal(props) {
return (
<Modal
size={sizes.SMALL}
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>

View file

@ -62,21 +62,15 @@ function TagDetailsModalContent(props) {
delayProfiles.map((item) => {
const {
id,
preferredProtocol,
enableUsenet,
enableTorrent,
usenetDelay,
torrentDelay
name,
items
} = item;
return (
<TagDetailsDelayProfile
key={id}
preferredProtocol={preferredProtocol}
enableUsenet={enableUsenet}
enableTorrent={enableTorrent}
usenetDelay={usenetDelay}
torrentDelay={torrentDelay}
name={name}
items={items}
/>
);
})

View file

@ -52,6 +52,10 @@ export default {
isFetching: false,
isPopulated: false,
error: null,
isSchemaFetching: false,
isSchemaPopulated: false,
schemaError: null,
schema: {},
items: [],
isSaving: false,
saveError: null,

View file

@ -49,6 +49,7 @@ export const defaultState = {
export const SHOW_MESSAGE = 'app/showMessage';
export const HIDE_MESSAGE = 'app/hideMessage';
export const CLEAR_MESSAGES = 'app/clearMessages';
export const SAVE_DIMENSIONS = 'app/saveDimensions';
export const SET_VERSION = 'app/setVersion';
export const SET_APP_VALUE = 'app/setAppValue';
@ -65,6 +66,7 @@ export const setIsSidebarVisible = createAction(SET_IS_SIDEBAR_VISIBLE);
export const setAppValue = createAction(SET_APP_VALUE);
export const showMessage = createAction(SHOW_MESSAGE);
export const hideMessage = createAction(HIDE_MESSAGE);
export const clearMessages = createAction(CLEAR_MESSAGES);
export const pingServer = createThunk(PING_SERVER);
//
@ -173,6 +175,14 @@ export const reducers = createHandleActions({
return updateSectionState(state, messagesSection, newState);
},
[CLEAR_MESSAGES]: function(state) {
const newState = getSectionState(state, messagesSection);
newState.items = [];
return updateSectionState(state, messagesSection, newState);
},
[SET_APP_VALUE]: function(state, { payload }) {
const newState = Object.assign(getSectionState(state, section), payload);

View file

@ -70,6 +70,13 @@ export const defaultState = {
items: []
},
plugins: {
isFetching: false,
isPopulated: false,
error: null,
items: []
},
logs: {
isFetching: false,
isPopulated: false,
@ -199,6 +206,8 @@ export const DELETE_BACKUP = 'system/backups/deleteBackup';
export const FETCH_UPDATES = 'system/updates/fetchUpdates';
export const FETCH_INSTALLED_PLUGINS = 'system/plugins/fetchInstalledPlugins';
export const FETCH_LOGS = 'system/logs/fetchLogs';
export const GOTO_FIRST_LOGS_PAGE = 'system/logs/gotoLogsFirstPage';
export const GOTO_PREVIOUS_LOGS_PAGE = 'system/logs/gotoLogsPreviousPage';
@ -233,6 +242,8 @@ export const deleteBackup = createThunk(DELETE_BACKUP);
export const fetchUpdates = createThunk(FETCH_UPDATES);
export const fetchInstalledPlugins = createThunk(FETCH_INSTALLED_PLUGINS);
export const fetchLogs = createThunk(FETCH_LOGS);
export const gotoLogsFirstPage = createThunk(GOTO_FIRST_LOGS_PAGE);
export const gotoLogsPreviousPage = createThunk(GOTO_PREVIOUS_LOGS_PAGE);
@ -326,6 +337,7 @@ export const actionHandlers = handleThunks({
[DELETE_BACKUP]: createRemoveItemHandler(backupsSection, '/system/backup'),
[FETCH_UPDATES]: createFetchHandler('system.updates', '/update'),
[FETCH_INSTALLED_PLUGINS]: createFetchHandler('system.plugins', '/system/plugins'),
[FETCH_LOG_FILES]: createFetchHandler('system.logFiles', '/log/file'),
[FETCH_UPDATE_LOG_FILES]: createFetchHandler('system.updateLogFiles', '/log/file/update'),

View file

@ -0,0 +1,11 @@
.version {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 150px;
}
.actions {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 150px;
}

View file

@ -0,0 +1,79 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { icons, kinds } from 'Helpers/Props';
import styles from './PluginRow.css';
class PluginRow extends Component {
//
// Listeners
onInstallPluginPress = () => {
this.props.onInstallPluginPress(this.props.githubUrl);
};
onUninstallPluginPress = () => {
this.props.onUninstallPluginPress(this.props.githubUrl);
};
//
// Render
render() {
const {
name,
owner,
installedVersion,
availableVersion,
updateAvailable,
isInstallingPlugin,
isUninstallingPlugin
} = this.props;
return (
<TableRow>
<TableRowCell>{name}</TableRowCell>
<TableRowCell>{owner}</TableRowCell>
<TableRowCell className={styles.version}>{installedVersion}</TableRowCell>
<TableRowCell className={styles.version}>{availableVersion}</TableRowCell>
<TableRowCell
className={styles.actions}
>
{
updateAvailable &&
<SpinnerIconButton
name={icons.UPDATE}
kind={kinds.DEFAULT}
isSpinning={isInstallingPlugin}
onPress={this.onInstallPluginPress}
/>
}
<SpinnerIconButton
name={icons.DELETE}
kind={kinds.DEFAULT}
isSpinning={isUninstallingPlugin}
onPress={this.onUninstallPluginPress}
/>
</TableRowCell>
</TableRow>
);
}
}
PluginRow.propTypes = {
githubUrl: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
owner: PropTypes.string.isRequired,
installedVersion: PropTypes.string.isRequired,
availableVersion: PropTypes.string.isRequired,
updateAvailable: PropTypes.bool.isRequired,
isInstallingPlugin: PropTypes.bool.isRequired,
onInstallPluginPress: PropTypes.func.isRequired,
isUninstallingPlugin: PropTypes.bool.isRequired,
onUninstallPluginPress: PropTypes.func.isRequired
};
export default PluginRow;

View file

@ -0,0 +1,6 @@
.loading {
composes: loading from '~Components/Loading/LoadingIndicator.css';
margin-top: 5px;
margin-left: auto;
}

View file

@ -0,0 +1,168 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { inputTypes, kinds } from 'Helpers/Props';
import PluginRow from './PluginRow';
const columns = [
{
name: 'name',
label: 'Name',
isVisible: true
},
{
name: 'owner',
label: 'Owner',
isVisible: true
},
{
name: 'installedVersion',
label: 'Installed Version',
isVisible: true
},
{
name: 'availableVersion',
label: 'Available Version',
isVisible: true
},
{
name: 'actions',
isVisible: true
}
];
class Plugins extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
repoUrl: ''
};
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.setState({
[name]: value
});
};
onInstallPluginPress = () => {
this.props.onInstallPluginPress(this.state.repoUrl);
};
//
// Render
render() {
const {
isPopulated,
error,
items,
isInstallingPlugin,
onInstallPluginPress,
isUninstallingPlugin,
onUninstallPluginPress
} = this.props;
const {
repoUrl
} = this.state;
const noPlugins = isPopulated && !error && !items.length;
return (
<PageContent title="Plugins">
<PageContentBody>
<Form>
<FieldSet legend="Install New Plugin">
<FormGroup>
<FormLabel>GitHub URL</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="repoUrl"
helpText="URL to GitHub repository containing plugin"
helpLink="https://wiki.servarr.com/Lidarr_FAQ#How_do_I_install_plugins"
value={repoUrl}
onChange={this.onInputChange}
/>
</FormGroup>
<SpinnerButton
kind={kinds.PRIMARY}
isSpinning={isInstallingPlugin}
onPress={this.onInstallPluginPress}
>
Install
</SpinnerButton>
</FieldSet>
</Form>
<FieldSet legend="Installed Plugins">
{
!isPopulated && !error &&
<LoadingIndicator />
}
{
isPopulated && noPlugins &&
<div>No plugins are installed</div>
}
{
isPopulated && !noPlugins &&
<Table
columns={columns}
>
<TableBody>
{
items.map((plugin) => {
return (
<PluginRow
key={plugin.githubUrl}
{...plugin}
isInstallingPlugin={isInstallingPlugin}
isUninstallingPlugin={isUninstallingPlugin}
onInstallPluginPress={onInstallPluginPress}
onUninstallPluginPress={onUninstallPluginPress}
/>
);
})
}
</TableBody>
</Table>
}
</FieldSet>
</PageContentBody>
</PageContent>
);
}
}
Plugins.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.array.isRequired,
isInstallingPlugin: PropTypes.bool.isRequired,
onInstallPluginPress: PropTypes.func.isRequired,
isUninstallingPlugin: PropTypes.bool.isRequired,
onUninstallPluginPress: PropTypes.func.isRequired
};
export default Plugins;

View file

@ -0,0 +1,95 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchInstalledPlugins } from 'Store/Actions/systemActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import Plugins from './Plugins';
function createMapStateToProps() {
return createSelector(
(state) => state.system.plugins,
createCommandExecutingSelector(commandNames.INSTALL_PLUGIN),
createCommandExecutingSelector(commandNames.UNINSTALL_PLUGIN),
(
plugins,
isInstallingPlugin,
isUninstallingPlugin
) => {
return {
...plugins,
isInstallingPlugin,
isUninstallingPlugin
};
}
);
}
const mapDispatchToProps = {
dispatchFetchInstalledPlugins: fetchInstalledPlugins,
dispatchExecuteCommand: executeCommand
};
class PluginsConnector extends Component {
//
// Lifecycle
componentDidMount() {
registerPagePopulator(this.repopulate);
this.repopulate();
}
componentWillUnmount() {
unregisterPagePopulator(this.repopulate);
}
//
// Control
repopulate = () => {
this.props.dispatchFetchInstalledPlugins();
};
//
// Listeners
onInstallPluginPress = (url) => {
this.props.dispatchExecuteCommand({
name: commandNames.INSTALL_PLUGIN,
githubUrl: url
});
};
onUninstallPluginPress = (url) => {
this.props.dispatchExecuteCommand({
name: commandNames.UNINSTALL_PLUGIN,
githubUrl: url
});
};
//
// Render
render() {
return (
<Plugins
onInstallPluginPress={this.onInstallPluginPress}
onUninstallPluginPress={this.onUninstallPluginPress}
{...this.props}
/>
);
}
}
PluginsConnector.propTypes = {
dispatchFetchInstalledPlugins: PropTypes.func.isRequired,
dispatchExecuteCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(PluginsConnector);