mirror of
https://github.com/Sonarr/Sonarr.git
synced 2025-04-23 13:57:06 -04:00
Upgrade react-dnd and DnD Components to TypeScript
This commit is contained in:
parent
572bdc979c
commit
1bc1b080d1
85 changed files with 3525 additions and 4767 deletions
|
@ -39,7 +39,7 @@ export interface AutoTaggingSpecificationAppState
|
|||
AppSectionSchemaState<AutoTaggingSpecification> {}
|
||||
|
||||
export interface DelayProfileAppState
|
||||
extends AppSectionState<DelayProfile>,
|
||||
extends AppSectionListState<DelayProfile>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
|
@ -77,7 +77,7 @@ export interface NotificationAppState
|
|||
AppSectionDeleteState {}
|
||||
|
||||
export interface QualityDefinitionsAppState
|
||||
extends AppSectionState<QualityProfile>,
|
||||
extends AppSectionState<QualityDefinition>,
|
||||
AppSectionSaveState {
|
||||
pendingChanges: {
|
||||
[key: number]: Partial<QualityProfile>;
|
||||
|
@ -86,7 +86,9 @@ export interface QualityDefinitionsAppState
|
|||
|
||||
export interface QualityProfilesAppState
|
||||
extends AppSectionState<QualityProfile>,
|
||||
AppSectionItemSchemaState<QualityProfile> {}
|
||||
AppSectionItemSchemaState<QualityProfile>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface ReleaseProfilesAppState
|
||||
extends AppSectionState<ReleaseProfile>,
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
.dragLayer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'dragLayer': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
|
@ -1,21 +0,0 @@
|
|||
import React from 'react';
|
||||
import styles from './DragPreviewLayer.css';
|
||||
|
||||
interface DragPreviewLayerProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function DragPreviewLayer({
|
||||
className = styles.dragLayer,
|
||||
children,
|
||||
...otherProps
|
||||
}: DragPreviewLayerProps) {
|
||||
return (
|
||||
<div className={className} {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DragPreviewLayer;
|
|
@ -1,16 +1,21 @@
|
|||
import React from 'react';
|
||||
import React, { ForwardedRef, forwardRef, ReactNode } from 'react';
|
||||
import styles from './ModalFooter.css';
|
||||
|
||||
interface ModalFooterProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function ModalFooter({ children, ...otherProps }: ModalFooterProps) {
|
||||
return (
|
||||
<div className={styles.modalFooter} {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const ModalFooter = forwardRef(
|
||||
(
|
||||
{ children, ...otherProps }: ModalFooterProps,
|
||||
ref: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
return (
|
||||
<div ref={ref} className={styles.modalFooter} {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default ModalFooter;
|
||||
|
|
|
@ -1,16 +1,21 @@
|
|||
import React from 'react';
|
||||
import React, { ForwardedRef, forwardRef, ReactNode } from 'react';
|
||||
import styles from './ModalHeader.css';
|
||||
|
||||
interface ModalHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function ModalHeader({ children, ...otherProps }: ModalHeaderProps) {
|
||||
return (
|
||||
<div className={styles.modalHeader} {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const ModalHeader = forwardRef(
|
||||
(
|
||||
{ children, ...otherProps }: ModalHeaderProps,
|
||||
ref: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
return (
|
||||
<div ref={ref} className={styles.modalHeader} {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default ModalHeader;
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
.columnContainer {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
@ -43,6 +47,17 @@
|
|||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.notDragable {
|
||||
padding: 4px 0;
|
||||
.placeholder {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px dotted #aaa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.placeholderBefore {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.placeholderAfter {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
|
|
@ -3,11 +3,14 @@
|
|||
interface CssExports {
|
||||
'checkContainer': string;
|
||||
'column': string;
|
||||
'columnContainer': string;
|
||||
'dragHandle': string;
|
||||
'dragIcon': string;
|
||||
'isDragging': string;
|
||||
'label': string;
|
||||
'notDragable': string;
|
||||
'placeholder': string;
|
||||
'placeholderAfter': string;
|
||||
'placeholderBefore': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
|
|
@ -1,68 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import styles from './TableOptionsColumn.css';
|
||||
|
||||
function TableOptionsColumn(props) {
|
||||
const {
|
||||
name,
|
||||
label,
|
||||
isVisible,
|
||||
isModifiable,
|
||||
isDragging,
|
||||
connectDragSource,
|
||||
onVisibleChange
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={isModifiable ? undefined : styles.notDragable}>
|
||||
<div
|
||||
className={classNames(
|
||||
styles.column,
|
||||
isDragging && styles.isDragging
|
||||
)}
|
||||
>
|
||||
<label
|
||||
className={styles.label}
|
||||
>
|
||||
<CheckInput
|
||||
containerClassName={styles.checkContainer}
|
||||
name={name}
|
||||
value={isVisible}
|
||||
isDisabled={isModifiable === false}
|
||||
onChange={onVisibleChange}
|
||||
/>
|
||||
{typeof label === 'function' ? label() : label}
|
||||
</label>
|
||||
|
||||
{
|
||||
!!connectDragSource &&
|
||||
connectDragSource(
|
||||
<div className={styles.dragHandle}>
|
||||
<Icon
|
||||
className={styles.dragIcon}
|
||||
name={icons.REORDER}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TableOptionsColumn.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
|
||||
isVisible: PropTypes.bool.isRequired,
|
||||
isModifiable: PropTypes.bool.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
isDragging: PropTypes.bool,
|
||||
connectDragSource: PropTypes.func,
|
||||
onVisibleChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default TableOptionsColumn;
|
|
@ -0,0 +1,163 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useRef } from 'react';
|
||||
import { DragSourceMonitor, useDrag, useDrop, XYCoord } from 'react-dnd';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import DragType from 'Helpers/DragType';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import Column from '../Column';
|
||||
import styles from './TableOptionsColumn.css';
|
||||
|
||||
interface DragItem {
|
||||
name: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface TableOptionsColumnProps {
|
||||
name: string;
|
||||
label: Column['label'];
|
||||
isDraggingDown: boolean;
|
||||
isDraggingUp: boolean;
|
||||
isVisible: boolean;
|
||||
isModifiable: boolean;
|
||||
index: number;
|
||||
onVisibleChange: (change: CheckInputChanged) => void;
|
||||
onColumnDragEnd: (didDrop: boolean) => void;
|
||||
onColumnDragMove: (dragIndex: number, hoverIndex: number) => void;
|
||||
}
|
||||
|
||||
function TableOptionsColumn({
|
||||
name,
|
||||
label,
|
||||
index,
|
||||
isDraggingDown,
|
||||
isDraggingUp,
|
||||
isVisible,
|
||||
isModifiable,
|
||||
onVisibleChange,
|
||||
onColumnDragEnd,
|
||||
onColumnDragMove,
|
||||
}: TableOptionsColumnProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [{ isOver }, dropRef] = useDrop<DragItem, void, { isOver: boolean }>({
|
||||
accept: DragType.TableColumn,
|
||||
collect(monitor) {
|
||||
return {
|
||||
isOver: monitor.isOver(),
|
||||
};
|
||||
},
|
||||
hover(item: DragItem, monitor) {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isModifiable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = index;
|
||||
|
||||
// Don't replace items with themselves
|
||||
if (dragIndex === hoverIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine rectangle on screen
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
|
||||
// Get vertical middle
|
||||
const hoverMiddleY =
|
||||
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
|
||||
// Determine mouse position
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
|
||||
// Get pixels to the top
|
||||
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
|
||||
|
||||
// When moving up, only trigger if drag position is above 50% and
|
||||
// when moving down, only trigger if drag position is below 50%.
|
||||
// If we're moving down the hoverIndex needs to be increased
|
||||
// by one so it's ordered properly. Otherwise the hoverIndex will work.
|
||||
|
||||
// Dragging downwards
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dragging upwards
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
onColumnDragMove(dragIndex, hoverIndex);
|
||||
},
|
||||
});
|
||||
|
||||
const [{ isDragging }, dragRef, previewRef] = useDrag<
|
||||
DragItem,
|
||||
unknown,
|
||||
{ isDragging: boolean }
|
||||
>({
|
||||
type: DragType.TableColumn,
|
||||
item: () => {
|
||||
return {
|
||||
name,
|
||||
index,
|
||||
};
|
||||
},
|
||||
collect: (monitor: DragSourceMonitor<unknown, unknown>) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
end: (_item: DragItem, monitor) => {
|
||||
onColumnDragEnd(monitor.didDrop());
|
||||
},
|
||||
});
|
||||
|
||||
dropRef(previewRef(ref));
|
||||
|
||||
const isBefore = !isDragging && isDraggingUp && isOver;
|
||||
const isAfter = !isDragging && isDraggingDown && isOver;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.columnContainer}>
|
||||
{isBefore ? (
|
||||
<div
|
||||
className={classNames(styles.placeholder, styles.placeholderBefore)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={classNames(styles.column, isDragging && styles.isDragging)}
|
||||
>
|
||||
<label className={styles.label}>
|
||||
<CheckInput
|
||||
containerClassName={styles.checkContainer}
|
||||
name={name}
|
||||
value={isVisible}
|
||||
isDisabled={isModifiable === false}
|
||||
onChange={onVisibleChange}
|
||||
/>
|
||||
{typeof label === 'function' ? label() : label}
|
||||
</label>
|
||||
|
||||
{isModifiable ? (
|
||||
<div ref={dragRef} className={styles.dragHandle}>
|
||||
<Icon className={styles.dragIcon} name={icons.REORDER} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isAfter ? (
|
||||
<div
|
||||
className={classNames(styles.placeholder, styles.placeholderAfter)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TableOptionsColumn;
|
|
@ -1,4 +0,0 @@
|
|||
.dragPreview {
|
||||
width: 380px;
|
||||
opacity: 0.75;
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'dragPreview': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
|
@ -1,78 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { DragLayer } from 'react-dnd';
|
||||
import DragPreviewLayer from 'Components/DragPreviewLayer';
|
||||
import { TABLE_COLUMN } from 'Helpers/dragTypes';
|
||||
import dimensions from 'Styles/Variables/dimensions.js';
|
||||
import TableOptionsColumn from './TableOptionsColumn';
|
||||
import styles from './TableOptionsColumnDragPreview.css';
|
||||
|
||||
const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
|
||||
const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth);
|
||||
const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
|
||||
const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
|
||||
|
||||
function collectDragLayer(monitor) {
|
||||
return {
|
||||
item: monitor.getItem(),
|
||||
itemType: monitor.getItemType(),
|
||||
currentOffset: monitor.getSourceClientOffset()
|
||||
};
|
||||
}
|
||||
|
||||
class TableOptionsColumnDragPreview extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
item,
|
||||
itemType,
|
||||
currentOffset
|
||||
} = this.props;
|
||||
|
||||
if (!currentOffset || itemType !== TABLE_COLUMN) {
|
||||
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 - formLabelLargeWidth - formLabelRightMarginWidth - dragHandleWidth;
|
||||
const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
|
||||
|
||||
const style = {
|
||||
position: 'absolute',
|
||||
WebkitTransform: transform,
|
||||
msTransform: transform,
|
||||
transform
|
||||
};
|
||||
|
||||
return (
|
||||
<DragPreviewLayer>
|
||||
<div
|
||||
className={styles.dragPreview}
|
||||
style={style}
|
||||
>
|
||||
<TableOptionsColumn
|
||||
isDragging={false}
|
||||
{...item}
|
||||
/>
|
||||
</div>
|
||||
</DragPreviewLayer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TableOptionsColumnDragPreview.propTypes = {
|
||||
item: PropTypes.object,
|
||||
itemType: PropTypes.string,
|
||||
currentOffset: PropTypes.shape({
|
||||
x: PropTypes.number.isRequired,
|
||||
y: PropTypes.number.isRequired
|
||||
})
|
||||
};
|
||||
|
||||
export default DragLayer(collectDragLayer)(TableOptionsColumnDragPreview);
|
|
@ -1,18 +0,0 @@
|
|||
.columnDragSource {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.columnPlaceholder {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px dotted #aaa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.columnPlaceholderBefore {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.columnPlaceholderAfter {
|
||||
margin-top: 8px;
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'columnDragSource': string;
|
||||
'columnPlaceholder': string;
|
||||
'columnPlaceholderAfter': string;
|
||||
'columnPlaceholderBefore': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
|
@ -1,164 +0,0 @@
|
|||
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 { TABLE_COLUMN } from 'Helpers/dragTypes';
|
||||
import TableOptionsColumn from './TableOptionsColumn';
|
||||
import styles from './TableOptionsColumnDragSource.css';
|
||||
|
||||
const columnDragSource = {
|
||||
beginDrag(column) {
|
||||
return column;
|
||||
},
|
||||
|
||||
endDrag(props, monitor, component) {
|
||||
props.onColumnDragEnd(monitor.getItem(), monitor.didDrop());
|
||||
}
|
||||
};
|
||||
|
||||
const columnDropTarget = {
|
||||
hover(props, monitor, component) {
|
||||
const dragIndex = monitor.getItem().index;
|
||||
const hoverIndex = props.index;
|
||||
|
||||
const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
|
||||
|
||||
if (dragIndex === hoverIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When moving up, only trigger if drag position is above 50% and
|
||||
// when moving down, only trigger if drag position is below 50%.
|
||||
// If we're moving down the hoverIndex needs to be increased
|
||||
// by one so it's ordered properly. Otherwise the hoverIndex will work.
|
||||
|
||||
// Dragging downwards
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dragging upwards
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onColumnDragMove(dragIndex, hoverIndex);
|
||||
}
|
||||
};
|
||||
|
||||
function collectDragSource(connect, monitor) {
|
||||
return {
|
||||
connectDragSource: connect.dragSource(),
|
||||
isDragging: monitor.isDragging()
|
||||
};
|
||||
}
|
||||
|
||||
function collectDropTarget(connect, monitor) {
|
||||
return {
|
||||
connectDropTarget: connect.dropTarget(),
|
||||
isOver: monitor.isOver()
|
||||
};
|
||||
}
|
||||
|
||||
class TableOptionsColumnDragSource extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
name,
|
||||
label,
|
||||
isVisible,
|
||||
isModifiable,
|
||||
index,
|
||||
isDragging,
|
||||
isDraggingUp,
|
||||
isDraggingDown,
|
||||
isOver,
|
||||
connectDragSource,
|
||||
connectDropTarget,
|
||||
onVisibleChange
|
||||
} = this.props;
|
||||
|
||||
const isBefore = !isDragging && isDraggingUp && isOver;
|
||||
const isAfter = !isDragging && isDraggingDown && isOver;
|
||||
|
||||
// if (isDragging && !isOver) {
|
||||
// return null;
|
||||
// }
|
||||
|
||||
return connectDropTarget(
|
||||
<div
|
||||
className={classNames(
|
||||
styles.columnDragSource,
|
||||
isBefore && styles.isDraggingUp,
|
||||
isAfter && styles.isDraggingDown
|
||||
)}
|
||||
>
|
||||
{
|
||||
isBefore &&
|
||||
<div
|
||||
className={classNames(
|
||||
styles.columnPlaceholder,
|
||||
styles.columnPlaceholderBefore
|
||||
)}
|
||||
/>
|
||||
}
|
||||
|
||||
<TableOptionsColumn
|
||||
name={name}
|
||||
label={typeof label === 'function' ? label() : label}
|
||||
isVisible={isVisible}
|
||||
isModifiable={isModifiable}
|
||||
index={index}
|
||||
isDragging={isDragging}
|
||||
isOver={isOver}
|
||||
connectDragSource={connectDragSource}
|
||||
onVisibleChange={onVisibleChange}
|
||||
/>
|
||||
|
||||
{
|
||||
isAfter &&
|
||||
<div
|
||||
className={classNames(
|
||||
styles.columnPlaceholder,
|
||||
styles.columnPlaceholderAfter
|
||||
)}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TableOptionsColumnDragSource.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
|
||||
isVisible: PropTypes.bool.isRequired,
|
||||
isModifiable: PropTypes.bool.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
isDragging: PropTypes.bool,
|
||||
isDraggingUp: PropTypes.bool,
|
||||
isDraggingDown: PropTypes.bool,
|
||||
isOver: PropTypes.bool,
|
||||
connectDragSource: PropTypes.func,
|
||||
connectDropTarget: PropTypes.func,
|
||||
onVisibleChange: PropTypes.func.isRequired,
|
||||
onColumnDragMove: PropTypes.func.isRequired,
|
||||
onColumnDragEnd: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default DropTarget(
|
||||
TABLE_COLUMN,
|
||||
columnDropTarget,
|
||||
collectDropTarget
|
||||
)(DragSource(
|
||||
TABLE_COLUMN,
|
||||
columnDragSource,
|
||||
collectDragSource
|
||||
)(TableOptionsColumnDragSource));
|
|
@ -1,263 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { DndProvider } from 'react-dnd-multi-backend';
|
||||
import HTML5toTouch from 'react-dnd-multi-backend/dist/esm/HTML5toTouch';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormInputHelpText from 'Components/Form/FormInputHelpText';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Button from 'Components/Link/Button';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import TableOptionsColumn from './TableOptionsColumn';
|
||||
import TableOptionsColumnDragPreview from './TableOptionsColumnDragPreview';
|
||||
import TableOptionsColumnDragSource from './TableOptionsColumnDragSource';
|
||||
import styles from './TableOptionsModal.css';
|
||||
|
||||
class TableOptionsModal extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
hasPageSize: !!props.pageSize,
|
||||
pageSize: props.pageSize,
|
||||
pageSizeError: null,
|
||||
dragIndex: null,
|
||||
dropIndex: null
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.pageSize !== this.state.pageSize) {
|
||||
this.setState({ pageSize: this.props.pageSize });
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPageSizeChange = ({ value }) => {
|
||||
let pageSizeError = null;
|
||||
const maxPageSize = this.props.maxPageSize ?? 250;
|
||||
|
||||
if (value < 5) {
|
||||
pageSizeError = translate('TablePageSizeMinimum', { minimumValue: '5' });
|
||||
} else if (value > maxPageSize) {
|
||||
pageSizeError = translate('TablePageSizeMaximum', { maximumValue: `${maxPageSize}` });
|
||||
} else {
|
||||
this.props.onTableOptionChange({ pageSize: value });
|
||||
}
|
||||
|
||||
this.setState({
|
||||
pageSize: value,
|
||||
pageSizeError
|
||||
});
|
||||
};
|
||||
|
||||
onVisibleChange = ({ name, value }) => {
|
||||
const columns = _.cloneDeep(this.props.columns);
|
||||
|
||||
const column = _.find(columns, { name });
|
||||
column.isVisible = value;
|
||||
|
||||
this.props.onTableOptionChange({ columns });
|
||||
};
|
||||
|
||||
onColumnDragMove = (dragIndex, dropIndex) => {
|
||||
if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
|
||||
this.setState({
|
||||
dragIndex,
|
||||
dropIndex
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onColumnDragEnd = ({ id }, didDrop) => {
|
||||
const {
|
||||
dragIndex,
|
||||
dropIndex
|
||||
} = this.state;
|
||||
|
||||
if (didDrop && dropIndex !== null) {
|
||||
const columns = _.cloneDeep(this.props.columns);
|
||||
const items = columns.splice(dragIndex, 1);
|
||||
columns.splice(dropIndex, 0, items[0]);
|
||||
|
||||
this.props.onTableOptionChange({ columns });
|
||||
}
|
||||
|
||||
this.setState({
|
||||
dragIndex: null,
|
||||
dropIndex: null
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isOpen,
|
||||
columns,
|
||||
canModifyColumns,
|
||||
optionsComponent: OptionsComponent,
|
||||
onTableOptionChange,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
hasPageSize,
|
||||
pageSize,
|
||||
pageSizeError,
|
||||
dragIndex,
|
||||
dropIndex
|
||||
} = this.state;
|
||||
|
||||
const isDragging = dropIndex !== null;
|
||||
const isDraggingUp = isDragging && dropIndex < dragIndex;
|
||||
const isDraggingDown = isDragging && dropIndex > dragIndex;
|
||||
|
||||
return (
|
||||
<DndProvider options={HTML5toTouch}>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
{
|
||||
isOpen ?
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('TableOptions')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form>
|
||||
{
|
||||
hasPageSize ?
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('TablePageSize')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="pageSize"
|
||||
value={pageSize || 0}
|
||||
helpText={translate('TablePageSizeHelpText')}
|
||||
errors={pageSizeError ? [{ message: pageSizeError }] : undefined}
|
||||
onChange={this.onPageSizeChange}
|
||||
/>
|
||||
</FormGroup> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
OptionsComponent ?
|
||||
<OptionsComponent
|
||||
onTableOptionChange={onTableOptionChange}
|
||||
/> : null
|
||||
}
|
||||
|
||||
{
|
||||
canModifyColumns ?
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('TableColumns')}</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormInputHelpText
|
||||
text={translate('TableColumnsHelpText')}
|
||||
/>
|
||||
|
||||
<div className={styles.columns}>
|
||||
{
|
||||
columns.map((column, index) => {
|
||||
const {
|
||||
name,
|
||||
label,
|
||||
columnLabel,
|
||||
isVisible,
|
||||
isModifiable
|
||||
} = column;
|
||||
|
||||
if (isModifiable !== false) {
|
||||
return (
|
||||
<TableOptionsColumnDragSource
|
||||
key={name}
|
||||
name={name}
|
||||
label={columnLabel || label}
|
||||
isVisible={isVisible}
|
||||
isModifiable={true}
|
||||
index={index}
|
||||
isDragging={isDragging}
|
||||
isDraggingUp={isDraggingUp}
|
||||
isDraggingDown={isDraggingDown}
|
||||
onVisibleChange={this.onVisibleChange}
|
||||
onColumnDragMove={this.onColumnDragMove}
|
||||
onColumnDragEnd={this.onColumnDragEnd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableOptionsColumn
|
||||
key={name}
|
||||
name={name}
|
||||
label={columnLabel || label}
|
||||
isVisible={isVisible}
|
||||
index={index}
|
||||
isModifiable={false}
|
||||
onVisibleChange={this.onVisibleChange}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
<TableOptionsColumnDragPreview />
|
||||
</div>
|
||||
</div>
|
||||
</FormGroup> :
|
||||
null
|
||||
}
|
||||
</Form>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Close')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent> :
|
||||
null
|
||||
}
|
||||
</Modal>
|
||||
</DndProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TableOptionsModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
pageSize: PropTypes.number,
|
||||
maxPageSize: PropTypes.number,
|
||||
canModifyColumns: PropTypes.bool.isRequired,
|
||||
optionsComponent: PropTypes.elementType,
|
||||
onTableOptionChange: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
TableOptionsModal.defaultProps = {
|
||||
canModifyColumns: true
|
||||
};
|
||||
|
||||
export default TableOptionsModal;
|
216
frontend/src/Components/Table/TableOptions/TableOptionsModal.tsx
Normal file
216
frontend/src/Components/Table/TableOptions/TableOptionsModal.tsx
Normal file
|
@ -0,0 +1,216 @@
|
|||
import _ from 'lodash';
|
||||
import { HTML5toTouch } from 'rdndmb-html5-to-touch';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { DndProvider } from 'react-dnd-multi-backend';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormInputHelpText from 'Components/Form/FormInputHelpText';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Button from 'Components/Link/Button';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import { CheckInputChanged, InputChanged } from 'typings/inputs';
|
||||
import { TableOptionsChangePayload } from 'typings/Table';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import Column from '../Column';
|
||||
import TableOptionsColumn from './TableOptionsColumn';
|
||||
import styles from './TableOptionsModal.css';
|
||||
|
||||
interface TableOptionsModalProps {
|
||||
isOpen: boolean;
|
||||
columns: Column[];
|
||||
pageSize?: number;
|
||||
maxPageSize?: number;
|
||||
canModifyColumns: boolean;
|
||||
optionsComponent?: React.ElementType;
|
||||
onTableOptionChange: (payload: TableOptionsChangePayload) => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function TableOptionsModal({
|
||||
isOpen,
|
||||
columns,
|
||||
canModifyColumns = true,
|
||||
optionsComponent: OptionsComponent,
|
||||
pageSize: propsPageSize,
|
||||
maxPageSize = 250,
|
||||
onTableOptionChange,
|
||||
onModalClose,
|
||||
}: TableOptionsModalProps) {
|
||||
const [pageSize, setPageSize] = useState(propsPageSize);
|
||||
const [pageSizeError, setPageSizeError] = useState<string | null>(null);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
|
||||
const hasPageSize = !!propsPageSize;
|
||||
const isDragging = dropIndex !== null;
|
||||
const isDraggingUp =
|
||||
isDragging &&
|
||||
dropIndex != null &&
|
||||
dragIndex != null &&
|
||||
dropIndex < dragIndex;
|
||||
const isDraggingDown =
|
||||
isDragging &&
|
||||
dropIndex != null &&
|
||||
dragIndex != null &&
|
||||
dropIndex > dragIndex;
|
||||
|
||||
const handlePageSizeChange = useCallback(
|
||||
({ value }: InputChanged<number>) => {
|
||||
let error: string | null = null;
|
||||
|
||||
if (value < 5) {
|
||||
error = translate('TablePageSizeMinimum', {
|
||||
minimumValue: '5',
|
||||
});
|
||||
} else if (value > maxPageSize) {
|
||||
error = translate('TablePageSizeMaximum', {
|
||||
maximumValue: `${maxPageSize}`,
|
||||
});
|
||||
} else {
|
||||
onTableOptionChange({ pageSize: value });
|
||||
}
|
||||
|
||||
setPageSize(value);
|
||||
setPageSizeError(error);
|
||||
},
|
||||
[maxPageSize, onTableOptionChange]
|
||||
);
|
||||
|
||||
const handleVisibleChange = useCallback(
|
||||
({ name, value }: CheckInputChanged) => {
|
||||
const newColumns = columns.map((column) => {
|
||||
if (column.name === name) {
|
||||
return {
|
||||
...column,
|
||||
isVisible: value,
|
||||
};
|
||||
}
|
||||
|
||||
return column;
|
||||
});
|
||||
|
||||
onTableOptionChange({ columns: newColumns });
|
||||
},
|
||||
[columns, onTableOptionChange]
|
||||
);
|
||||
|
||||
const handleColumnDragMove = useCallback(
|
||||
(newDragIndex: number, newDropIndex: number) => {
|
||||
setDropIndex(newDropIndex);
|
||||
setDragIndex(newDragIndex);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleColumnDragEnd = useCallback(
|
||||
(didDrop: boolean) => {
|
||||
if (didDrop && dragIndex && dropIndex !== null) {
|
||||
const newColumns = [...columns];
|
||||
const items = newColumns.splice(dragIndex, 1);
|
||||
newColumns.splice(dropIndex, 0, items[0]);
|
||||
|
||||
onTableOptionChange({ columns: newColumns });
|
||||
}
|
||||
|
||||
setDragIndex(null);
|
||||
setDropIndex(null);
|
||||
},
|
||||
[dragIndex, dropIndex, columns, onTableOptionChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPageSize(propsPageSize);
|
||||
}, [propsPageSize]);
|
||||
|
||||
return (
|
||||
<DndProvider options={HTML5toTouch}>
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
{isOpen ? (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('TableOptions')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form>
|
||||
{hasPageSize ? (
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('TablePageSize')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="pageSize"
|
||||
value={pageSize || 0}
|
||||
helpText={translate('TablePageSizeHelpText')}
|
||||
errors={
|
||||
pageSizeError ? [{ message: pageSizeError }] : undefined
|
||||
}
|
||||
onChange={handlePageSizeChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
) : null}
|
||||
|
||||
{OptionsComponent ? (
|
||||
<OptionsComponent onTableOptionChange={onTableOptionChange} />
|
||||
) : null}
|
||||
|
||||
{canModifyColumns ? (
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('TableColumns')}</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormInputHelpText
|
||||
text={translate('TableColumnsHelpText')}
|
||||
/>
|
||||
|
||||
<div className={styles.columns}>
|
||||
{columns.map((column, index) => {
|
||||
const {
|
||||
name,
|
||||
label,
|
||||
columnLabel,
|
||||
isVisible,
|
||||
isModifiable = true,
|
||||
} = column;
|
||||
|
||||
return (
|
||||
<TableOptionsColumn
|
||||
key={name}
|
||||
name={name}
|
||||
label={columnLabel ?? label}
|
||||
isVisible={isVisible}
|
||||
isModifiable={isModifiable}
|
||||
index={index}
|
||||
isDraggingUp={isDraggingUp}
|
||||
isDraggingDown={isDraggingDown}
|
||||
onVisibleChange={handleVisibleChange}
|
||||
onColumnDragMove={handleColumnDragMove}
|
||||
onColumnDragEnd={handleColumnDragEnd}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
) : null}
|
||||
</Form>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
) : null}
|
||||
</Modal>
|
||||
</DndProvider>
|
||||
);
|
||||
}
|
||||
|
||||
TableOptionsModal.defaultProps = {
|
||||
canModifyColumns: true,
|
||||
};
|
||||
|
||||
export default TableOptionsModal;
|
|
@ -11,7 +11,7 @@ import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
|||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import Series from 'Series/Series';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
|
||||
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileName';
|
||||
import {
|
||||
deleteEpisodeFile,
|
||||
fetchEpisodeFile,
|
||||
|
|
7
frontend/src/Helpers/DragType.ts
Normal file
7
frontend/src/Helpers/DragType.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
enum DragType {
|
||||
DelayProfile = 'delayProfile',
|
||||
QualityProfileItem = 'qualityProfileItem',
|
||||
TableColumn = 'tableColumn',
|
||||
}
|
||||
|
||||
export default DragType;
|
|
@ -1,3 +0,0 @@
|
|||
export const QUALITY_PROFILE_ITEM = 'qualityProfileItem';
|
||||
export const DELAY_PROFILE = 'delayProfile';
|
||||
export const TABLE_COLUMN = 'tableColumn';
|
|
@ -20,6 +20,9 @@ interface Quality {
|
|||
name: string;
|
||||
resolution: number;
|
||||
source: QualitySource;
|
||||
minSize: number | null;
|
||||
maxSize: number | null;
|
||||
preferredSize: number | null;
|
||||
}
|
||||
|
||||
export interface QualityModel {
|
||||
|
|
|
@ -30,7 +30,7 @@ import MonitoringOptionsModal from 'Series/MonitoringOptions/MonitoringOptionsMo
|
|||
import SeriesGenres from 'Series/SeriesGenres';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import { getSeriesStatusDetails } from 'Series/SeriesStatus';
|
||||
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
|
||||
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileName';
|
||||
import fonts from 'Styles/Variables/fonts';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
|
|
@ -38,3 +38,17 @@
|
|||
width: $dragHandleWidth;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
border-bottom: 1px dotted #aaa;
|
||||
}
|
||||
|
||||
.placeholderBefore {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.placeholderAfter {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,9 @@ interface CssExports {
|
|||
'dragIcon': string;
|
||||
'editButton': string;
|
||||
'isDragging': string;
|
||||
'placeholder': string;
|
||||
'placeholderAfter': string;
|
||||
'placeholderBefore': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
|
|
@ -1,173 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import 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 EditDelayProfileModalConnector from './EditDelayProfileModalConnector';
|
||||
import styles from './DelayProfile.css';
|
||||
|
||||
function getDelay(enabled, delay) {
|
||||
if (!enabled) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (!delay) {
|
||||
return translate('NoDelay');
|
||||
}
|
||||
|
||||
if (delay === 1) {
|
||||
return translate('OneMinute');
|
||||
}
|
||||
|
||||
// TODO: use better units of time than just minutes
|
||||
return translate('DelayMinutes', { delay });
|
||||
}
|
||||
|
||||
class DelayProfile extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isEditDelayProfileModalOpen: false,
|
||||
isDeleteDelayProfileModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onEditDelayProfilePress = () => {
|
||||
this.setState({ isEditDelayProfileModalOpen: true });
|
||||
};
|
||||
|
||||
onEditDelayProfileModalClose = () => {
|
||||
this.setState({ isEditDelayProfileModalOpen: false });
|
||||
};
|
||||
|
||||
onDeleteDelayProfilePress = () => {
|
||||
this.setState({
|
||||
isEditDelayProfileModalOpen: false,
|
||||
isDeleteDelayProfileModalOpen: true
|
||||
});
|
||||
};
|
||||
|
||||
onDeleteDelayProfileModalClose = () => {
|
||||
this.setState({ isDeleteDelayProfileModalOpen: false });
|
||||
};
|
||||
|
||||
onConfirmDeleteDelayProfile = () => {
|
||||
this.props.onConfirmDeleteDelayProfile(this.props.id);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
enableUsenet,
|
||||
enableTorrent,
|
||||
preferredProtocol,
|
||||
usenetDelay,
|
||||
torrentDelay,
|
||||
tags,
|
||||
tagList,
|
||||
isDragging,
|
||||
connectDragSource
|
||||
} = this.props;
|
||||
|
||||
let preferred = titleCase(translate('PreferProtocol', { preferredProtocol }));
|
||||
|
||||
if (!enableUsenet) {
|
||||
preferred = translate('OnlyTorrent');
|
||||
} else if (!enableTorrent) {
|
||||
preferred = translate('OnlyUsenet');
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.delayProfile,
|
||||
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>
|
||||
|
||||
<TagList
|
||||
tags={tags}
|
||||
tagList={tagList}
|
||||
/>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link
|
||||
className={id === 1 ? styles.editButton : undefined}
|
||||
onPress={this.onEditDelayProfilePress}
|
||||
>
|
||||
<Icon name={icons.EDIT} />
|
||||
</Link>
|
||||
|
||||
{
|
||||
id !== 1 &&
|
||||
connectDragSource(
|
||||
<div className={styles.dragHandle}>
|
||||
<Icon
|
||||
className={styles.dragIcon}
|
||||
name={icons.REORDER}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<EditDelayProfileModalConnector
|
||||
id={id}
|
||||
isOpen={this.state.isEditDelayProfileModalOpen}
|
||||
onModalClose={this.onEditDelayProfileModalClose}
|
||||
onDeleteDelayProfilePress={this.onDeleteDelayProfilePress}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={this.state.isDeleteDelayProfileModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteDelayProfile')}
|
||||
message={translate('DeleteDelayProfileMessageText')}
|
||||
confirmLabel={translate('Delete')}
|
||||
onConfirm={this.onConfirmDeleteDelayProfile}
|
||||
onCancel={this.onDeleteDelayProfileModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isDragging: PropTypes.bool.isRequired,
|
||||
connectDragSource: PropTypes.func,
|
||||
onConfirmDeleteDelayProfile: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
DelayProfile.defaultProps = {
|
||||
// The drag preview will not connect the drag handle.
|
||||
connectDragSource: (node) => node
|
||||
};
|
||||
|
||||
export default DelayProfile;
|
240
frontend/src/Settings/Profiles/Delay/DelayProfile.tsx
Normal file
240
frontend/src/Settings/Profiles/Delay/DelayProfile.tsx
Normal file
|
@ -0,0 +1,240 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { DragSourceMonitor, useDrag, useDrop, XYCoord } from 'react-dnd';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import TagList from 'Components/TagList';
|
||||
import DragType from 'Helpers/DragType';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import { deleteDelayProfile } from 'Store/Actions/settingsActions';
|
||||
import titleCase from 'Utilities/String/titleCase';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditDelayProfileModal from './EditDelayProfileModal';
|
||||
import styles from './DelayProfile.css';
|
||||
|
||||
function getDelay(enabled: boolean, delay: number) {
|
||||
if (!enabled) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (!delay) {
|
||||
return translate('NoDelay');
|
||||
}
|
||||
|
||||
if (delay === 1) {
|
||||
return translate('OneMinute');
|
||||
}
|
||||
|
||||
// TODO: use better units of time than just minutes
|
||||
return translate('DelayMinutes', { delay });
|
||||
}
|
||||
|
||||
interface DragItem {
|
||||
id: number;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface DelayProfileProps {
|
||||
id: number;
|
||||
enableUsenet: boolean;
|
||||
enableTorrent: boolean;
|
||||
preferredProtocol: string;
|
||||
usenetDelay: number;
|
||||
torrentDelay: number;
|
||||
order: number;
|
||||
tags: number[];
|
||||
tagList: Tag[];
|
||||
isDraggingDown: boolean;
|
||||
isDraggingUp: boolean;
|
||||
onDelayProfileDragEnd: (id: number, didDrop: boolean) => void;
|
||||
onDelayProfileDragMove: (dragIndex: number, hoverIndex: number) => void;
|
||||
}
|
||||
|
||||
function DelayProfile({
|
||||
id,
|
||||
enableUsenet,
|
||||
enableTorrent,
|
||||
preferredProtocol,
|
||||
usenetDelay,
|
||||
torrentDelay,
|
||||
order,
|
||||
tags,
|
||||
tagList,
|
||||
isDraggingDown,
|
||||
isDraggingUp,
|
||||
onDelayProfileDragEnd,
|
||||
onDelayProfileDragMove,
|
||||
}: DelayProfileProps) {
|
||||
const dispatch = useDispatch();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isEditDelayProfileModalOpen, setIsEditDelayProfileModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const [isDeleteDelayProfileModalOpen, setIsDeleteDelayProfileModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const preferred = useMemo(() => {
|
||||
if (!enableUsenet) {
|
||||
return translate('OnlyTorrent');
|
||||
} else if (!enableTorrent) {
|
||||
return translate('OnlyUsenet');
|
||||
}
|
||||
|
||||
return titleCase(translate('PreferProtocol', { preferredProtocol }));
|
||||
}, [preferredProtocol, enableUsenet, enableTorrent]);
|
||||
|
||||
const handleEditDelayProfilePress = useCallback(() => {
|
||||
setIsEditDelayProfileModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditDelayProfileModalClose = useCallback(() => {
|
||||
setIsEditDelayProfileModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleDeleteDelayProfilePress = useCallback(() => {
|
||||
setIsEditDelayProfileModalOpen(false);
|
||||
setIsDeleteDelayProfileModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteDelayProfileModalClose = useCallback(() => {
|
||||
setIsDeleteDelayProfileModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDeleteDelayProfile = useCallback(() => {
|
||||
dispatch(deleteDelayProfile(id));
|
||||
}, [id, dispatch]);
|
||||
|
||||
const [{ isOver }, dropRef] = useDrop<DragItem, void, { isOver: boolean }>({
|
||||
accept: DragType.DelayProfile,
|
||||
collect(monitor) {
|
||||
return {
|
||||
isOver: monitor.isOver(),
|
||||
};
|
||||
},
|
||||
hover(item: DragItem, monitor) {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
const dragIndex = item.order;
|
||||
const hoverIndex = order;
|
||||
|
||||
// Don't replace items with themselves
|
||||
if (dragIndex === hoverIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const hoverMiddleY =
|
||||
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
|
||||
|
||||
// When moving up, only trigger if drag position is above 50% and
|
||||
// when moving down, only trigger if drag position is below 50%.
|
||||
// If we're moving down the hoverIndex needs to be increased
|
||||
// by one so it's ordered properly. Otherwise the hoverIndex will work.
|
||||
|
||||
if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
onDelayProfileDragMove(dragIndex, hoverIndex + 1);
|
||||
} else if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
onDelayProfileDragMove(dragIndex, hoverIndex);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [{ isDragging }, dragRef, previewRef] = useDrag<
|
||||
DragItem,
|
||||
unknown,
|
||||
{ isDragging: boolean }
|
||||
>({
|
||||
type: DragType.DelayProfile,
|
||||
item: () => {
|
||||
return {
|
||||
id,
|
||||
order,
|
||||
};
|
||||
},
|
||||
collect: (monitor: DragSourceMonitor<unknown, unknown>) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
end: (item: DragItem, monitor) => {
|
||||
onDelayProfileDragEnd(item.id, monitor.didDrop());
|
||||
},
|
||||
});
|
||||
|
||||
dropRef(previewRef(ref));
|
||||
|
||||
const isBefore = !isDragging && isDraggingUp && isOver;
|
||||
const isAfter = !isDragging && isDraggingDown && isOver;
|
||||
|
||||
return (
|
||||
<div ref={id === 1 ? undefined : ref}>
|
||||
{isBefore ? (
|
||||
<div
|
||||
className={classNames(styles.placeholder, styles.placeholderBefore)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
styles.delayProfile,
|
||||
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>
|
||||
|
||||
<TagList tags={tags} tagList={tagList} />
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link
|
||||
className={id === 1 ? styles.editButton : undefined}
|
||||
onPress={handleEditDelayProfilePress}
|
||||
>
|
||||
<Icon name={icons.EDIT} />
|
||||
</Link>
|
||||
|
||||
{id === 1 ? null : (
|
||||
<div ref={dragRef} className={styles.dragHandle}>
|
||||
<Icon className={styles.dragIcon} name={icons.REORDER} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAfter ? (
|
||||
<div
|
||||
className={classNames(styles.placeholder, styles.placeholderAfter)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<EditDelayProfileModal
|
||||
id={id}
|
||||
isOpen={isEditDelayProfileModalOpen}
|
||||
onModalClose={handleEditDelayProfileModalClose}
|
||||
onDeleteDelayProfilePress={handleDeleteDelayProfilePress}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteDelayProfileModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteDelayProfile')}
|
||||
message={translate('DeleteDelayProfileMessageText')}
|
||||
confirmLabel={translate('Delete')}
|
||||
onConfirm={handleConfirmDeleteDelayProfile}
|
||||
onCancel={handleDeleteDelayProfileModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DelayProfile;
|
|
@ -1,3 +0,0 @@
|
|||
.dragPreview {
|
||||
opacity: 0.75;
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'dragPreview': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
|
@ -1,78 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { DragLayer } from 'react-dnd';
|
||||
import DragPreviewLayer from 'Components/DragPreviewLayer';
|
||||
import { DELAY_PROFILE } from 'Helpers/dragTypes';
|
||||
import dimensions from 'Styles/Variables/dimensions.js';
|
||||
import DelayProfile from './DelayProfile';
|
||||
import styles from './DelayProfileDragPreview.css';
|
||||
|
||||
const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
|
||||
|
||||
function collectDragLayer(monitor) {
|
||||
return {
|
||||
item: monitor.getItem(),
|
||||
itemType: monitor.getItemType(),
|
||||
currentOffset: monitor.getSourceClientOffset()
|
||||
};
|
||||
}
|
||||
|
||||
class DelayProfileDragPreview extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
width,
|
||||
item,
|
||||
itemType,
|
||||
currentOffset
|
||||
} = this.props;
|
||||
|
||||
if (!currentOffset || itemType !== DELAY_PROFILE) {
|
||||
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 = width - dragHandleWidth;
|
||||
const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
|
||||
|
||||
const style = {
|
||||
width,
|
||||
position: 'absolute',
|
||||
WebkitTransform: transform,
|
||||
msTransform: transform,
|
||||
transform
|
||||
};
|
||||
|
||||
return (
|
||||
<DragPreviewLayer>
|
||||
<div
|
||||
className={styles.dragPreview}
|
||||
style={style}
|
||||
>
|
||||
<DelayProfile
|
||||
isDragging={false}
|
||||
{...item}
|
||||
/>
|
||||
</div>
|
||||
</DragPreviewLayer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DelayProfileDragPreview.propTypes = {
|
||||
width: PropTypes.number.isRequired,
|
||||
item: PropTypes.object,
|
||||
itemType: PropTypes.string,
|
||||
currentOffset: PropTypes.shape({
|
||||
x: PropTypes.number.isRequired,
|
||||
y: PropTypes.number.isRequired
|
||||
})
|
||||
};
|
||||
|
||||
export default DragLayer(collectDragLayer)(DelayProfileDragPreview);
|
|
@ -1,17 +0,0 @@
|
|||
.delayProfileDragSource {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.delayProfilePlaceholder {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
border-bottom: 1px dotted #aaa;
|
||||
}
|
||||
|
||||
.delayProfilePlaceholderBefore {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.delayProfilePlaceholderAfter {
|
||||
margin-top: 8px;
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'delayProfileDragSource': string;
|
||||
'delayProfilePlaceholder': string;
|
||||
'delayProfilePlaceholderAfter': string;
|
||||
'delayProfilePlaceholderBefore': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
|
@ -1,148 +0,0 @@
|
|||
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 { DELAY_PROFILE } from 'Helpers/dragTypes';
|
||||
import DelayProfile from './DelayProfile';
|
||||
import styles from './DelayProfileDragSource.css';
|
||||
|
||||
const delayProfileDragSource = {
|
||||
beginDrag(item) {
|
||||
return item;
|
||||
},
|
||||
|
||||
endDrag(props, monitor, component) {
|
||||
props.onDelayProfileDragEnd(monitor.getItem(), monitor.didDrop());
|
||||
}
|
||||
};
|
||||
|
||||
const delayProfileDropTarget = {
|
||||
hover(props, monitor, component) {
|
||||
const dragIndex = monitor.getItem().order;
|
||||
const hoverIndex = props.order;
|
||||
|
||||
const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
|
||||
|
||||
if (dragIndex === hoverIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When moving up, only trigger if drag position is above 50% and
|
||||
// when moving down, only trigger if drag position is below 50%.
|
||||
// If we're moving down the hoverIndex needs to be increased
|
||||
// by one so it's ordered properly. Otherwise the hoverIndex will work.
|
||||
|
||||
if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
props.onDelayProfileDragMove(dragIndex, hoverIndex + 1);
|
||||
} else if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
props.onDelayProfileDragMove(dragIndex, hoverIndex);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function collectDragSource(connect, monitor) {
|
||||
return {
|
||||
connectDragSource: connect.dragSource(),
|
||||
isDragging: monitor.isDragging()
|
||||
};
|
||||
}
|
||||
|
||||
function collectDropTarget(connect, monitor) {
|
||||
return {
|
||||
connectDropTarget: connect.dropTarget(),
|
||||
isOver: monitor.isOver()
|
||||
};
|
||||
}
|
||||
|
||||
class DelayProfileDragSource extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
order,
|
||||
isDragging,
|
||||
isDraggingUp,
|
||||
isDraggingDown,
|
||||
isOver,
|
||||
connectDragSource,
|
||||
connectDropTarget,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const isBefore = !isDragging && isDraggingUp && isOver;
|
||||
const isAfter = !isDragging && isDraggingDown && isOver;
|
||||
|
||||
// if (isDragging && !isOver) {
|
||||
// return null;
|
||||
// }
|
||||
|
||||
return connectDropTarget(
|
||||
<div
|
||||
className={classNames(
|
||||
styles.delayProfileDragSource,
|
||||
isBefore && styles.isDraggingUp,
|
||||
isAfter && styles.isDraggingDown
|
||||
)}
|
||||
>
|
||||
{
|
||||
isBefore &&
|
||||
<div
|
||||
className={classNames(
|
||||
styles.delayProfilePlaceholder,
|
||||
styles.delayProfilePlaceholderBefore
|
||||
)}
|
||||
/>
|
||||
}
|
||||
|
||||
<DelayProfile
|
||||
id={id}
|
||||
order={order}
|
||||
isDragging={isDragging}
|
||||
isOver={isOver}
|
||||
{...otherProps}
|
||||
connectDragSource={connectDragSource}
|
||||
/>
|
||||
|
||||
{
|
||||
isAfter &&
|
||||
<div
|
||||
className={classNames(
|
||||
styles.delayProfilePlaceholder,
|
||||
styles.delayProfilePlaceholderAfter
|
||||
)}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DelayProfileDragSource.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
order: PropTypes.number.isRequired,
|
||||
isDragging: PropTypes.bool,
|
||||
isDraggingUp: PropTypes.bool,
|
||||
isDraggingDown: PropTypes.bool,
|
||||
isOver: PropTypes.bool,
|
||||
connectDragSource: PropTypes.func,
|
||||
connectDropTarget: PropTypes.func,
|
||||
onDelayProfileDragMove: PropTypes.func.isRequired,
|
||||
onDelayProfileDragEnd: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default DropTarget(
|
||||
DELAY_PROFILE,
|
||||
delayProfileDropTarget,
|
||||
collectDropTarget
|
||||
)(DragSource(
|
||||
DELAY_PROFILE,
|
||||
delayProfileDragSource,
|
||||
collectDragSource
|
||||
)(DelayProfileDragSource));
|
|
@ -1,169 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import Measure from 'Components/Measure';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import { icons, scrollDirections } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import DelayProfile from './DelayProfile';
|
||||
import DelayProfileDragPreview from './DelayProfileDragPreview';
|
||||
import DelayProfileDragSource from './DelayProfileDragSource';
|
||||
import EditDelayProfileModalConnector from './EditDelayProfileModalConnector';
|
||||
import styles from './DelayProfiles.css';
|
||||
|
||||
class DelayProfiles extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isAddDelayProfileModalOpen: false,
|
||||
width: 0
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onAddDelayProfilePress = () => {
|
||||
this.setState({ isAddDelayProfileModalOpen: true });
|
||||
};
|
||||
|
||||
onModalClose = () => {
|
||||
this.setState({ isAddDelayProfileModalOpen: false });
|
||||
};
|
||||
|
||||
onMeasure = ({ width }) => {
|
||||
this.setState({ width });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
defaultProfile,
|
||||
items,
|
||||
tagList,
|
||||
dragIndex,
|
||||
dropIndex,
|
||||
onConfirmDeleteDelayProfile,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
isAddDelayProfileModalOpen,
|
||||
width
|
||||
} = this.state;
|
||||
|
||||
const isDragging = dropIndex !== null;
|
||||
const isDraggingUp = isDragging && dropIndex < dragIndex;
|
||||
const isDraggingDown = isDragging && dropIndex > dragIndex;
|
||||
|
||||
return (
|
||||
<Measure onMeasure={this.onMeasure}>
|
||||
<FieldSet legend={translate('DelayProfiles')}>
|
||||
<PageSectionContent
|
||||
errorMessage={translate('DelayProfilesLoadError')}
|
||||
{...otherProps}
|
||||
>
|
||||
<Scroller
|
||||
className={styles.horizontalScroll}
|
||||
scrollDirection={
|
||||
scrollDirections.HORIZONTAL
|
||||
}
|
||||
autoFocus={false}
|
||||
>
|
||||
<div>
|
||||
<div className={styles.delayProfilesHeader}>
|
||||
<div className={styles.column}>
|
||||
{translate('PreferredProtocol')}
|
||||
</div>
|
||||
<div className={styles.column}>
|
||||
{translate('UsenetDelay')}
|
||||
</div>
|
||||
<div className={styles.column}>
|
||||
{translate('TorrentDelay')}
|
||||
</div>
|
||||
<div className={styles.tags}>
|
||||
{translate('Tags')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.delayProfiles}>
|
||||
{
|
||||
items.map((item, index) => {
|
||||
return (
|
||||
<DelayProfileDragSource
|
||||
key={item.id}
|
||||
tagList={tagList}
|
||||
{...item}
|
||||
{...otherProps}
|
||||
index={index}
|
||||
isDragging={isDragging}
|
||||
isDraggingUp={isDraggingUp}
|
||||
isDraggingDown={isDraggingDown}
|
||||
onConfirmDeleteDelayProfile={onConfirmDeleteDelayProfile}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
<DelayProfileDragPreview
|
||||
width={width}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
defaultProfile ?
|
||||
<div>
|
||||
<DelayProfile
|
||||
tagList={tagList}
|
||||
isDragging={false}
|
||||
onConfirmDeleteDelayProfile={onConfirmDeleteDelayProfile}
|
||||
{...defaultProfile}
|
||||
/>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</Scroller>
|
||||
|
||||
<div className={styles.addDelayProfile}>
|
||||
<Link
|
||||
className={styles.addButton}
|
||||
onPress={this.onAddDelayProfilePress}
|
||||
>
|
||||
<Icon name={icons.ADD} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<EditDelayProfileModalConnector
|
||||
isOpen={isAddDelayProfileModalOpen}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
</PageSectionContent>
|
||||
</FieldSet>
|
||||
</Measure>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DelayProfiles.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
defaultProfile: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
dragIndex: PropTypes.number,
|
||||
dropIndex: PropTypes.number,
|
||||
onConfirmDeleteDelayProfile: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default DelayProfiles;
|
186
frontend/src/Settings/Profiles/Delay/DelayProfiles.tsx
Normal file
186
frontend/src/Settings/Profiles/Delay/DelayProfiles.tsx
Normal file
|
@ -0,0 +1,186 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import { icons, scrollDirections } from 'Helpers/Props';
|
||||
import {
|
||||
fetchDelayProfiles,
|
||||
reorderDelayProfile,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import DelayProfileModel from 'typings/DelayProfile';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import DelayProfile from './DelayProfile';
|
||||
import EditDelayProfileModal from './EditDelayProfileModal';
|
||||
import styles from './DelayProfiles.css';
|
||||
|
||||
function createDisplayProfilesSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.delayProfiles,
|
||||
(delayProfiles) => {
|
||||
const { defaultProfile, items } = delayProfiles.items.reduce<{
|
||||
defaultProfile: null | DelayProfileModel;
|
||||
items: DelayProfileModel[];
|
||||
}>(
|
||||
(acc, item) => {
|
||||
if (item.id === 1) {
|
||||
acc.defaultProfile = item;
|
||||
} else {
|
||||
acc.items.push(item);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
defaultProfile: null,
|
||||
items: [],
|
||||
}
|
||||
);
|
||||
|
||||
items.sort((a, b) => a.order - b.order);
|
||||
|
||||
return {
|
||||
defaultProfile,
|
||||
...delayProfiles,
|
||||
items,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function DelayProfiles() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { error, isFetching, isPopulated, items, defaultProfile } = useSelector(
|
||||
createDisplayProfilesSelector()
|
||||
);
|
||||
|
||||
const tagList = useSelector(createTagsSelector());
|
||||
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
const [isAddDelayProfileModalOpen, setIsAddDelayProfileModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const isDragging = dropIndex !== null;
|
||||
const isDraggingUp =
|
||||
isDragging &&
|
||||
dropIndex != null &&
|
||||
dragIndex != null &&
|
||||
dropIndex < dragIndex;
|
||||
const isDraggingDown =
|
||||
isDragging &&
|
||||
dropIndex != null &&
|
||||
dragIndex != null &&
|
||||
dropIndex > dragIndex;
|
||||
|
||||
const handleAddDelayProfilePress = useCallback(() => {
|
||||
setIsAddDelayProfileModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddDelayProfileModalClose = useCallback(() => {
|
||||
setIsAddDelayProfileModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleDelayProfileDragMove = useCallback(
|
||||
(newDragIndex: number, newDropIndex: number) => {
|
||||
setDragIndex(newDragIndex);
|
||||
setDropIndex(newDropIndex);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDelayProfileDragEnd = useCallback(
|
||||
(id: number, didDrop: boolean) => {
|
||||
if (didDrop && dropIndex !== null) {
|
||||
dispatch(reorderDelayProfile({ id, moveIndex: dropIndex - 1 }));
|
||||
}
|
||||
|
||||
setDragIndex(null);
|
||||
setDropIndex(null);
|
||||
},
|
||||
[dropIndex, dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchDelayProfiles());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<FieldSet legend={translate('DelayProfiles')}>
|
||||
<PageSectionContent
|
||||
errorMessage={translate('DelayProfilesLoadError')}
|
||||
error={error}
|
||||
isFetching={isFetching}
|
||||
isPopulated={isPopulated}
|
||||
>
|
||||
<Scroller
|
||||
className={styles.horizontalScroll}
|
||||
scrollDirection={scrollDirections.HORIZONTAL}
|
||||
autoFocus={false}
|
||||
>
|
||||
<div>
|
||||
<div className={styles.delayProfilesHeader}>
|
||||
<div className={styles.column}>
|
||||
{translate('PreferredProtocol')}
|
||||
</div>
|
||||
<div className={styles.column}>{translate('UsenetDelay')}</div>
|
||||
<div className={styles.column}>{translate('TorrentDelay')}</div>
|
||||
<div className={styles.tags}>{translate('Tags')}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.delayProfiles}>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<DelayProfile
|
||||
key={item.id}
|
||||
{...item}
|
||||
tagList={tagList}
|
||||
isDraggingUp={isDraggingUp}
|
||||
isDraggingDown={isDraggingDown}
|
||||
onDelayProfileDragEnd={handleDelayProfileDragEnd}
|
||||
onDelayProfileDragMove={handleDelayProfileDragMove}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{defaultProfile ? (
|
||||
<div>
|
||||
<DelayProfile
|
||||
{...defaultProfile}
|
||||
tagList={tagList}
|
||||
isDraggingDown={false}
|
||||
isDraggingUp={false}
|
||||
onDelayProfileDragEnd={handleDelayProfileDragEnd}
|
||||
onDelayProfileDragMove={handleDelayProfileDragMove}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Scroller>
|
||||
|
||||
<div className={styles.addDelayProfile}>
|
||||
<Link
|
||||
className={styles.addButton}
|
||||
onPress={handleAddDelayProfilePress}
|
||||
>
|
||||
<Icon name={icons.ADD} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<EditDelayProfileModal
|
||||
isOpen={isAddDelayProfileModalOpen}
|
||||
onModalClose={handleAddDelayProfileModalClose}
|
||||
/>
|
||||
</PageSectionContent>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export default DelayProfiles;
|
|
@ -1,105 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { deleteDelayProfile, fetchDelayProfiles, reorderDelayProfile } from 'Store/Actions/settingsActions';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import DelayProfiles from './DelayProfiles';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.delayProfiles,
|
||||
createTagsSelector(),
|
||||
(delayProfiles, tagList) => {
|
||||
const defaultProfile = _.find(delayProfiles.items, { id: 1 });
|
||||
const items = _.sortBy(_.reject(delayProfiles.items, { id: 1 }), ['order']);
|
||||
|
||||
return {
|
||||
defaultProfile,
|
||||
...delayProfiles,
|
||||
items,
|
||||
tagList
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchDelayProfiles,
|
||||
deleteDelayProfile,
|
||||
reorderDelayProfile
|
||||
};
|
||||
|
||||
class DelayProfilesConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
dragIndex: null,
|
||||
dropIndex: null
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchDelayProfiles();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onConfirmDeleteDelayProfile = (id) => {
|
||||
this.props.deleteDelayProfile({ id });
|
||||
};
|
||||
|
||||
onDelayProfileDragMove = (dragIndex, dropIndex) => {
|
||||
if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
|
||||
this.setState({
|
||||
dragIndex,
|
||||
dropIndex
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onDelayProfileDragEnd = ({ id }, didDrop) => {
|
||||
const {
|
||||
dropIndex
|
||||
} = this.state;
|
||||
|
||||
if (didDrop && dropIndex !== null) {
|
||||
this.props.reorderDelayProfile({ id, moveIndex: dropIndex - 1 });
|
||||
}
|
||||
|
||||
this.setState({
|
||||
dragIndex: null,
|
||||
dropIndex: null
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<DelayProfiles
|
||||
{...this.state}
|
||||
{...this.props}
|
||||
onConfirmDeleteDelayProfile={this.onConfirmDeleteDelayProfile}
|
||||
onDelayProfileDragMove={this.onDelayProfileDragMove}
|
||||
onDelayProfileDragEnd={this.onDelayProfileDragEnd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DelayProfilesConnector.propTypes = {
|
||||
fetchDelayProfiles: PropTypes.func.isRequired,
|
||||
deleteDelayProfile: PropTypes.func.isRequired,
|
||||
reorderDelayProfile: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(DelayProfilesConnector);
|
|
@ -1,27 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import EditDelayProfileModalContentConnector from './EditDelayProfileModalContentConnector';
|
||||
|
||||
function EditDelayProfileModal({ isOpen, onModalClose, ...otherProps }) {
|
||||
return (
|
||||
<Modal
|
||||
size={sizes.MEDIUM}
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<EditDelayProfileModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
EditDelayProfileModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EditDelayProfileModal;
|
|
@ -0,0 +1,37 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import EditDelayProfileModalContent, {
|
||||
EditDelayProfileModalContentProps,
|
||||
} from './EditDelayProfileModalContent';
|
||||
|
||||
interface EditDelayProfileModalProps extends EditDelayProfileModalContentProps {
|
||||
isOpen: boolean;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function EditDelayProfileModal({
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
}: EditDelayProfileModalProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
dispatch(clearPendingChanges({ section: 'settings.delayProfiles' }));
|
||||
onModalClose();
|
||||
}, [dispatch, onModalClose]);
|
||||
|
||||
return (
|
||||
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={handleModalClose}>
|
||||
<EditDelayProfileModalContent
|
||||
{...otherProps}
|
||||
onModalClose={handleModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditDelayProfileModal;
|
|
@ -1,43 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import EditDelayProfileModal from './EditDelayProfileModal';
|
||||
|
||||
function mapStateToProps() {
|
||||
return {};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
clearPendingChanges
|
||||
};
|
||||
|
||||
class EditDelayProfileModalConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onModalClose = () => {
|
||||
this.props.clearPendingChanges({ section: 'settings.delayProfiles' });
|
||||
this.props.onModalClose();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EditDelayProfileModal
|
||||
{...this.props}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditDelayProfileModalConnector.propTypes = {
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
clearPendingChanges: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EditDelayProfileModalConnector);
|
|
@ -1,266 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
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 SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||
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 { boolSettingShape, numberSettingShape, tagSettingShape } from 'Helpers/Props/Shapes/settingShape';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EditDelayProfileModalContent.css';
|
||||
|
||||
const protocolOptions = [
|
||||
{
|
||||
key: 'preferUsenet',
|
||||
get value() {
|
||||
return translate('PreferUsenet');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'preferTorrent',
|
||||
get value() {
|
||||
return translate('PreferTorrent');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'onlyUsenet',
|
||||
get value() {
|
||||
return translate('OnlyUsenet');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'onlyTorrent',
|
||||
get value() {
|
||||
return translate('OnlyTorrent');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
function EditDelayProfileModalContent(props) {
|
||||
const {
|
||||
id,
|
||||
isFetching,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
item,
|
||||
protocol,
|
||||
onInputChange,
|
||||
onProtocolChange,
|
||||
onSavePress,
|
||||
onModalClose,
|
||||
onDeleteDelayProfilePress,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const {
|
||||
enableUsenet,
|
||||
enableTorrent,
|
||||
usenetDelay,
|
||||
torrentDelay,
|
||||
bypassIfHighestQuality,
|
||||
bypassIfAboveCustomFormatScore,
|
||||
minimumCustomFormatScore,
|
||||
tags
|
||||
} = item;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{id ? translate('EditDelayProfile') : translate('AddDelayProfile')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
{
|
||||
isFetching ?
|
||||
<LoadingIndicator /> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !!error ?
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('AddDelayProfileError')}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !error ?
|
||||
<Form {...otherProps}>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('PreferredProtocol')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="protocol"
|
||||
value={protocol}
|
||||
values={protocolOptions}
|
||||
helpText={translate('ProtocolHelpText')}
|
||||
onChange={onProtocolChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
enableUsenet.value ?
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('UsenetDelay')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="usenetDelay"
|
||||
unit="minutes"
|
||||
{...usenetDelay}
|
||||
helpText={translate('UsenetDelayHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
enableTorrent.value ?
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('TorrentDelay')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="torrentDelay"
|
||||
unit="minutes"
|
||||
{...torrentDelay}
|
||||
helpText={translate('TorrentDelayHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup> :
|
||||
null
|
||||
}
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('BypassDelayIfHighestQuality')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="bypassIfHighestQuality"
|
||||
{...bypassIfHighestQuality}
|
||||
helpText={translate('BypassDelayIfHighestQualityHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('BypassDelayIfAboveCustomFormatScore')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="bypassIfAboveCustomFormatScore"
|
||||
{...bypassIfAboveCustomFormatScore}
|
||||
helpText={translate('BypassDelayIfAboveCustomFormatScoreHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
bypassIfAboveCustomFormatScore.value ?
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('BypassDelayIfAboveCustomFormatScoreMinimumScore')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="minimumCustomFormatScore"
|
||||
{...minimumCustomFormatScore}
|
||||
helpText={translate('BypassDelayIfAboveCustomFormatScoreMinimumScoreHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
id === 1 ?
|
||||
<Alert>
|
||||
{translate('DefaultDelayProfileSeries')}
|
||||
</Alert> :
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
{...tags}
|
||||
helpText={translate('DelayProfileSeriesTagsHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
</Form> :
|
||||
null
|
||||
}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
{
|
||||
id && id > 1 ?
|
||||
<Button
|
||||
className={styles.deleteButton}
|
||||
kind={kinds.DANGER}
|
||||
onPress={onDeleteDelayProfilePress}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</Button> :
|
||||
null
|
||||
}
|
||||
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Cancel')}
|
||||
</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={onSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
const delayProfileShape = {
|
||||
enableUsenet: PropTypes.shape(boolSettingShape).isRequired,
|
||||
enableTorrent: PropTypes.shape(boolSettingShape).isRequired,
|
||||
usenetDelay: PropTypes.shape(numberSettingShape).isRequired,
|
||||
torrentDelay: PropTypes.shape(numberSettingShape).isRequired,
|
||||
bypassIfHighestQuality: PropTypes.shape(boolSettingShape).isRequired,
|
||||
bypassIfAboveCustomFormatScore: PropTypes.shape(boolSettingShape).isRequired,
|
||||
minimumCustomFormatScore: PropTypes.shape(numberSettingShape).isRequired,
|
||||
order: PropTypes.shape(numberSettingShape),
|
||||
tags: PropTypes.shape(tagSettingShape).isRequired
|
||||
};
|
||||
|
||||
EditDelayProfileModalContent.propTypes = {
|
||||
id: PropTypes.number,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.shape(delayProfileShape).isRequired,
|
||||
protocol: PropTypes.string.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onProtocolChange: PropTypes.func.isRequired,
|
||||
onSavePress: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
onDeleteDelayProfilePress: PropTypes.func
|
||||
};
|
||||
|
||||
export default EditDelayProfileModalContent;
|
|
@ -0,0 +1,370 @@
|
|||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
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 SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||
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 usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { inputTypes, kinds } from 'Helpers/Props';
|
||||
import {
|
||||
saveDelayProfile,
|
||||
setDelayProfileValue,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EditDelayProfileModalContent.css';
|
||||
|
||||
const newDelayProfile: Record<string, boolean | number | number[] | string> = {
|
||||
enableUsenet: true,
|
||||
enableTorrent: true,
|
||||
preferredProtocol: 'usenet',
|
||||
usenetDelay: 0,
|
||||
torrentDelay: 0,
|
||||
bypassIfHighestQuality: false,
|
||||
bypassIfAboveCustomFormatScore: false,
|
||||
minimumCustomFormatScore: 0,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const protocolOptions = [
|
||||
{
|
||||
key: 'preferUsenet',
|
||||
get value() {
|
||||
return translate('PreferUsenet');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'preferTorrent',
|
||||
get value() {
|
||||
return translate('PreferTorrent');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'onlyUsenet',
|
||||
get value() {
|
||||
return translate('OnlyUsenet');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'onlyTorrent',
|
||||
get value() {
|
||||
return translate('OnlyTorrent');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function createDelayProfileSelector(id: number | undefined) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.delayProfiles,
|
||||
(delayProfiles) => {
|
||||
const { isFetching, error, isSaving, saveError, pendingChanges, items } =
|
||||
delayProfiles;
|
||||
|
||||
const profile = id ? items.find((i) => i.id === id) : newDelayProfile;
|
||||
const settings = selectSettings(profile!, pendingChanges, saveError);
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
item: settings.settings,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export interface EditDelayProfileModalContentProps {
|
||||
id?: number;
|
||||
onDeleteDelayProfilePress?: () => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function EditDelayProfileModalContent({
|
||||
id,
|
||||
onModalClose,
|
||||
onDeleteDelayProfilePress,
|
||||
...otherProps
|
||||
}: EditDelayProfileModalContentProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { item, isFetching, error, isSaving, saveError } = useSelector(
|
||||
createDelayProfileSelector(id)
|
||||
);
|
||||
|
||||
const {
|
||||
enableUsenet,
|
||||
enableTorrent,
|
||||
preferredProtocol,
|
||||
usenetDelay,
|
||||
torrentDelay,
|
||||
bypassIfHighestQuality,
|
||||
bypassIfAboveCustomFormatScore,
|
||||
minimumCustomFormatScore,
|
||||
tags,
|
||||
} = item;
|
||||
|
||||
const protocol = useMemo(() => {
|
||||
if (!enableUsenet.value) {
|
||||
return 'onlyTorrent';
|
||||
} else if (!enableTorrent.value) {
|
||||
return 'onlyUsenet';
|
||||
}
|
||||
|
||||
return preferredProtocol.value === 'usenet'
|
||||
? 'preferUsenet'
|
||||
: 'preferTorrent';
|
||||
}, [enableUsenet, enableTorrent, preferredProtocol]);
|
||||
|
||||
const wasSaving = usePrevious(isSaving);
|
||||
|
||||
const onInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
// @ts-expect-error - actions are not typed
|
||||
dispatch(setDelayProfileValue({ name, value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const onProtocolChange = useCallback(
|
||||
({ value }: InputChanged) => {
|
||||
let enableUsenet = false;
|
||||
let enableTorrent = false;
|
||||
let preferredProtocol: 'usenet' | 'torrent' = 'usenet';
|
||||
|
||||
switch (value) {
|
||||
case 'preferUsenet':
|
||||
enableUsenet = true;
|
||||
enableTorrent = true;
|
||||
preferredProtocol = 'usenet';
|
||||
|
||||
break;
|
||||
case 'preferTorrent':
|
||||
enableUsenet = true;
|
||||
enableTorrent = true;
|
||||
preferredProtocol = 'torrent';
|
||||
|
||||
break;
|
||||
case 'onlyUsenet':
|
||||
enableUsenet = true;
|
||||
enableTorrent = false;
|
||||
preferredProtocol = 'usenet';
|
||||
|
||||
break;
|
||||
case 'onlyTorrent':
|
||||
enableUsenet = false;
|
||||
enableTorrent = true;
|
||||
preferredProtocol = 'torrent';
|
||||
|
||||
break;
|
||||
default:
|
||||
throw Error(`Unknown protocol option: ${value}`);
|
||||
}
|
||||
|
||||
dispatch(
|
||||
// @ts-expect-error - actions are not typed
|
||||
setDelayProfileValue({ name: 'enableUsenet', value: enableUsenet })
|
||||
);
|
||||
dispatch(
|
||||
// @ts-expect-error - actions are not typed
|
||||
setDelayProfileValue({
|
||||
name: 'enableTorrent',
|
||||
value: enableTorrent,
|
||||
})
|
||||
);
|
||||
dispatch(
|
||||
// @ts-expect-error - actions are not typed
|
||||
setDelayProfileValue({
|
||||
name: 'preferredProtocol',
|
||||
value: preferredProtocol,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleSavePress = useCallback(() => {
|
||||
dispatch(saveDelayProfile({ id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
Object.keys(newDelayProfile).forEach((name) => {
|
||||
dispatch(
|
||||
// @ts-expect-error - actions are not typed
|
||||
setDelayProfileValue({
|
||||
name,
|
||||
value: newDelayProfile[name],
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [id, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasSaving && !isSaving && !saveError) {
|
||||
onModalClose();
|
||||
}
|
||||
}, [isSaving, wasSaving, saveError, onModalClose]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{id ? translate('EditDelayProfile') : translate('AddDelayProfile')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
{isFetching ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isFetching && !!error ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('AddDelayProfileError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{!isFetching && !error ? (
|
||||
<Form {...otherProps}>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('PreferredProtocol')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="protocol"
|
||||
value={protocol}
|
||||
values={protocolOptions}
|
||||
helpText={translate('ProtocolHelpText')}
|
||||
onChange={onProtocolChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{enableUsenet.value ? (
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('UsenetDelay')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="usenetDelay"
|
||||
unit="minutes"
|
||||
{...usenetDelay}
|
||||
helpText={translate('UsenetDelayHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
) : null}
|
||||
|
||||
{enableTorrent.value ? (
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('TorrentDelay')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="torrentDelay"
|
||||
unit="minutes"
|
||||
{...torrentDelay}
|
||||
helpText={translate('TorrentDelayHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
) : null}
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('BypassDelayIfHighestQuality')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="bypassIfHighestQuality"
|
||||
{...bypassIfHighestQuality}
|
||||
helpText={translate('BypassDelayIfHighestQualityHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
{translate('BypassDelayIfAboveCustomFormatScore')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="bypassIfAboveCustomFormatScore"
|
||||
{...bypassIfAboveCustomFormatScore}
|
||||
helpText={translate(
|
||||
'BypassDelayIfAboveCustomFormatScoreHelpText'
|
||||
)}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{bypassIfAboveCustomFormatScore.value ? (
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
{translate('BypassDelayIfAboveCustomFormatScoreMinimumScore')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="minimumCustomFormatScore"
|
||||
{...minimumCustomFormatScore}
|
||||
helpText={translate(
|
||||
'BypassDelayIfAboveCustomFormatScoreMinimumScoreHelpText'
|
||||
)}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
) : null}
|
||||
|
||||
{id === 1 ? (
|
||||
<Alert>{translate('DefaultDelayProfileSeries')}</Alert>
|
||||
) : (
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
{...tags}
|
||||
helpText={translate('DelayProfileSeriesTagsHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</Form>
|
||||
) : null}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{id && id > 1 ? (
|
||||
<Button
|
||||
className={styles.deleteButton}
|
||||
kind={kinds.DANGER}
|
||||
onPress={onDeleteDelayProfilePress}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={handleSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditDelayProfileModalContent;
|
|
@ -1,172 +0,0 @@
|
|||
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 EditDelayProfileModalContent from './EditDelayProfileModalContent';
|
||||
|
||||
const newDelayProfile = {
|
||||
enableUsenet: true,
|
||||
enableTorrent: true,
|
||||
preferredProtocol: 'usenet',
|
||||
usenetDelay: 0,
|
||||
torrentDelay: 0,
|
||||
bypassIfHighestQuality: false,
|
||||
bypassIfAboveCustomFormatScore: false,
|
||||
minimumCustomFormatScore: 0,
|
||||
tags: []
|
||||
};
|
||||
|
||||
function createDelayProfileSelector() {
|
||||
return createSelector(
|
||||
(state, { id }) => id,
|
||||
(state) => state.settings.delayProfiles,
|
||||
(id, delayProfiles) => {
|
||||
const {
|
||||
isFetching,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
pendingChanges,
|
||||
items
|
||||
} = delayProfiles;
|
||||
|
||||
const profile = id ? items.find((i) => i.id === id) : newDelayProfile;
|
||||
const settings = selectSettings(profile, pendingChanges, saveError);
|
||||
|
||||
return {
|
||||
id,
|
||||
isFetching,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
item: settings.settings,
|
||||
...settings
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createDelayProfileSelector(),
|
||||
(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,
|
||||
...delayProfile
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setDelayProfileValue,
|
||||
saveDelayProfile
|
||||
};
|
||||
|
||||
class EditDelayProfileModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.id) {
|
||||
Object.keys(newDelayProfile).forEach((name) => {
|
||||
this.props.setDelayProfileValue({
|
||||
name,
|
||||
value: newDelayProfile[name]
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
|
||||
this.props.onModalClose();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
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 });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EditDelayProfileModalContent
|
||||
{...this.props}
|
||||
onSavePress={this.onSavePress}
|
||||
onInputChange={this.onInputChange}
|
||||
onProtocolChange={this.onProtocolChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditDelayProfileModalContentConnector.propTypes = {
|
||||
id: PropTypes.number,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.object.isRequired,
|
||||
setDelayProfileValue: PropTypes.func.isRequired,
|
||||
saveDelayProfile: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(EditDelayProfileModalContentConnector);
|
|
@ -1,37 +0,0 @@
|
|||
import React, { Component } from 'react';
|
||||
import { DndProvider } from 'react-dnd-multi-backend';
|
||||
import HTML5toTouch from 'react-dnd-multi-backend/dist/esm/HTML5toTouch';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import SettingsToolbar from 'Settings/SettingsToolbar';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import DelayProfilesConnector from './Delay/DelayProfilesConnector';
|
||||
import QualityProfilesConnector from './Quality/QualityProfilesConnector';
|
||||
import ReleaseProfiles from './Release/ReleaseProfiles';
|
||||
|
||||
// Only a single DragDrop Context can exist so it's done here to allow editing
|
||||
// quality profiles and reordering delay profiles to work.
|
||||
|
||||
class Profiles extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<PageContent title={translate('Profiles')}>
|
||||
<SettingsToolbar showSave={false} />
|
||||
|
||||
<PageContentBody>
|
||||
<DndProvider options={HTML5toTouch}>
|
||||
<QualityProfilesConnector />
|
||||
<DelayProfilesConnector />
|
||||
<ReleaseProfiles />
|
||||
</DndProvider>
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Profiles;
|
31
frontend/src/Settings/Profiles/Profiles.tsx
Normal file
31
frontend/src/Settings/Profiles/Profiles.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { HTML5toTouch } from 'rdndmb-html5-to-touch';
|
||||
import React from 'react';
|
||||
import { DndProvider } from 'react-dnd-multi-backend';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import SettingsToolbar from 'Settings/SettingsToolbar';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import DelayProfiles from './Delay/DelayProfiles';
|
||||
import QualityProfiles from './Quality/QualityProfiles';
|
||||
import ReleaseProfiles from './Release/ReleaseProfiles';
|
||||
|
||||
// Only a single DragDrop Context can exist so it's done here to allow editing
|
||||
// quality profiles and reordering delay profiles to work.
|
||||
|
||||
function Profiles() {
|
||||
return (
|
||||
<PageContent title={translate('Profiles')}>
|
||||
<SettingsToolbar showSave={false} />
|
||||
|
||||
<PageContentBody>
|
||||
<DndProvider options={HTML5toTouch}>
|
||||
<QualityProfiles />
|
||||
<DelayProfiles />
|
||||
<ReleaseProfiles />
|
||||
</DndProvider>
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default Profiles;
|
|
@ -1,61 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import EditQualityProfileModalContentConnector from './EditQualityProfileModalContentConnector';
|
||||
|
||||
class EditQualityProfileModal extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
height: 'auto'
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onContentHeightChange = (height) => {
|
||||
if (this.state.height === 'auto' || height !== 0) {
|
||||
this.setState({ height });
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
style={{ height: `${this.state.height}px` }}
|
||||
isOpen={isOpen}
|
||||
size={sizes.EXTRA_LARGE}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<EditQualityProfileModalContentConnector
|
||||
{...otherProps}
|
||||
onContentHeightChange={this.onContentHeightChange}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditQualityProfileModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EditQualityProfileModal;
|
|
@ -0,0 +1,55 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import EditQualityProfileModalContent from './EditQualityProfileModalContent';
|
||||
|
||||
interface EditQualityProfileModalProps {
|
||||
id?: number;
|
||||
isOpen: boolean;
|
||||
onDeleteQualityProfilePress?: () => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function EditQualityProfileModal({
|
||||
id,
|
||||
isOpen,
|
||||
onDeleteQualityProfilePress,
|
||||
onModalClose,
|
||||
}: EditQualityProfileModalProps) {
|
||||
const dispatch = useDispatch();
|
||||
const [height, setHeight] = useState<'auto' | number>('auto');
|
||||
|
||||
const handleOnModalClose = useCallback(() => {
|
||||
dispatch(clearPendingChanges({ section: 'settings.qualityProfiles' }));
|
||||
onModalClose();
|
||||
}, [dispatch, onModalClose]);
|
||||
|
||||
const handleContentHeightChange = useCallback(
|
||||
(newHeight: number) => {
|
||||
if (height === 'auto' || newHeight !== 0) {
|
||||
setHeight(newHeight);
|
||||
}
|
||||
},
|
||||
[height]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
style={{ height: height === 'auto' ? 'auto' : `${height}px` }}
|
||||
isOpen={isOpen}
|
||||
size={sizes.EXTRA_LARGE}
|
||||
onModalClose={handleOnModalClose}
|
||||
>
|
||||
<EditQualityProfileModalContent
|
||||
id={id}
|
||||
onContentHeightChange={handleContentHeightChange}
|
||||
onDeleteQualityProfilePress={onDeleteQualityProfilePress}
|
||||
onModalClose={handleOnModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditQualityProfileModal;
|
|
@ -1,43 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import EditQualityProfileModal from './EditQualityProfileModal';
|
||||
|
||||
function mapStateToProps() {
|
||||
return {};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
clearPendingChanges
|
||||
};
|
||||
|
||||
class EditQualityProfileModalConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onModalClose = () => {
|
||||
this.props.clearPendingChanges({ section: 'settings.qualityProfiles' });
|
||||
this.props.onModalClose();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EditQualityProfileModal
|
||||
{...this.props}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditQualityProfileModalConnector.propTypes = {
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
clearPendingChanges: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EditQualityProfileModalConnector);
|
|
@ -1,363 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
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 SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Measure from 'Components/Measure';
|
||||
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, sizes } from 'Helpers/Props';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import QualityProfileFormatItems from './QualityProfileFormatItems';
|
||||
import QualityProfileItems from './QualityProfileItems';
|
||||
import styles from './EditQualityProfileModalContent.css';
|
||||
|
||||
const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding);
|
||||
|
||||
function getCustomFormatRender(formatItems, otherProps) {
|
||||
return (
|
||||
<QualityProfileFormatItems
|
||||
profileFormatItems={formatItems.value}
|
||||
errors={formatItems.errors}
|
||||
warnings={formatItems.warnings}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
class EditQualityProfileModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
headerHeight: 0,
|
||||
bodyHeight: 0,
|
||||
defaultBodyHeight: 0,
|
||||
editGroupsBodyHeight: 0,
|
||||
editSizesBodyHeight: 0,
|
||||
footerHeight: 0
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const {
|
||||
headerHeight,
|
||||
footerHeight
|
||||
} = this.state;
|
||||
|
||||
const bodyHeight = this.state[`${this.props.mode}BodyHeight`];
|
||||
|
||||
if (
|
||||
headerHeight > 0 &&
|
||||
bodyHeight > 0 &&
|
||||
footerHeight > 0 &&
|
||||
(
|
||||
headerHeight !== prevState.headerHeight ||
|
||||
bodyHeight !== prevState[`${prevProps.mode}BodyHeight`] ||
|
||||
footerHeight !== prevState.footerHeight
|
||||
)
|
||||
) {
|
||||
const padding = MODAL_BODY_PADDING * 2;
|
||||
|
||||
this.props.onContentHeightChange(
|
||||
headerHeight + bodyHeight + footerHeight + padding
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onHeaderMeasure = ({ height }) => {
|
||||
if (height !== this.state.headerHeight) {
|
||||
this.setState({ headerHeight: height });
|
||||
}
|
||||
};
|
||||
|
||||
onBodyMeasure = ({ height }) => {
|
||||
const heightKey = `${this.props.mode}BodyHeight`;
|
||||
|
||||
if (height !== this.state[heightKey]) {
|
||||
this.setState({ [heightKey]: height });
|
||||
}
|
||||
};
|
||||
|
||||
onFooterMeasure = ({ height }) => {
|
||||
if (height > this.state.footerHeight) {
|
||||
this.setState({ footerHeight: height });
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
mode,
|
||||
isFetching,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
qualities,
|
||||
customFormats,
|
||||
item,
|
||||
isInUse,
|
||||
onInputChange,
|
||||
onCutoffChange,
|
||||
onSavePress,
|
||||
onModalClose,
|
||||
onDeleteQualityProfilePress,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
upgradeAllowed,
|
||||
cutoff,
|
||||
minFormatScore,
|
||||
minUpgradeFormatScore,
|
||||
cutoffFormatScore,
|
||||
items,
|
||||
formatItems
|
||||
} = item;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<Measure
|
||||
whitelist={['height']}
|
||||
includeMargin={false}
|
||||
onMeasure={this.onHeaderMeasure}
|
||||
>
|
||||
<ModalHeader>
|
||||
{id ? translate('EditQualityProfile') : translate('AddQualityProfile')}
|
||||
</ModalHeader>
|
||||
</Measure>
|
||||
|
||||
<ModalBody>
|
||||
<Measure
|
||||
whitelist={['height']}
|
||||
onMeasure={this.onBodyMeasure}
|
||||
>
|
||||
<div>
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !!error &&
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('AddQualityProfileError')}
|
||||
</Alert>
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !error &&
|
||||
<Form
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.formGroupsContainer}>
|
||||
<div className={styles.formGroupWrapper}>
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>
|
||||
{translate('Name')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="name"
|
||||
{...name}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>
|
||||
{translate('UpgradesAllowed')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="upgradeAllowed"
|
||||
{...upgradeAllowed}
|
||||
helpText={translate('UpgradesAllowedHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
upgradeAllowed.value &&
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>
|
||||
{translate('UpgradeUntil')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="cutoff"
|
||||
{...cutoff}
|
||||
values={qualities}
|
||||
helpText={translate('UpgradeUntilEpisodeHelpText')}
|
||||
onChange={onCutoffChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
{
|
||||
formatItems.value.length > 0 &&
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>
|
||||
{translate('MinimumCustomFormatScore')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="minFormatScore"
|
||||
{...minFormatScore}
|
||||
helpText={translate('MinimumCustomFormatScoreHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
{
|
||||
upgradeAllowed.value && formatItems.value.length > 0 &&
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>
|
||||
{translate('UpgradeUntilCustomFormatScore')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="cutoffFormatScore"
|
||||
{...cutoffFormatScore}
|
||||
helpText={translate('UpgradeUntilCustomFormatScoreEpisodeHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
{
|
||||
upgradeAllowed.value && formatItems.value.length > 0 ?
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>
|
||||
{translate('MinimumCustomFormatScoreIncrement')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="minUpgradeFormatScore"
|
||||
min={1}
|
||||
{...minUpgradeFormatScore}
|
||||
helpText={translate('MinimumCustomFormatScoreIncrementHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup> :
|
||||
null
|
||||
}
|
||||
|
||||
<div className={styles.formatItemLarge}>
|
||||
{getCustomFormatRender(formatItems, otherProps)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroupWrapper}>
|
||||
<QualityProfileItems
|
||||
mode={mode}
|
||||
qualityProfileItems={items.value}
|
||||
errors={items.errors}
|
||||
warnings={items.warnings}
|
||||
{...otherProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formatItemSmall}>
|
||||
{getCustomFormatRender(formatItems, otherProps)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
}
|
||||
</div>
|
||||
</Measure>
|
||||
</ModalBody>
|
||||
|
||||
<Measure
|
||||
whitelist={['height']}
|
||||
includeMargin={false}
|
||||
onMeasure={this.onFooterMeasure}
|
||||
>
|
||||
<ModalFooter>
|
||||
{
|
||||
id ?
|
||||
<div
|
||||
className={styles.deleteButtonContainer}
|
||||
title={
|
||||
isInUse ?
|
||||
translate('QualityProfileInUseSeriesListCollection') :
|
||||
undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
kind={kinds.DANGER}
|
||||
isDisabled={isInUse}
|
||||
onPress={onDeleteQualityProfilePress}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Cancel')}
|
||||
</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={onSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</Measure>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditQualityProfileModalContent.propTypes = {
|
||||
mode: PropTypes.string.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
customFormats: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
item: PropTypes.object.isRequired,
|
||||
isInUse: PropTypes.bool.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onCutoffChange: PropTypes.func.isRequired,
|
||||
onSavePress: PropTypes.func.isRequired,
|
||||
onContentHeightChange: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
onDeleteQualityProfilePress: PropTypes.func
|
||||
};
|
||||
|
||||
export default EditQualityProfileModalContent;
|
|
@ -0,0 +1,763 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { QualityProfilesAppState } from 'App/State/SettingsAppState';
|
||||
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 SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||
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 useMeasure from 'Helpers/Hooks/useMeasure';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||
import {
|
||||
fetchQualityProfileSchema,
|
||||
saveQualityProfile,
|
||||
setQualityProfileValue,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector';
|
||||
import createQualityProfileInUseSelector from 'Store/Selectors/createQualityProfileInUseSelector';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import QualityProfile, {
|
||||
QualityProfileGroup,
|
||||
QualityProfileQualityItem,
|
||||
} from 'typings/QualityProfile';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import QualityProfileFormatItems from './QualityProfileFormatItems';
|
||||
import { DragMoveState } from './QualityProfileItemDragSource';
|
||||
import QualityProfileItems, {
|
||||
EditQualityProfileMode,
|
||||
} from './QualityProfileItems';
|
||||
import { SizeChanged } from './QualityProfileItemSize';
|
||||
import styles from './EditQualityProfileModalContent.css';
|
||||
|
||||
const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding);
|
||||
|
||||
function parseIndex(index: string): [number | null, number] {
|
||||
const split = index.split('.');
|
||||
|
||||
if (split.length === 1) {
|
||||
return [null, parseInt(split[0]) - 1];
|
||||
}
|
||||
|
||||
return [parseInt(split[0]) - 1, parseInt(split[1]) - 1];
|
||||
}
|
||||
|
||||
interface EditQualityProfileModalContentProps {
|
||||
id?: number;
|
||||
onContentHeightChange: (height: number) => void;
|
||||
onDeleteQualityProfilePress?: () => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function EditQualityProfileModalContent({
|
||||
id,
|
||||
onContentHeightChange,
|
||||
onDeleteQualityProfilePress,
|
||||
onModalClose,
|
||||
}: EditQualityProfileModalContentProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { error, isFetching, isPopulated, isSaving, saveError, item } =
|
||||
useSelector(
|
||||
createProviderSettingsSelectorHook<
|
||||
QualityProfile,
|
||||
QualityProfilesAppState
|
||||
>('qualityProfiles', id)
|
||||
);
|
||||
|
||||
const isInUse = useSelector(createQualityProfileInUseSelector(id));
|
||||
|
||||
const [measureHeaderRef, { height: headerHeight }] = useMeasure();
|
||||
const [measureBodyRef, { height: bodyHeight }] = useMeasure();
|
||||
const [measureFooterRef, { height: footerHeight }] = useMeasure();
|
||||
|
||||
const [mode, setMode] = useState<EditQualityProfileMode>('default');
|
||||
const [defaultBodyHeight, setDefaultBodyHeight] = useState(0);
|
||||
const [editGroupsBodyHeight, setEditGroupsBodyHeight] = useState(0);
|
||||
const [editSizesBodyHeight, setEditSizesBodyHeight] = useState(0);
|
||||
const [dndState, setDndState] = useState<DragMoveState>({
|
||||
dragQualityIndex: null,
|
||||
dropQualityIndex: null,
|
||||
dropPosition: null,
|
||||
});
|
||||
|
||||
const wasSaving = usePrevious(isSaving);
|
||||
const { dragQualityIndex, dropQualityIndex, dropPosition } = dndState;
|
||||
|
||||
const {
|
||||
name,
|
||||
upgradeAllowed,
|
||||
cutoff,
|
||||
minFormatScore,
|
||||
minUpgradeFormatScore,
|
||||
cutoffFormatScore,
|
||||
items,
|
||||
formatItems,
|
||||
} = item;
|
||||
|
||||
const qualities = useMemo(() => {
|
||||
if (!items?.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items.value.reduceRight<{ key: number; value: string }[]>(
|
||||
(acc, item) => {
|
||||
if (item.allowed) {
|
||||
if ('id' in item) {
|
||||
acc.push({
|
||||
key: item.id,
|
||||
value: item.name,
|
||||
});
|
||||
} else {
|
||||
acc.push({
|
||||
key: item.quality.id,
|
||||
value: item.quality.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
}, [items]);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
// @ts-expect-error - actions are not typed
|
||||
dispatch(setQualityProfileValue({ name, value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleSavePress = useCallback(() => {
|
||||
dispatch(saveQualityProfile({ id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
const handleCutoffChange = useCallback(
|
||||
({ name, value }: InputChanged<number>) => {
|
||||
const cutoffItem = items.value.find((item) => {
|
||||
return 'id' in item ? item.id === value : item.quality.id === value;
|
||||
});
|
||||
|
||||
if (cutoffItem) {
|
||||
const cutoffId =
|
||||
'id' in cutoffItem ? cutoffItem.id : cutoffItem.quality.id;
|
||||
|
||||
// @ts-expect-error - actions are not typed
|
||||
dispatch(setQualityProfileValue({ name, value: cutoffId }));
|
||||
}
|
||||
},
|
||||
[items, dispatch]
|
||||
);
|
||||
|
||||
const handleItemAllowedChange = useCallback(
|
||||
(qualityId: number, allowed: boolean) => {
|
||||
const newItems = items.value.map((item) => {
|
||||
if ('quality' in item && item.quality.id === qualityId) {
|
||||
return {
|
||||
...item,
|
||||
allowed,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
dispatch(
|
||||
// @ts-expect-error - actions are not typed
|
||||
setQualityProfileValue({
|
||||
name: 'items',
|
||||
value: newItems,
|
||||
})
|
||||
);
|
||||
},
|
||||
[items, dispatch]
|
||||
);
|
||||
|
||||
const handleGroupAllowedChange = useCallback(
|
||||
(groupId: number, allowed: boolean) => {
|
||||
const newItems = items.value.map((item) => {
|
||||
if ('id' in item && item.id === groupId) {
|
||||
return {
|
||||
...item,
|
||||
allowed,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
dispatch(
|
||||
// @ts-expect-error - actions are not typed
|
||||
setQualityProfileValue({
|
||||
name: 'items',
|
||||
value: newItems,
|
||||
})
|
||||
);
|
||||
},
|
||||
[items, dispatch]
|
||||
);
|
||||
|
||||
const handleGroupNameChange = useCallback(
|
||||
(groupId: number, name: string) => {
|
||||
const newItems = items.value.map((item) => {
|
||||
if ('id' in item && item.id === groupId) {
|
||||
return {
|
||||
...item,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
// @ts-expect-error - actions are not typed
|
||||
dispatch(setQualityProfileValue({ name: 'items', value: newItems }));
|
||||
},
|
||||
[items, dispatch]
|
||||
);
|
||||
|
||||
const handleSizeChange = useCallback(
|
||||
(sizeChange: SizeChanged) => {
|
||||
const { qualityId, ...sizes } = sizeChange;
|
||||
|
||||
const newItems = items.value.map((item) => {
|
||||
if ('quality' in item && item.quality.id === qualityId) {
|
||||
return {
|
||||
...item,
|
||||
...sizes,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
items: (item as QualityProfileGroup).items.map((subItem) => {
|
||||
if (subItem.quality.id === qualityId) {
|
||||
return {
|
||||
...subItem,
|
||||
...sizes,
|
||||
};
|
||||
}
|
||||
|
||||
return subItem;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
dispatch(
|
||||
// @ts-expect-error - actions are not typed
|
||||
setQualityProfileValue({
|
||||
name: 'items',
|
||||
value: newItems,
|
||||
})
|
||||
);
|
||||
},
|
||||
[items, dispatch]
|
||||
);
|
||||
|
||||
const handleCreateGroupPress = useCallback(
|
||||
(qualityId: number) => {
|
||||
const groupId =
|
||||
items.value.reduce((acc, item) => {
|
||||
if ('id' in item && item.id > acc) {
|
||||
acc = item.id;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, 1000) + 1;
|
||||
|
||||
const newItems = items.value.map((item) => {
|
||||
if ('quality' in item && item.quality.id === qualityId) {
|
||||
return {
|
||||
id: groupId,
|
||||
name: item.quality.name,
|
||||
allowed: item.allowed,
|
||||
items: [item],
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
// @ts-expect-error - actions are not typed
|
||||
dispatch(setQualityProfileValue({ name: 'items', newItems }));
|
||||
},
|
||||
[items, dispatch]
|
||||
);
|
||||
|
||||
const handleDeleteGroupPress = useCallback(
|
||||
(groupId: number) => {
|
||||
const newItems = items.value.reduce<QualityProfileQualityItem[]>(
|
||||
(acc, item) => {
|
||||
if ('id' in item && item.id === groupId) {
|
||||
acc.push(...item.items);
|
||||
} else {
|
||||
acc.push(item as QualityProfileQualityItem);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// @ts-expect-error - actions are not typed
|
||||
dispatch(setQualityProfileValue({ name: 'items', value: newItems }));
|
||||
},
|
||||
[items, dispatch]
|
||||
);
|
||||
|
||||
const handleDragMove = useCallback((options: DragMoveState) => {
|
||||
const { dragQualityIndex, dropQualityIndex, dropPosition } = options;
|
||||
|
||||
if (!dragQualityIndex || !dropQualityIndex || !dropPosition) {
|
||||
setDndState({
|
||||
dragQualityIndex: null,
|
||||
dropQualityIndex: null,
|
||||
dropPosition: null,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
|
||||
const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
|
||||
|
||||
if (
|
||||
(dropPosition === 'below' && dropItemIndex - 1 === dragItemIndex) ||
|
||||
(dropPosition === 'above' && dropItemIndex + 1 === dragItemIndex)
|
||||
) {
|
||||
setDndState({
|
||||
dragQualityIndex: null,
|
||||
dropQualityIndex: null,
|
||||
dropPosition: null,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let adjustedDropQualityIndex = dropQualityIndex;
|
||||
|
||||
// Correct dragging out of a group to the position above
|
||||
if (
|
||||
dropPosition === 'above' &&
|
||||
dragGroupIndex !== dropGroupIndex &&
|
||||
dropGroupIndex != null
|
||||
) {
|
||||
// Add 1 to the group index and 2 to the item index so it's inserted above in the correct group
|
||||
adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex + 2}`;
|
||||
}
|
||||
|
||||
// Correct inserting above outside a group
|
||||
if (
|
||||
dropPosition === 'above' &&
|
||||
dragGroupIndex !== dropGroupIndex &&
|
||||
dropGroupIndex == null
|
||||
) {
|
||||
// Add 2 to the item index so it's entered in the correct place
|
||||
adjustedDropQualityIndex = `${dropItemIndex + 2}`;
|
||||
}
|
||||
|
||||
// Correct inserting below a quality within the same group (when moving a lower item)
|
||||
if (
|
||||
dropPosition === 'below' &&
|
||||
dragGroupIndex === dropGroupIndex &&
|
||||
dropGroupIndex != null &&
|
||||
dragItemIndex < dropItemIndex
|
||||
) {
|
||||
// Add 1 to the group index leave the item index
|
||||
adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex}`;
|
||||
}
|
||||
|
||||
// Correct inserting below a quality outside a group (when moving a lower item)
|
||||
if (
|
||||
dropPosition === 'below' &&
|
||||
dragGroupIndex === dropGroupIndex &&
|
||||
dropGroupIndex == null &&
|
||||
dragItemIndex < dropItemIndex
|
||||
) {
|
||||
// Leave the item index so it's inserted below the item
|
||||
adjustedDropQualityIndex = `${dropItemIndex}`;
|
||||
}
|
||||
|
||||
setDndState({
|
||||
dragQualityIndex,
|
||||
dropQualityIndex: adjustedDropQualityIndex,
|
||||
dropPosition,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(didDrop: boolean) => {
|
||||
if (didDrop && dragQualityIndex != null && dropQualityIndex != null) {
|
||||
const newItems = items.value.map((i) => {
|
||||
if ('id' in i) {
|
||||
return {
|
||||
...i,
|
||||
items: [...i.items],
|
||||
} as QualityProfileGroup;
|
||||
}
|
||||
|
||||
return {
|
||||
...i,
|
||||
} as QualityProfileQualityItem;
|
||||
});
|
||||
|
||||
const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
|
||||
const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
|
||||
|
||||
let item: QualityProfileQualityItem | null = null;
|
||||
let dropGroup: QualityProfileGroup | null = null;
|
||||
|
||||
// Get the group before moving anything so we know the correct place to drop it.
|
||||
if (dropGroupIndex != null) {
|
||||
dropGroup = newItems[dropGroupIndex] as QualityProfileGroup;
|
||||
}
|
||||
|
||||
if (dragGroupIndex == null) {
|
||||
item = newItems.splice(
|
||||
dragItemIndex,
|
||||
1
|
||||
)[0] as QualityProfileQualityItem;
|
||||
} else {
|
||||
const group = newItems[dragGroupIndex] as QualityProfileGroup;
|
||||
|
||||
item = group.items.splice(dragItemIndex, 1)[0];
|
||||
|
||||
// If the group is now empty, destroy it.
|
||||
if (!group.items.length) {
|
||||
newItems.splice(dragGroupIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (dropGroup == null) {
|
||||
newItems.splice(dropItemIndex, 0, item);
|
||||
} else {
|
||||
dropGroup.items.splice(dropItemIndex, 0, item);
|
||||
}
|
||||
|
||||
dispatch(
|
||||
// @ts-expect-error - actions are not typed
|
||||
setQualityProfileValue({
|
||||
name: 'items',
|
||||
value: newItems,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setDndState({
|
||||
dragQualityIndex: null,
|
||||
dropQualityIndex: null,
|
||||
dropPosition: null,
|
||||
});
|
||||
},
|
||||
[dragQualityIndex, dropQualityIndex, items, dispatch]
|
||||
);
|
||||
|
||||
const handleChangeMode = useCallback((newMode: EditQualityProfileMode) => {
|
||||
setMode(newMode);
|
||||
}, []);
|
||||
|
||||
const handleFormatItemScoreChange = useCallback(
|
||||
(formatId: number, score: number) => {
|
||||
const newFormatItems = formatItems.value.map((formatItem) => {
|
||||
if (formatItem.format === formatId) {
|
||||
return {
|
||||
...formatItem,
|
||||
score,
|
||||
};
|
||||
}
|
||||
|
||||
return formatItem;
|
||||
});
|
||||
|
||||
dispatch(
|
||||
// @ts-expect-error - actions are not typed
|
||||
setQualityProfileValue({
|
||||
name: 'formatItems',
|
||||
value: newFormatItems,
|
||||
})
|
||||
);
|
||||
},
|
||||
[formatItems, dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let bodyHeight = 0;
|
||||
|
||||
if (mode === 'default') {
|
||||
bodyHeight = defaultBodyHeight;
|
||||
} else if (mode === 'editGroups') {
|
||||
bodyHeight = editGroupsBodyHeight;
|
||||
} else if (mode === 'editSizes') {
|
||||
bodyHeight = editSizesBodyHeight;
|
||||
}
|
||||
|
||||
const padding = MODAL_BODY_PADDING * 2;
|
||||
|
||||
onContentHeightChange(headerHeight + bodyHeight + footerHeight + padding);
|
||||
}, [
|
||||
headerHeight,
|
||||
defaultBodyHeight,
|
||||
editGroupsBodyHeight,
|
||||
editSizesBodyHeight,
|
||||
footerHeight,
|
||||
mode,
|
||||
onContentHeightChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'default') {
|
||||
setDefaultBodyHeight(bodyHeight);
|
||||
} else if (mode === 'editGroups') {
|
||||
setEditGroupsBodyHeight(bodyHeight);
|
||||
} else if (mode === 'editSizes') {
|
||||
setEditSizesBodyHeight(bodyHeight);
|
||||
}
|
||||
}, [bodyHeight, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id && !isPopulated) {
|
||||
dispatch(fetchQualityProfileSchema());
|
||||
}
|
||||
}, [id, isPopulated, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasSaving && !isSaving && !saveError) {
|
||||
onModalClose();
|
||||
}
|
||||
}, [isSaving, wasSaving, saveError, onModalClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!items?.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cutoffItem = items.value.find((item) =>
|
||||
'id' in item ? item.id === cutoff.value : item.quality.id === cutoff.value
|
||||
);
|
||||
|
||||
// If the cutoff isn't allowed anymore or there isn't a cutoff set one
|
||||
if (!cutoff || !cutoffItem || !cutoffItem.allowed) {
|
||||
const firstAllowed = items.value.find((item) => item.allowed);
|
||||
|
||||
let cutoffId = null;
|
||||
|
||||
if (firstAllowed) {
|
||||
cutoffId =
|
||||
'id' in firstAllowed ? firstAllowed.id : firstAllowed.quality.id;
|
||||
|
||||
// @ts-expect-error - actions are not typed
|
||||
dispatch(setQualityProfileValue({ name: 'cutoff', value: cutoffId }));
|
||||
}
|
||||
}
|
||||
}, [cutoff, items, dispatch]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader ref={measureHeaderRef}>
|
||||
{id ? translate('EditQualityProfile') : translate('AddQualityProfile')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div ref={measureBodyRef}>
|
||||
{isPopulated ? null : <LoadingIndicator />}
|
||||
|
||||
{!isFetching && error ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('AddQualityProfileError')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isPopulated && !error ? (
|
||||
<Form>
|
||||
<div className={styles.formGroupsContainer}>
|
||||
<div className={styles.formGroupWrapper}>
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>
|
||||
{translate('Name')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="name"
|
||||
{...name}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>
|
||||
{translate('UpgradesAllowed')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="upgradeAllowed"
|
||||
{...upgradeAllowed}
|
||||
helpText={translate('UpgradesAllowedHelpText')}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{upgradeAllowed.value ? (
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>
|
||||
{translate('UpgradeUntil')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="cutoff"
|
||||
{...cutoff}
|
||||
values={qualities}
|
||||
helpText={translate('UpgradeUntilEpisodeHelpText')}
|
||||
onChange={handleCutoffChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
) : null}
|
||||
|
||||
{formatItems.value.length > 0 ? (
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>
|
||||
{translate('MinimumCustomFormatScore')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="minFormatScore"
|
||||
{...minFormatScore}
|
||||
helpText={translate('MinimumCustomFormatScoreHelpText')}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
) : null}
|
||||
|
||||
{upgradeAllowed.value && formatItems.value.length > 0 ? (
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>
|
||||
{translate('UpgradeUntilCustomFormatScore')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="cutoffFormatScore"
|
||||
{...cutoffFormatScore}
|
||||
helpText={translate(
|
||||
'UpgradeUntilCustomFormatScoreEpisodeHelpText'
|
||||
)}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
) : null}
|
||||
|
||||
{upgradeAllowed.value && formatItems.value.length > 0 ? (
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>
|
||||
{translate('MinimumCustomFormatScoreIncrement')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="minUpgradeFormatScore"
|
||||
min={1}
|
||||
{...minUpgradeFormatScore}
|
||||
helpText={translate(
|
||||
'MinimumCustomFormatScoreIncrementHelpText'
|
||||
)}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
) : null}
|
||||
|
||||
<div className={styles.formatItemLarge}>
|
||||
<QualityProfileFormatItems
|
||||
profileFormatItems={formatItems.value}
|
||||
errors={formatItems.errors}
|
||||
warnings={formatItems.warnings}
|
||||
onQualityProfileFormatItemScoreChange={
|
||||
handleFormatItemScoreChange
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroupWrapper}>
|
||||
<QualityProfileItems
|
||||
mode={mode}
|
||||
qualityProfileItems={items.value}
|
||||
errors={items.errors}
|
||||
warnings={items.warnings}
|
||||
dragQualityIndex={dragQualityIndex}
|
||||
dropQualityIndex={dropQualityIndex}
|
||||
dropPosition={dropPosition}
|
||||
onChangeMode={handleChangeMode}
|
||||
onCreateGroupPress={handleCreateGroupPress}
|
||||
onDeleteGroupPress={handleDeleteGroupPress}
|
||||
onItemAllowedChange={handleItemAllowedChange}
|
||||
onGroupAllowedChange={handleGroupAllowedChange}
|
||||
onItemGroupNameChange={handleGroupNameChange}
|
||||
onDragMove={handleDragMove}
|
||||
onDragEnd={handleDragEnd}
|
||||
onSizeChange={handleSizeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formatItemSmall}>
|
||||
<QualityProfileFormatItems
|
||||
profileFormatItems={formatItems.value}
|
||||
errors={formatItems.errors}
|
||||
warnings={formatItems.warnings}
|
||||
onQualityProfileFormatItemScoreChange={
|
||||
handleFormatItemScoreChange
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
) : null}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter ref={measureFooterRef}>
|
||||
{id ? (
|
||||
<div
|
||||
className={styles.deleteButtonContainer}
|
||||
title={
|
||||
isInUse
|
||||
? translate('QualityProfileInUseSeriesListCollection')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
kind={kinds.DANGER}
|
||||
isDisabled={isInUse}
|
||||
onPress={onDeleteQualityProfilePress}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={handleSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditQualityProfileModalContent;
|
|
@ -1,532 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchQualityProfileSchema, saveQualityProfile, setQualityProfileValue } from 'Store/Actions/settingsActions';
|
||||
import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector';
|
||||
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
|
||||
import EditQualityProfileModalContent from './EditQualityProfileModalContent';
|
||||
|
||||
function getQualityItemGroupId(qualityProfile) {
|
||||
// Get items with an `id` and filter out null/undefined values
|
||||
const ids = _.filter(_.map(qualityProfile.items.value, 'id'), (i) => i != null);
|
||||
|
||||
return Math.max(1000, ...ids) + 1;
|
||||
}
|
||||
|
||||
function parseIndex(index) {
|
||||
const split = index.split('.');
|
||||
|
||||
if (split.length === 1) {
|
||||
return [
|
||||
null,
|
||||
parseInt(split[0]) - 1
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
parseInt(split[0]) - 1,
|
||||
parseInt(split[1]) - 1
|
||||
];
|
||||
}
|
||||
|
||||
function createQualitiesSelector() {
|
||||
return createSelector(
|
||||
createProviderSettingsSelector('qualityProfiles'),
|
||||
(qualityProfile) => {
|
||||
const items = qualityProfile.item.items;
|
||||
if (!items || !items.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _.reduceRight(items.value, (result, { allowed, id, name, quality }) => {
|
||||
if (allowed) {
|
||||
if (id) {
|
||||
result.push({
|
||||
key: id,
|
||||
value: name
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
key: quality.id,
|
||||
value: quality.name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createFormatsSelector() {
|
||||
return createSelector(
|
||||
createProviderSettingsSelector('qualityProfiles'),
|
||||
(customFormat) => {
|
||||
const items = customFormat.item.formatItems;
|
||||
if (!items || !items.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _.reduceRight(items.value, (result, { id, name, format, score }) => {
|
||||
if (id) {
|
||||
result.push({
|
||||
key: id,
|
||||
value: name,
|
||||
score
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
key: format,
|
||||
value: name,
|
||||
score
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createProviderSettingsSelector('qualityProfiles'),
|
||||
createQualitiesSelector(),
|
||||
createFormatsSelector(),
|
||||
createProfileInUseSelector('qualityProfileId'),
|
||||
(qualityProfile, qualities, customFormats, isInUse) => {
|
||||
return {
|
||||
qualities,
|
||||
customFormats,
|
||||
...qualityProfile,
|
||||
isInUse
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchQualityProfileSchema,
|
||||
setQualityProfileValue,
|
||||
saveQualityProfile
|
||||
};
|
||||
|
||||
class EditQualityProfileModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
dragQualityIndex: null,
|
||||
dropQualityIndex: null,
|
||||
dropPosition: null,
|
||||
mode: 'default' // default, editGroups, editSizes
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.id && !this.props.isPopulated) {
|
||||
this.props.fetchQualityProfileSchema();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
|
||||
this.props.onModalClose();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
ensureCutoff = (qualityProfile) => {
|
||||
const cutoff = qualityProfile.cutoff.value;
|
||||
|
||||
const cutoffItem = _.find(qualityProfile.items.value, (i) => {
|
||||
if (!cutoff) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return i.id === cutoff || (i.quality && i.quality.id === cutoff);
|
||||
});
|
||||
|
||||
// If the cutoff isn't allowed anymore or there isn't a cutoff set one
|
||||
if (!cutoff || !cutoffItem || !cutoffItem.allowed) {
|
||||
const firstAllowed = _.find(qualityProfile.items.value, { allowed: true });
|
||||
let cutoffId = null;
|
||||
|
||||
if (firstAllowed) {
|
||||
cutoffId = firstAllowed.quality ? firstAllowed.quality.id : firstAllowed.id;
|
||||
}
|
||||
|
||||
this.props.setQualityProfileValue({ name: 'cutoff', value: cutoffId });
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.props.setQualityProfileValue({ name, value });
|
||||
};
|
||||
|
||||
onCutoffChange = ({ name, value }) => {
|
||||
const id = parseInt(value);
|
||||
const item = _.find(this.props.item.items.value, (i) => {
|
||||
if (i.quality) {
|
||||
return i.quality.id === id;
|
||||
}
|
||||
|
||||
return i.id === id;
|
||||
});
|
||||
|
||||
const cutoffId = item.quality ? item.quality.id : item.id;
|
||||
|
||||
this.props.setQualityProfileValue({ name, value: cutoffId });
|
||||
};
|
||||
|
||||
onSavePress = () => {
|
||||
this.props.saveQualityProfile({ id: this.props.id });
|
||||
};
|
||||
|
||||
onQualityProfileItemAllowedChange = (id, allowed) => {
|
||||
const qualityProfile = _.cloneDeep(this.props.item);
|
||||
const items = qualityProfile.items.value;
|
||||
const item = _.find(qualityProfile.items.value, (i) => i.quality && i.quality.id === id);
|
||||
|
||||
item.allowed = allowed;
|
||||
|
||||
this.props.setQualityProfileValue({
|
||||
name: 'items',
|
||||
value: items
|
||||
});
|
||||
|
||||
this.ensureCutoff(qualityProfile);
|
||||
};
|
||||
|
||||
onQualityProfileFormatItemScoreChange = (id, score) => {
|
||||
const qualityProfile = _.cloneDeep(this.props.item);
|
||||
const formatItems = qualityProfile.formatItems.value;
|
||||
const item = _.find(qualityProfile.formatItems.value, (i) => i.format === id);
|
||||
|
||||
item.score = score;
|
||||
|
||||
this.props.setQualityProfileValue({
|
||||
name: 'formatItems',
|
||||
value: formatItems
|
||||
});
|
||||
};
|
||||
|
||||
onItemGroupAllowedChange = (id, allowed) => {
|
||||
const qualityProfile = _.cloneDeep(this.props.item);
|
||||
const items = qualityProfile.items.value;
|
||||
const item = _.find(qualityProfile.items.value, (i) => i.id === id);
|
||||
|
||||
item.allowed = allowed;
|
||||
|
||||
// Update each item in the group (for consistency only)
|
||||
item.items.forEach((i) => {
|
||||
i.allowed = allowed;
|
||||
});
|
||||
|
||||
this.props.setQualityProfileValue({
|
||||
name: 'items',
|
||||
value: items
|
||||
});
|
||||
|
||||
this.ensureCutoff(qualityProfile);
|
||||
};
|
||||
|
||||
onItemGroupNameChange = (id, name) => {
|
||||
const qualityProfile = _.cloneDeep(this.props.item);
|
||||
const items = qualityProfile.items.value;
|
||||
const group = _.find(items, (i) => i.id === id);
|
||||
|
||||
group.name = name;
|
||||
|
||||
this.props.setQualityProfileValue({
|
||||
name: 'items',
|
||||
value: items
|
||||
});
|
||||
};
|
||||
|
||||
onSizeChange = ({ id, minSize, maxSize, preferredSize }) => {
|
||||
const qualityProfile = _.cloneDeep(this.props.item);
|
||||
const items = qualityProfile.items.value;
|
||||
let quality = null;
|
||||
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const index in items) {
|
||||
const item = items[index];
|
||||
|
||||
if (item.quality?.id === id) {
|
||||
quality = item;
|
||||
break;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const i in item.items) {
|
||||
const nestedItem = items[i];
|
||||
|
||||
if (nestedItem.quality?.id === id) {
|
||||
quality = nestedItem;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (quality) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!quality) {
|
||||
return;
|
||||
}
|
||||
|
||||
quality.minSize = minSize;
|
||||
quality.maxSize = maxSize;
|
||||
quality.preferredSize = preferredSize;
|
||||
|
||||
this.props.setQualityProfileValue({
|
||||
name: 'items',
|
||||
value: items
|
||||
});
|
||||
};
|
||||
|
||||
onCreateGroupPress = (id) => {
|
||||
const qualityProfile = _.cloneDeep(this.props.item);
|
||||
const items = qualityProfile.items.value;
|
||||
const item = _.find(items, (i) => i.quality && i.quality.id === id);
|
||||
const index = items.indexOf(item);
|
||||
const groupId = getQualityItemGroupId(qualityProfile);
|
||||
|
||||
const group = {
|
||||
id: groupId,
|
||||
name: item.quality.name,
|
||||
allowed: item.allowed,
|
||||
items: [
|
||||
item
|
||||
]
|
||||
};
|
||||
|
||||
// Add the group in the same location the quality item was in.
|
||||
items.splice(index, 1, group);
|
||||
|
||||
this.props.setQualityProfileValue({
|
||||
name: 'items',
|
||||
value: items
|
||||
});
|
||||
|
||||
this.ensureCutoff(qualityProfile);
|
||||
};
|
||||
|
||||
onDeleteGroupPress = (id) => {
|
||||
const qualityProfile = _.cloneDeep(this.props.item);
|
||||
const items = qualityProfile.items.value;
|
||||
const group = _.find(items, (i) => i.id === id);
|
||||
const index = items.indexOf(group);
|
||||
|
||||
// Add the items in the same location the group was in
|
||||
items.splice(index, 1, ...group.items);
|
||||
|
||||
this.props.setQualityProfileValue({
|
||||
name: 'items',
|
||||
value: items
|
||||
});
|
||||
|
||||
this.ensureCutoff(qualityProfile);
|
||||
};
|
||||
|
||||
onQualityProfileItemDragMove = (options) => {
|
||||
const {
|
||||
dragQualityIndex,
|
||||
dropQualityIndex,
|
||||
dropPosition
|
||||
} = options;
|
||||
|
||||
const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
|
||||
const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
|
||||
|
||||
if (
|
||||
(dropPosition === 'below' && dropItemIndex - 1 === dragItemIndex) ||
|
||||
(dropPosition === 'above' && dropItemIndex + 1 === dragItemIndex)
|
||||
) {
|
||||
if (
|
||||
this.state.dragQualityIndex != null &&
|
||||
this.state.dropQualityIndex != null &&
|
||||
this.state.dropPosition != null
|
||||
) {
|
||||
this.setState({
|
||||
dragQualityIndex: null,
|
||||
dropQualityIndex: null,
|
||||
dropPosition: null
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let adjustedDropQualityIndex = dropQualityIndex;
|
||||
|
||||
// Correct dragging out of a group to the position above
|
||||
if (
|
||||
dropPosition === 'above' &&
|
||||
dragGroupIndex !== dropGroupIndex &&
|
||||
dropGroupIndex != null
|
||||
) {
|
||||
// Add 1 to the group index and 2 to the item index so it's inserted above in the correct group
|
||||
adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex + 2}`;
|
||||
}
|
||||
|
||||
// Correct inserting above outside a group
|
||||
if (
|
||||
dropPosition === 'above' &&
|
||||
dragGroupIndex !== dropGroupIndex &&
|
||||
dropGroupIndex == null
|
||||
) {
|
||||
// Add 2 to the item index so it's entered in the correct place
|
||||
adjustedDropQualityIndex = `${dropItemIndex + 2}`;
|
||||
}
|
||||
|
||||
// Correct inserting below a quality within the same group (when moving a lower item)
|
||||
if (
|
||||
dropPosition === 'below' &&
|
||||
dragGroupIndex === dropGroupIndex &&
|
||||
dropGroupIndex != null &&
|
||||
dragItemIndex < dropItemIndex
|
||||
) {
|
||||
// Add 1 to the group index leave the item index
|
||||
adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex}`;
|
||||
}
|
||||
|
||||
// Correct inserting below a quality outside a group (when moving a lower item)
|
||||
if (
|
||||
dropPosition === 'below' &&
|
||||
dragGroupIndex === dropGroupIndex &&
|
||||
dropGroupIndex == null &&
|
||||
dragItemIndex < dropItemIndex
|
||||
) {
|
||||
// Leave the item index so it's inserted below the item
|
||||
adjustedDropQualityIndex = `${dropItemIndex}`;
|
||||
}
|
||||
|
||||
if (
|
||||
dragQualityIndex !== this.state.dragQualityIndex ||
|
||||
adjustedDropQualityIndex !== this.state.dropQualityIndex ||
|
||||
dropPosition !== this.state.dropPosition
|
||||
) {
|
||||
this.setState({
|
||||
dragQualityIndex,
|
||||
dropQualityIndex: adjustedDropQualityIndex,
|
||||
dropPosition
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onQualityProfileItemDragEnd = (didDrop) => {
|
||||
const {
|
||||
dragQualityIndex,
|
||||
dropQualityIndex
|
||||
} = this.state;
|
||||
|
||||
if (didDrop && dropQualityIndex != null) {
|
||||
const qualityProfile = _.cloneDeep(this.props.item);
|
||||
const items = qualityProfile.items.value;
|
||||
const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
|
||||
const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
|
||||
|
||||
let item = null;
|
||||
let dropGroup = null;
|
||||
|
||||
// Get the group before moving anything so we know the correct place to drop it.
|
||||
if (dropGroupIndex != null) {
|
||||
dropGroup = items[dropGroupIndex];
|
||||
}
|
||||
|
||||
if (dragGroupIndex == null) {
|
||||
item = items.splice(dragItemIndex, 1)[0];
|
||||
} else {
|
||||
const group = items[dragGroupIndex];
|
||||
item = group.items.splice(dragItemIndex, 1)[0];
|
||||
|
||||
// If the group is now empty, destroy it.
|
||||
if (!group.items.length) {
|
||||
items.splice(dragGroupIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (dropGroupIndex == null) {
|
||||
items.splice(dropItemIndex, 0, item);
|
||||
} else {
|
||||
dropGroup.items.splice(dropItemIndex, 0, item);
|
||||
}
|
||||
|
||||
this.props.setQualityProfileValue({
|
||||
name: 'items',
|
||||
value: items
|
||||
});
|
||||
|
||||
this.ensureCutoff(qualityProfile);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
dragQualityIndex: null,
|
||||
dropQualityIndex: null,
|
||||
dropPosition: null
|
||||
});
|
||||
};
|
||||
|
||||
onChangeMode = (mode) => {
|
||||
this.setState({ mode });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
if (_.isEmpty(this.props.item.items) && !this.props.isFetching) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EditQualityProfileModalContent
|
||||
{...this.state}
|
||||
{...this.props}
|
||||
onSavePress={this.onSavePress}
|
||||
onInputChange={this.onInputChange}
|
||||
onCutoffChange={this.onCutoffChange}
|
||||
onCreateGroupPress={this.onCreateGroupPress}
|
||||
onDeleteGroupPress={this.onDeleteGroupPress}
|
||||
onQualityProfileItemAllowedChange={this.onQualityProfileItemAllowedChange}
|
||||
onItemGroupAllowedChange={this.onItemGroupAllowedChange}
|
||||
onItemGroupNameChange={this.onItemGroupNameChange}
|
||||
onQualityProfileItemDragMove={this.onQualityProfileItemDragMove}
|
||||
onQualityProfileItemDragEnd={this.onQualityProfileItemDragEnd}
|
||||
onQualityProfileFormatItemScoreChange={this.onQualityProfileFormatItemScoreChange}
|
||||
onChangeMode={this.onChangeMode}
|
||||
onSizeChange={this.onSizeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditQualityProfileModalContentConnector.propTypes = {
|
||||
id: PropTypes.number,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.object.isRequired,
|
||||
setQualityProfileValue: PropTypes.func.isRequired,
|
||||
fetchQualityProfileSchema: PropTypes.func.isRequired,
|
||||
saveQualityProfile: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(EditQualityProfileModalContentConnector);
|
|
@ -1,187 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
|
||||
import styles from './QualityProfile.css';
|
||||
|
||||
class QualityProfile extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isEditQualityProfileModalOpen: false,
|
||||
isDeleteQualityProfileModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onEditQualityProfilePress = () => {
|
||||
this.setState({ isEditQualityProfileModalOpen: true });
|
||||
};
|
||||
|
||||
onEditQualityProfileModalClose = () => {
|
||||
this.setState({ isEditQualityProfileModalOpen: false });
|
||||
};
|
||||
|
||||
onDeleteQualityProfilePress = () => {
|
||||
this.setState({
|
||||
isEditQualityProfileModalOpen: false,
|
||||
isDeleteQualityProfileModalOpen: true
|
||||
});
|
||||
};
|
||||
|
||||
onDeleteQualityProfileModalClose = () => {
|
||||
this.setState({ isDeleteQualityProfileModalOpen: false });
|
||||
};
|
||||
|
||||
onConfirmDeleteQualityProfile = () => {
|
||||
this.props.onConfirmDeleteQualityProfile(this.props.id);
|
||||
};
|
||||
|
||||
onCloneQualityProfilePress = () => {
|
||||
const {
|
||||
id,
|
||||
onCloneQualityProfilePress
|
||||
} = this.props;
|
||||
|
||||
onCloneQualityProfilePress(id);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
upgradeAllowed,
|
||||
cutoff,
|
||||
items,
|
||||
isDeleting
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={styles.qualityProfile}
|
||||
overlayContent={true}
|
||||
onPress={this.onEditQualityProfilePress}
|
||||
>
|
||||
<div className={styles.nameContainer}>
|
||||
<div className={styles.name}>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
className={styles.cloneButton}
|
||||
title={translate('CloneProfile')}
|
||||
name={icons.CLONE}
|
||||
onPress={this.onCloneQualityProfilePress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.qualities}>
|
||||
{
|
||||
items.map((item) => {
|
||||
if (!item.allowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.quality) {
|
||||
const isCutoff = upgradeAllowed && item.quality.id === cutoff;
|
||||
|
||||
return (
|
||||
<Label
|
||||
key={item.quality.id}
|
||||
kind={isCutoff ? kinds.INFO : kinds.DEFAULT}
|
||||
title={isCutoff ? translate('UpgradeUntilThisQualityIsMetOrExceeded') : null}
|
||||
>
|
||||
{item.quality.name}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
const isCutoff = upgradeAllowed && item.id === cutoff;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={item.id}
|
||||
className={styles.tooltipLabel}
|
||||
anchor={
|
||||
<Label
|
||||
kind={isCutoff ? kinds.INFO : kinds.DEFAULT}
|
||||
title={isCutoff ? translate('Cutoff') : null}
|
||||
>
|
||||
{item.name}
|
||||
</Label>
|
||||
}
|
||||
tooltip={
|
||||
<div>
|
||||
{
|
||||
item.items.map((groupItem) => {
|
||||
return (
|
||||
<Label
|
||||
key={groupItem.quality.id}
|
||||
kind={isCutoff ? kinds.INFO : kinds.DEFAULT}
|
||||
title={isCutoff ? translate('Cutoff') : null}
|
||||
>
|
||||
{groupItem.quality.name}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
kind={kinds.INVERSE}
|
||||
position={tooltipPositions.TOP}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<EditQualityProfileModalConnector
|
||||
id={id}
|
||||
isOpen={this.state.isEditQualityProfileModalOpen}
|
||||
onModalClose={this.onEditQualityProfileModalClose}
|
||||
onDeleteQualityProfilePress={this.onDeleteQualityProfilePress}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={this.state.isDeleteQualityProfileModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteQualityProfile')}
|
||||
message={translate('DeleteQualityProfileMessageText', { name })}
|
||||
confirmLabel={translate('Delete')}
|
||||
isSpinning={isDeleting}
|
||||
onConfirm={this.onConfirmDeleteQualityProfile}
|
||||
onCancel={this.onDeleteQualityProfileModalClose}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
QualityProfile.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
upgradeAllowed: PropTypes.bool.isRequired,
|
||||
cutoff: PropTypes.number.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isDeleting: PropTypes.bool.isRequired,
|
||||
onConfirmDeleteQualityProfile: PropTypes.func.isRequired,
|
||||
onCloneQualityProfilePress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default QualityProfile;
|
165
frontend/src/Settings/Profiles/Quality/QualityProfile.tsx
Normal file
165
frontend/src/Settings/Profiles/Quality/QualityProfile.tsx
Normal file
|
@ -0,0 +1,165 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import { deleteQualityProfile } from 'Store/Actions/settingsActions';
|
||||
import { QualityProfileItems } from 'typings/QualityProfile';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditQualityProfileModal from './EditQualityProfileModal';
|
||||
import styles from './QualityProfile.css';
|
||||
|
||||
interface QualityProfileProps {
|
||||
id: number;
|
||||
name: string;
|
||||
upgradeAllowed: boolean;
|
||||
cutoff: number;
|
||||
items: QualityProfileItems;
|
||||
|
||||
isDeleting: boolean;
|
||||
onCloneQualityProfilePress: (id: number) => void;
|
||||
}
|
||||
|
||||
function QualityProfile({
|
||||
id,
|
||||
name,
|
||||
upgradeAllowed,
|
||||
cutoff,
|
||||
items,
|
||||
isDeleting,
|
||||
onCloneQualityProfilePress,
|
||||
}: QualityProfileProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [isEditQualityProfileModalOpen, setIsEditQualityProfileModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const [isDeleteQualityProfileModalOpen, setIsDeleteQualityProfileModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleEditQualityProfilePress = useCallback(() => {
|
||||
setIsEditQualityProfileModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditQualityProfileModalClose = useCallback(() => {
|
||||
setIsEditQualityProfileModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleDeleteQualityProfilePress = useCallback(() => {
|
||||
setIsDeleteQualityProfileModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteQualityProfileModalClose = useCallback(() => {
|
||||
setIsDeleteQualityProfileModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDeleteQualityProfile = useCallback(() => {
|
||||
dispatch(deleteQualityProfile({ id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
const handleCloneQualityProfilePress = useCallback(() => {
|
||||
onCloneQualityProfilePress(id);
|
||||
}, [id, onCloneQualityProfilePress]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={styles.qualityProfile}
|
||||
overlayContent={true}
|
||||
onPress={handleEditQualityProfilePress}
|
||||
>
|
||||
<div className={styles.nameContainer}>
|
||||
<div className={styles.name}>{name}</div>
|
||||
|
||||
<IconButton
|
||||
className={styles.cloneButton}
|
||||
title={translate('CloneProfile')}
|
||||
name={icons.CLONE}
|
||||
onPress={handleCloneQualityProfilePress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.qualities}>
|
||||
{items.map((item) => {
|
||||
if (!item.allowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ('quality' in item) {
|
||||
const isCutoff = upgradeAllowed && item.quality.id === cutoff;
|
||||
|
||||
return (
|
||||
<Label
|
||||
key={item.quality.id}
|
||||
kind={isCutoff ? kinds.INFO : kinds.DEFAULT}
|
||||
title={
|
||||
isCutoff
|
||||
? translate('UpgradeUntilThisQualityIsMetOrExceeded')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.quality.name}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
const isCutoff = upgradeAllowed && item.id === cutoff;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={item.id}
|
||||
className={styles.tooltipLabel}
|
||||
anchor={
|
||||
<Label
|
||||
kind={isCutoff ? kinds.INFO : kinds.DEFAULT}
|
||||
title={isCutoff ? translate('Cutoff') : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</Label>
|
||||
}
|
||||
tooltip={
|
||||
<div>
|
||||
{item.items.map((groupItem) => {
|
||||
return (
|
||||
<Label
|
||||
key={groupItem.quality.id}
|
||||
kind={isCutoff ? kinds.INFO : kinds.DEFAULT}
|
||||
title={isCutoff ? translate('Cutoff') : undefined}
|
||||
>
|
||||
{groupItem.quality.name}
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
kind={kinds.INVERSE}
|
||||
position={tooltipPositions.TOP}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<EditQualityProfileModal
|
||||
id={id}
|
||||
isOpen={isEditQualityProfileModalOpen}
|
||||
onModalClose={handleEditQualityProfileModalClose}
|
||||
onDeleteQualityProfilePress={handleDeleteQualityProfilePress}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteQualityProfileModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteQualityProfile')}
|
||||
message={translate('DeleteQualityProfileMessageText', { name })}
|
||||
confirmLabel={translate('Delete')}
|
||||
isSpinning={isDeleting}
|
||||
onConfirm={handleConfirmDeleteQualityProfile}
|
||||
onCancel={handleDeleteQualityProfileModalClose}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default QualityProfile;
|
|
@ -1,68 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import NumberInput from 'Components/Form/NumberInput';
|
||||
import styles from './QualityProfileFormatItem.css';
|
||||
|
||||
class QualityProfileFormatItem extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onScoreChange = ({ value }) => {
|
||||
const {
|
||||
formatId
|
||||
} = this.props;
|
||||
|
||||
this.props.onScoreChange(formatId, value);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
name,
|
||||
score
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.qualityProfileFormatItemContainer}
|
||||
>
|
||||
<div
|
||||
className={styles.qualityProfileFormatItem}
|
||||
>
|
||||
<label
|
||||
className={styles.formatNameContainer}
|
||||
>
|
||||
<div className={styles.formatName}>
|
||||
{name}
|
||||
</div>
|
||||
<NumberInput
|
||||
containerClassName={styles.scoreContainer}
|
||||
className={styles.scoreInput}
|
||||
name={name}
|
||||
value={score}
|
||||
onChange={this.onScoreChange}
|
||||
/>
|
||||
</label>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
QualityProfileFormatItem.propTypes = {
|
||||
formatId: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
score: PropTypes.number.isRequired,
|
||||
onScoreChange: PropTypes.func
|
||||
};
|
||||
|
||||
QualityProfileFormatItem.defaultProps = {
|
||||
// To handle the case score is deleted during edit
|
||||
score: 0
|
||||
};
|
||||
|
||||
export default QualityProfileFormatItem;
|
|
@ -0,0 +1,45 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import NumberInput from 'Components/Form/NumberInput';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import styles from './QualityProfileFormatItem.css';
|
||||
|
||||
interface QualityProfileFormatItemProps {
|
||||
formatId: number;
|
||||
name: string;
|
||||
score?: number;
|
||||
onScoreChange: (formatId: number, score: number) => void;
|
||||
}
|
||||
|
||||
function QualityProfileFormatItem({
|
||||
formatId,
|
||||
name,
|
||||
score = 0,
|
||||
onScoreChange,
|
||||
}: QualityProfileFormatItemProps) {
|
||||
const handleScoreChange = useCallback(
|
||||
({ value }: InputChanged<number>) => {
|
||||
onScoreChange(formatId, value);
|
||||
},
|
||||
[formatId, onScoreChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.qualityProfileFormatItemContainer}>
|
||||
<div className={styles.qualityProfileFormatItem}>
|
||||
<label className={styles.formatNameContainer}>
|
||||
<div className={styles.formatName}>{name}</div>
|
||||
<NumberInput
|
||||
containerClassName={styles.scoreContainer}
|
||||
className={styles.scoreInput}
|
||||
name={name}
|
||||
value={score}
|
||||
// @ts-expect-error - mismatched types
|
||||
onChange={handleScoreChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QualityProfileFormatItem;
|
|
@ -1,158 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
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 InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import QualityProfileFormatItem from './QualityProfileFormatItem';
|
||||
import styles from './QualityProfileFormatItems.css';
|
||||
|
||||
function calcOrder(profileFormatItems) {
|
||||
const items = profileFormatItems.reduce((acc, cur, index) => {
|
||||
acc[cur.format] = index;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return [...profileFormatItems].sort((a, b) => {
|
||||
if (b.score !== a.score) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name, undefined, { numeric: true });
|
||||
}).map((x) => items[x.format]);
|
||||
}
|
||||
|
||||
class QualityProfileFormatItems extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
order: calcOrder(this.props.profileFormatItems)
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onScoreChange = (formatId, value) => {
|
||||
const {
|
||||
onQualityProfileFormatItemScoreChange
|
||||
} = this.props;
|
||||
|
||||
onQualityProfileFormatItemScoreChange(formatId, value);
|
||||
this.reorderItems();
|
||||
};
|
||||
|
||||
reorderItems = _.debounce(() => this.setState({ order: calcOrder(this.props.profileFormatItems) }), 1000);
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
profileFormatItems,
|
||||
errors,
|
||||
warnings
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
order
|
||||
} = this.state;
|
||||
|
||||
if (profileFormatItems.length < 1) {
|
||||
return (
|
||||
<InlineMarkdown className={styles.addCustomFormatMessage} data={translate('WantMoreControlAddACustomFormat')} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>
|
||||
{translate('CustomFormats')}
|
||||
</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormInputHelpText
|
||||
text={translate('CustomFormatHelpText')}
|
||||
/>
|
||||
|
||||
{
|
||||
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}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
<div className={styles.formats}>
|
||||
<div className={styles.headerContainer}>
|
||||
<div className={styles.headerTitle}>
|
||||
{translate('CustomFormat')}
|
||||
</div>
|
||||
<div className={styles.headerScore}>
|
||||
{translate('Score')}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
order.map((index) => {
|
||||
const {
|
||||
format,
|
||||
name,
|
||||
score
|
||||
} = profileFormatItems[index];
|
||||
return (
|
||||
<QualityProfileFormatItem
|
||||
key={format}
|
||||
formatId={format}
|
||||
name={name}
|
||||
score={score}
|
||||
onScoreChange={this.onScoreChange}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
QualityProfileFormatItems.propTypes = {
|
||||
profileFormatItems: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
errors: PropTypes.arrayOf(PropTypes.object),
|
||||
warnings: PropTypes.arrayOf(PropTypes.object),
|
||||
onQualityProfileFormatItemScoreChange: PropTypes.func
|
||||
};
|
||||
|
||||
QualityProfileFormatItems.defaultProps = {
|
||||
errors: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
export default QualityProfileFormatItems;
|
|
@ -0,0 +1,113 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputHelpText from 'Components/Form/FormInputHelpText';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import { QualityProfileFormatItem as QualityProfileFormatItemModel } from 'typings/CustomFormat';
|
||||
import { Failure } from 'typings/pending';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import QualityProfileFormatItem from './QualityProfileFormatItem';
|
||||
import styles from './QualityProfileFormatItems.css';
|
||||
|
||||
interface QualityProfileFormatItemsProps {
|
||||
profileFormatItems: QualityProfileFormatItemModel[];
|
||||
errors?: Failure[];
|
||||
warnings?: Failure[];
|
||||
onQualityProfileFormatItemScoreChange: (
|
||||
formatId: number,
|
||||
score: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
function QualityProfileFormatItems({
|
||||
profileFormatItems,
|
||||
errors = [],
|
||||
warnings = [],
|
||||
onQualityProfileFormatItemScoreChange,
|
||||
}: QualityProfileFormatItemsProps) {
|
||||
const order = useMemo(() => {
|
||||
const items = profileFormatItems.reduce<Record<number, number>>(
|
||||
(acc, cur, index) => {
|
||||
acc[cur.format] = index;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return [...profileFormatItems]
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name, undefined, { numeric: true });
|
||||
})
|
||||
.map((x) => items[x.format]);
|
||||
}, [profileFormatItems]);
|
||||
|
||||
if (profileFormatItems.length < 1) {
|
||||
return (
|
||||
<InlineMarkdown
|
||||
className={styles.addCustomFormatMessage}
|
||||
data={translate('WantMoreControlAddACustomFormat')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>{translate('CustomFormats')}</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormInputHelpText text={translate('CustomFormatHelpText')} />
|
||||
|
||||
{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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className={styles.formats}>
|
||||
<div className={styles.headerContainer}>
|
||||
<div className={styles.headerTitle}>
|
||||
{translate('CustomFormat')}
|
||||
</div>
|
||||
<div className={styles.headerScore}>{translate('Score')}</div>
|
||||
</div>
|
||||
|
||||
{order.map((index) => {
|
||||
const { format, name, score } = profileFormatItems[index];
|
||||
return (
|
||||
<QualityProfileFormatItem
|
||||
key={format}
|
||||
formatId={format}
|
||||
name={name}
|
||||
score={score}
|
||||
onScoreChange={onQualityProfileFormatItemScoreChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default QualityProfileFormatItems;
|
|
@ -1,162 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import QualityProfileItemSize from './QualityProfileItemSize';
|
||||
import styles from './QualityProfileItem.css';
|
||||
|
||||
class QualityProfileItem extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onAllowedChange = ({ value }) => {
|
||||
const {
|
||||
qualityId,
|
||||
onQualityProfileItemAllowedChange
|
||||
} = this.props;
|
||||
|
||||
onQualityProfileItemAllowedChange(qualityId, value);
|
||||
};
|
||||
|
||||
onCreateGroupPress = () => {
|
||||
const {
|
||||
qualityId,
|
||||
onCreateGroupPress
|
||||
} = this.props;
|
||||
|
||||
onCreateGroupPress(qualityId);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
mode,
|
||||
isPreview,
|
||||
qualityId,
|
||||
groupId,
|
||||
name,
|
||||
allowed,
|
||||
minSize,
|
||||
maxSize,
|
||||
preferredSize,
|
||||
isDragging,
|
||||
isOverCurrent,
|
||||
connectDragSource,
|
||||
onSizeChange
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.qualityProfileItem,
|
||||
mode === 'editSizes' && styles.editSizes,
|
||||
isDragging && styles.isDragging,
|
||||
isPreview && styles.isPreview,
|
||||
isOverCurrent && styles.isOverCurrent,
|
||||
groupId && styles.isInGroup
|
||||
)}
|
||||
>
|
||||
<label
|
||||
className={classNames(
|
||||
styles.qualityNameContainer,
|
||||
mode === 'editSizes' && styles.editSizes
|
||||
)}
|
||||
>
|
||||
{
|
||||
mode === 'editGroups' && !groupId && !isPreview &&
|
||||
<IconButton
|
||||
className={styles.createGroupButton}
|
||||
name={icons.GROUP}
|
||||
title={translate('Group')}
|
||||
onPress={this.onCreateGroupPress}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
mode === 'default' &&
|
||||
<CheckInput
|
||||
className={styles.checkInput}
|
||||
containerClassName={styles.checkInputContainer}
|
||||
name={name}
|
||||
value={allowed}
|
||||
isDisabled={!!groupId}
|
||||
onChange={this.onAllowedChange}
|
||||
/>
|
||||
}
|
||||
|
||||
<div className={classNames(
|
||||
styles.qualityName,
|
||||
groupId && mode !== 'editSizes' && styles.isInGroup,
|
||||
!allowed && styles.notAllowed
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{
|
||||
mode === 'editSizes' && qualityId != null ?
|
||||
<div>
|
||||
<QualityProfileItemSize
|
||||
id={qualityId}
|
||||
minSize={minSize}
|
||||
maxSize={maxSize}
|
||||
preferredSize={preferredSize}
|
||||
onSizeChange={onSizeChange}
|
||||
/>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
mode === 'editSizes' ? null :
|
||||
connectDragSource(
|
||||
<div className={styles.dragHandle}>
|
||||
<Icon
|
||||
className={styles.dragIcon}
|
||||
title={translate('CreateGroup')}
|
||||
name={icons.REORDER}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
QualityProfileItem.propTypes = {
|
||||
mode: PropTypes.string.isRequired,
|
||||
isPreview: PropTypes.bool,
|
||||
groupId: PropTypes.number,
|
||||
qualityId: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
allowed: PropTypes.bool.isRequired,
|
||||
minSize: PropTypes.number,
|
||||
maxSize: PropTypes.number,
|
||||
preferredSize: PropTypes.number,
|
||||
isDragging: PropTypes.bool.isRequired,
|
||||
isOverCurrent: PropTypes.bool.isRequired,
|
||||
isInGroup: PropTypes.bool,
|
||||
connectDragSource: PropTypes.func,
|
||||
onCreateGroupPress: PropTypes.func,
|
||||
onQualityProfileItemAllowedChange: PropTypes.func,
|
||||
onSizeChange: PropTypes.func
|
||||
};
|
||||
|
||||
QualityProfileItem.defaultProps = {
|
||||
mode: 'default',
|
||||
isPreview: false,
|
||||
isOverCurrent: false,
|
||||
// The drag preview will not connect the drag handle.
|
||||
connectDragSource: (node) => node
|
||||
};
|
||||
|
||||
export default QualityProfileItem;
|
129
frontend/src/Settings/Profiles/Quality/QualityProfileItem.tsx
Normal file
129
frontend/src/Settings/Profiles/Quality/QualityProfileItem.tsx
Normal file
|
@ -0,0 +1,129 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useCallback } from 'react';
|
||||
import { ConnectDragSource } from 'react-dnd';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import QualityProfileItemSize, { SizeChanged } from './QualityProfileItemSize';
|
||||
import styles from './QualityProfileItem.css';
|
||||
|
||||
interface QualityProfileItemProps {
|
||||
dragRef: ConnectDragSource;
|
||||
mode: string;
|
||||
isPreview?: boolean;
|
||||
groupId?: number;
|
||||
qualityId: number;
|
||||
name: string;
|
||||
allowed: boolean;
|
||||
minSize: number | null;
|
||||
maxSize: number | null;
|
||||
preferredSize: number | null;
|
||||
isDragging: boolean;
|
||||
onCreateGroupPress?: (qualityId: number) => void;
|
||||
onItemAllowedChange: (qualityId: number, allowed: boolean) => void;
|
||||
onSizeChange: (change: SizeChanged) => void;
|
||||
}
|
||||
|
||||
function QualityProfileItem({
|
||||
dragRef,
|
||||
mode = 'default',
|
||||
isPreview = false,
|
||||
qualityId,
|
||||
groupId,
|
||||
name,
|
||||
allowed,
|
||||
minSize,
|
||||
maxSize,
|
||||
isDragging,
|
||||
preferredSize,
|
||||
onCreateGroupPress,
|
||||
onItemAllowedChange,
|
||||
onSizeChange,
|
||||
}: QualityProfileItemProps) {
|
||||
const handleAllowedChange = useCallback(
|
||||
({ value }: InputChanged<boolean>) => {
|
||||
onItemAllowedChange?.(qualityId, value);
|
||||
},
|
||||
[qualityId, onItemAllowedChange]
|
||||
);
|
||||
|
||||
const handleCreateGroupPress = useCallback(() => {
|
||||
onCreateGroupPress?.(qualityId);
|
||||
}, [qualityId, onCreateGroupPress]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.qualityProfileItem,
|
||||
mode === 'editSizes' && styles.editSizes,
|
||||
isDragging && styles.isDragging,
|
||||
isPreview && styles.isPreview,
|
||||
groupId && styles.isInGroup
|
||||
)}
|
||||
>
|
||||
<label
|
||||
className={classNames(
|
||||
styles.qualityNameContainer,
|
||||
mode === 'editSizes' && styles.editSizes
|
||||
)}
|
||||
>
|
||||
{mode === 'editGroups' && !groupId && !isPreview && (
|
||||
<IconButton
|
||||
className={styles.createGroupButton}
|
||||
name={icons.GROUP}
|
||||
title={translate('Group')}
|
||||
onPress={handleCreateGroupPress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'default' && (
|
||||
<CheckInput
|
||||
className={styles.checkInput}
|
||||
containerClassName={styles.checkInputContainer}
|
||||
name={name}
|
||||
value={allowed}
|
||||
isDisabled={!!groupId}
|
||||
onChange={handleAllowedChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
styles.qualityName,
|
||||
groupId && mode !== 'editSizes' && styles.isInGroup,
|
||||
!allowed && styles.notAllowed
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{mode === 'editSizes' && qualityId != null ? (
|
||||
<div>
|
||||
<QualityProfileItemSize
|
||||
id={qualityId}
|
||||
minSize={minSize}
|
||||
maxSize={maxSize}
|
||||
preferredSize={preferredSize}
|
||||
onSizeChange={onSizeChange}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === 'editSizes' ? null : (
|
||||
<div ref={dragRef} className={styles.dragHandle}>
|
||||
<Icon
|
||||
className={styles.dragIcon}
|
||||
title={translate('CreateGroup')}
|
||||
name={icons.REORDER}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QualityProfileItem;
|
|
@ -1,4 +0,0 @@
|
|||
.dragPreview {
|
||||
width: 380px;
|
||||
opacity: 0.75;
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'dragPreview': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
|
@ -1,92 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { DragLayer } from 'react-dnd';
|
||||
import DragPreviewLayer from 'Components/DragPreviewLayer';
|
||||
import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
|
||||
import dimensions from 'Styles/Variables/dimensions.js';
|
||||
import QualityProfileItem from './QualityProfileItem';
|
||||
import styles from './QualityProfileItemDragPreview.css';
|
||||
|
||||
const formGroupExtraSmallWidth = parseInt(dimensions.formGroupExtraSmallWidth);
|
||||
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 QualityProfileItemDragPreview extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
item,
|
||||
itemType,
|
||||
currentOffset
|
||||
} = this.props;
|
||||
|
||||
if (!currentOffset || itemType !== QUALITY_PROFILE_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 = formGroupExtraSmallWidth - formLabelSmallWidth - formLabelRightMarginWidth - dragHandleWidth;
|
||||
const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
|
||||
|
||||
const style = {
|
||||
position: 'absolute',
|
||||
WebkitTransform: transform,
|
||||
msTransform: transform,
|
||||
transform
|
||||
};
|
||||
|
||||
const {
|
||||
editGroups,
|
||||
groupId,
|
||||
qualityId,
|
||||
name,
|
||||
allowed
|
||||
} = item;
|
||||
|
||||
// TODO: Show a different preview for groups
|
||||
|
||||
return (
|
||||
<DragPreviewLayer>
|
||||
<div
|
||||
className={styles.dragPreview}
|
||||
style={style}
|
||||
>
|
||||
<QualityProfileItem
|
||||
editGroups={editGroups}
|
||||
isPreview={true}
|
||||
qualityId={groupId || qualityId}
|
||||
name={name}
|
||||
allowed={allowed}
|
||||
isDragging={false}
|
||||
/>
|
||||
</div>
|
||||
</DragPreviewLayer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
QualityProfileItemDragPreview.propTypes = {
|
||||
item: PropTypes.object,
|
||||
itemType: PropTypes.string,
|
||||
currentOffset: PropTypes.shape({
|
||||
x: PropTypes.number.isRequired,
|
||||
y: PropTypes.number.isRequired
|
||||
})
|
||||
};
|
||||
|
||||
export default DragLayer(collectDragLayer)(QualityProfileItemDragPreview);
|
|
@ -1,10 +1,10 @@
|
|||
.qualityProfileItemDragSource {
|
||||
padding: $qualityProfileItemDragSourcePadding 0;
|
||||
margin: $qualityProfileItemDragSourcePadding 0;
|
||||
}
|
||||
|
||||
.qualityProfileItemPlaceholder {
|
||||
width: 100%;
|
||||
height: $qualityProfileItemHeight;
|
||||
/* height: $qualityProfileItemHeight; */
|
||||
border: 1px dotted #aaa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
|
|
@ -1,254 +0,0 @@
|
|||
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 { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
|
||||
import QualityProfileItem from './QualityProfileItem';
|
||||
import QualityProfileItemGroup from './QualityProfileItemGroup';
|
||||
import styles from './QualityProfileItemDragSource.css';
|
||||
|
||||
const qualityProfileItemDragSource = {
|
||||
beginDrag(props) {
|
||||
const {
|
||||
mode,
|
||||
qualityIndex,
|
||||
groupId,
|
||||
qualityId,
|
||||
name,
|
||||
allowed
|
||||
} = props;
|
||||
|
||||
return {
|
||||
mode,
|
||||
qualityIndex,
|
||||
groupId,
|
||||
qualityId,
|
||||
isGroup: !qualityId,
|
||||
name,
|
||||
allowed
|
||||
};
|
||||
},
|
||||
|
||||
endDrag(props, monitor, component) {
|
||||
props.onQualityProfileItemDragEnd(monitor.didDrop());
|
||||
}
|
||||
};
|
||||
|
||||
const qualityProfileItemDropTarget = {
|
||||
hover(props, monitor, component) {
|
||||
const {
|
||||
qualityIndex: dragQualityIndex,
|
||||
isGroup: isDragGroup
|
||||
} = monitor.getItem();
|
||||
|
||||
const dropQualityIndex = props.qualityIndex;
|
||||
const isDropGroupItem = !!(props.qualityId && props.groupId);
|
||||
|
||||
// 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 (dragQualityIndex === dropQualityIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow a group to be dropped inside a group
|
||||
if (isDragGroup && isDropGroupItem) {
|
||||
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.onQualityProfileItemDragMove({
|
||||
dragQualityIndex,
|
||||
dropQualityIndex,
|
||||
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 QualityProfileItemDragSource extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
mode,
|
||||
groupId,
|
||||
qualityId,
|
||||
name,
|
||||
allowed,
|
||||
items,
|
||||
minSize,
|
||||
maxSize,
|
||||
preferredSize,
|
||||
qualityIndex,
|
||||
isDragging,
|
||||
isDraggingUp,
|
||||
isDraggingDown,
|
||||
isOverCurrent,
|
||||
connectDragSource,
|
||||
connectDropTarget,
|
||||
onCreateGroupPress,
|
||||
onDeleteGroupPress,
|
||||
onQualityProfileItemAllowedChange,
|
||||
onItemGroupAllowedChange,
|
||||
onItemGroupNameChange,
|
||||
onQualityProfileItemDragMove,
|
||||
onQualityProfileItemDragEnd,
|
||||
onSizeChange
|
||||
} = this.props;
|
||||
|
||||
const isBefore = !isDragging && isDraggingUp && isOverCurrent;
|
||||
const isAfter = !isDragging && isDraggingDown && isOverCurrent;
|
||||
|
||||
return connectDropTarget(
|
||||
<div
|
||||
className={classNames(
|
||||
styles.qualityProfileItemDragSource,
|
||||
isBefore && styles.isDraggingUp,
|
||||
isAfter && styles.isDraggingDown
|
||||
)}
|
||||
>
|
||||
{
|
||||
isBefore &&
|
||||
<div
|
||||
className={classNames(
|
||||
styles.qualityProfileItemPlaceholder,
|
||||
styles.qualityProfileItemPlaceholderBefore
|
||||
)}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!!groupId && qualityId == null &&
|
||||
<QualityProfileItemGroup
|
||||
mode={mode}
|
||||
groupId={groupId}
|
||||
name={name}
|
||||
allowed={allowed}
|
||||
items={items}
|
||||
qualityIndex={qualityIndex}
|
||||
isDragging={isDragging}
|
||||
isDraggingUp={isDraggingUp}
|
||||
isDraggingDown={isDraggingDown}
|
||||
connectDragSource={connectDragSource}
|
||||
onDeleteGroupPress={onDeleteGroupPress}
|
||||
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
|
||||
onItemGroupAllowedChange={onItemGroupAllowedChange}
|
||||
onItemGroupNameChange={onItemGroupNameChange}
|
||||
onQualityProfileItemDragMove={onQualityProfileItemDragMove}
|
||||
onQualityProfileItemDragEnd={onQualityProfileItemDragEnd}
|
||||
onSizeChange={onSizeChange}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
qualityId != null &&
|
||||
<QualityProfileItem
|
||||
mode={mode}
|
||||
groupId={groupId}
|
||||
qualityId={qualityId}
|
||||
name={name}
|
||||
allowed={allowed}
|
||||
minSize={minSize}
|
||||
maxSize={maxSize}
|
||||
preferredSize={preferredSize}
|
||||
qualityIndex={qualityIndex}
|
||||
isDragging={isDragging}
|
||||
isOverCurrent={isOverCurrent}
|
||||
connectDragSource={connectDragSource}
|
||||
onCreateGroupPress={onCreateGroupPress}
|
||||
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
|
||||
onSizeChange={onSizeChange}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
isAfter &&
|
||||
<div
|
||||
className={classNames(
|
||||
styles.qualityProfileItemPlaceholder,
|
||||
styles.qualityProfileItemPlaceholderAfter
|
||||
)}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
QualityProfileItemDragSource.propTypes = {
|
||||
mode: PropTypes.string.isRequired,
|
||||
groupId: PropTypes.number,
|
||||
qualityId: PropTypes.number,
|
||||
name: PropTypes.string.isRequired,
|
||||
allowed: PropTypes.bool.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object),
|
||||
minSize: PropTypes.number,
|
||||
maxSize: PropTypes.number,
|
||||
preferredSize: PropTypes.number,
|
||||
qualityIndex: PropTypes.string.isRequired,
|
||||
isDragging: PropTypes.bool,
|
||||
isDraggingUp: PropTypes.bool,
|
||||
isDraggingDown: PropTypes.bool,
|
||||
isOverCurrent: PropTypes.bool,
|
||||
isInGroup: PropTypes.bool,
|
||||
connectDragSource: PropTypes.func,
|
||||
connectDropTarget: PropTypes.func,
|
||||
onCreateGroupPress: PropTypes.func,
|
||||
onDeleteGroupPress: PropTypes.func,
|
||||
onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
|
||||
onItemGroupAllowedChange: PropTypes.func,
|
||||
onItemGroupNameChange: PropTypes.func,
|
||||
onQualityProfileItemDragMove: PropTypes.func.isRequired,
|
||||
onQualityProfileItemDragEnd: PropTypes.func.isRequired,
|
||||
onSizeChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default DropTarget(
|
||||
QUALITY_PROFILE_ITEM,
|
||||
qualityProfileItemDropTarget,
|
||||
collectDropTarget
|
||||
)(DragSource(
|
||||
QUALITY_PROFILE_ITEM,
|
||||
qualityProfileItemDragSource,
|
||||
collectDragSource
|
||||
)(QualityProfileItemDragSource));
|
|
@ -0,0 +1,251 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useRef } from 'react';
|
||||
import { DragSourceMonitor, useDrag, useDrop, XYCoord } from 'react-dnd';
|
||||
import DragType from 'Helpers/DragType';
|
||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||
import { qualityProfileItemHeight } from 'Styles/Variables/dimensions';
|
||||
import { QualityProfileQualityItem } from 'typings/QualityProfile';
|
||||
import QualityProfileItem from './QualityProfileItem';
|
||||
import QualityProfileItemGroup from './QualityProfileItemGroup';
|
||||
import { SizeChanged } from './QualityProfileItemSize';
|
||||
import styles from './QualityProfileItemDragSource.css';
|
||||
|
||||
export interface DragMoveState {
|
||||
dragQualityIndex: string | null;
|
||||
dropQualityIndex: string | null;
|
||||
dropPosition: 'above' | 'below' | null;
|
||||
}
|
||||
|
||||
interface DragItem {
|
||||
mode: string;
|
||||
qualityIndex: string;
|
||||
groupId: number | undefined;
|
||||
qualityId: number | undefined;
|
||||
isGroup: boolean;
|
||||
name: string;
|
||||
allowed: boolean;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
groupId: number | undefined;
|
||||
qualityId: number;
|
||||
name: string;
|
||||
minSize: number | null;
|
||||
maxSize: number | null;
|
||||
preferredSize: number | null;
|
||||
isInGroup?: boolean;
|
||||
onCreateGroupPress?: (qualityId: number) => void;
|
||||
onItemAllowedChange: (id: number, allowd: boolean) => void;
|
||||
}
|
||||
|
||||
interface GroupProps {
|
||||
groupId: number;
|
||||
qualityId: undefined;
|
||||
items: QualityProfileQualityItem[];
|
||||
qualityIndex: string;
|
||||
onDeleteGroupPress: (groupId: number) => void;
|
||||
onItemAllowedChange: (id: number, allowd: boolean) => void;
|
||||
onGroupAllowedChange: (id: number, allowd: boolean) => void;
|
||||
onItemGroupNameChange: (groupId: number, name: string) => void;
|
||||
}
|
||||
|
||||
interface CommonProps {
|
||||
mode: string;
|
||||
name: string;
|
||||
allowed: boolean;
|
||||
qualityIndex: string;
|
||||
isDraggingUp: boolean;
|
||||
isDraggingDown: boolean;
|
||||
onDragMove: (move: DragMoveState) => void;
|
||||
onDragEnd: (didDrop: boolean) => void;
|
||||
onSizeChange: (sizeChange: SizeChanged) => void;
|
||||
}
|
||||
|
||||
export type QualityProfileItemDragSourceProps = CommonProps &
|
||||
(ItemProps | GroupProps);
|
||||
|
||||
export interface QualityProfileItemDragSourceActionProps {
|
||||
onCreateGroupPress?: (qualityId: number) => void;
|
||||
onItemAllowedChange: (id: number, allowd: boolean) => void;
|
||||
onDeleteGroupPress: (groupId: number) => void;
|
||||
onGroupAllowedChange: (id: number, allowd: boolean) => void;
|
||||
onItemGroupNameChange: (groupId: number, name: string) => void;
|
||||
onDragMove: (move: DragMoveState) => void;
|
||||
onDragEnd: (didDrop: boolean) => void;
|
||||
onSizeChange: (sizeChange: SizeChanged) => void;
|
||||
}
|
||||
|
||||
function QualityProfileItemDragSource({
|
||||
mode,
|
||||
groupId,
|
||||
qualityId,
|
||||
name,
|
||||
allowed,
|
||||
qualityIndex,
|
||||
isDraggingDown,
|
||||
isDraggingUp,
|
||||
onDragMove,
|
||||
onDragEnd,
|
||||
...otherProps
|
||||
}: QualityProfileItemDragSourceProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [measureRef, { height }] = useMeasure();
|
||||
|
||||
const [{ isOver, dragHeight }, dropRef] = useDrop<
|
||||
DragItem,
|
||||
void,
|
||||
{ isOver: boolean; dragHeight: number }
|
||||
>({
|
||||
accept: DragType.QualityProfileItem,
|
||||
collect(monitor) {
|
||||
return {
|
||||
isOver: monitor.isOver(),
|
||||
dragHeight: monitor.getItem()?.height ?? qualityProfileItemHeight,
|
||||
};
|
||||
},
|
||||
hover(item: DragItem, monitor) {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { qualityIndex: dragQualityIndex, isGroup: isDragGroup } = item;
|
||||
|
||||
const dropQualityIndex = qualityIndex;
|
||||
const isDropGroupItem = !!(qualityId && groupId);
|
||||
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const hoverHeight = hoverBoundingRect.bottom - hoverBoundingRect.top;
|
||||
|
||||
// Smooth out updates when dragging down and the size grows to avoid flickering
|
||||
const hoverMiddleY = Math.max(hoverHeight - height, height) / 2;
|
||||
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
const hoverClientY = (clientOffset as XYCoord).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 (dragQualityIndex === dropQualityIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow a group to be dropped inside a group
|
||||
if (isDragGroup && isDropGroupItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dropPosition: 'above' | 'below' | null = null;
|
||||
|
||||
// Determine drop position based on position over target
|
||||
if (hoverClientY > hoverMiddleY) {
|
||||
dropPosition = 'below';
|
||||
} else if (hoverClientY < hoverMiddleY) {
|
||||
dropPosition = 'above';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
onDragMove({
|
||||
dragQualityIndex,
|
||||
dropQualityIndex,
|
||||
dropPosition,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [{ isDragging }, dragRef, previewRef] = useDrag<
|
||||
DragItem,
|
||||
unknown,
|
||||
{ isDragging: boolean }
|
||||
>({
|
||||
type: DragType.QualityProfileItem,
|
||||
item: () => {
|
||||
return {
|
||||
mode,
|
||||
qualityIndex,
|
||||
groupId,
|
||||
qualityId,
|
||||
isGroup: !qualityId,
|
||||
name,
|
||||
allowed,
|
||||
height,
|
||||
};
|
||||
},
|
||||
collect: (monitor: DragSourceMonitor<unknown, unknown>) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
end: (_item: DragItem, monitor) => {
|
||||
onDragEnd(monitor.didDrop());
|
||||
},
|
||||
});
|
||||
|
||||
dropRef(previewRef(ref));
|
||||
|
||||
const isBefore = !isDragging && isDraggingUp && isOver;
|
||||
const isAfter = !isDragging && isDraggingDown && isOver;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={classNames(styles.qualityProfileItemDragSource)}>
|
||||
{isBefore ? (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.qualityProfileItemPlaceholder,
|
||||
styles.qualityProfileItemPlaceholderBefore
|
||||
)}
|
||||
style={{
|
||||
height: dragHeight,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div ref={measureRef}>
|
||||
{'items' in otherProps && groupId ? (
|
||||
<QualityProfileItemGroup
|
||||
{...otherProps}
|
||||
dragRef={dragRef}
|
||||
mode={mode}
|
||||
groupId={groupId}
|
||||
name={name}
|
||||
allowed={allowed}
|
||||
qualityIndex={qualityIndex}
|
||||
isDragging={isDragging}
|
||||
isDraggingUp={isDraggingUp}
|
||||
isDraggingDown={isDraggingDown}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragMove={onDragMove}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!('items' in otherProps) && qualityId ? (
|
||||
<QualityProfileItem
|
||||
{...otherProps}
|
||||
dragRef={dragRef}
|
||||
mode={mode}
|
||||
qualityId={qualityId}
|
||||
name={name}
|
||||
allowed={allowed}
|
||||
isDragging={isDragging}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isAfter ? (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.qualityProfileItemPlaceholder,
|
||||
styles.qualityProfileItemPlaceholderAfter
|
||||
)}
|
||||
style={{
|
||||
height: dragHeight,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QualityProfileItemDragSource;
|
|
@ -1,228 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import QualityProfileItemDragSource from './QualityProfileItemDragSource';
|
||||
import styles from './QualityProfileItemGroup.css';
|
||||
|
||||
class QualityProfileItemGroup extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onAllowedChange = ({ value }) => {
|
||||
const {
|
||||
groupId,
|
||||
onItemGroupAllowedChange
|
||||
} = this.props;
|
||||
|
||||
onItemGroupAllowedChange(groupId, value);
|
||||
};
|
||||
|
||||
onNameChange = ({ value }) => {
|
||||
const {
|
||||
groupId,
|
||||
onItemGroupNameChange
|
||||
} = this.props;
|
||||
|
||||
onItemGroupNameChange(groupId, value);
|
||||
};
|
||||
|
||||
onDeleteGroupPress = ({ value }) => {
|
||||
const {
|
||||
groupId,
|
||||
onDeleteGroupPress
|
||||
} = this.props;
|
||||
|
||||
onDeleteGroupPress(groupId, value);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
mode,
|
||||
groupId,
|
||||
name,
|
||||
allowed,
|
||||
items,
|
||||
qualityIndex,
|
||||
isDragging,
|
||||
isDraggingUp,
|
||||
isDraggingDown,
|
||||
connectDragSource,
|
||||
onQualityProfileItemAllowedChange,
|
||||
onQualityProfileItemDragMove,
|
||||
onQualityProfileItemDragEnd,
|
||||
onSizeChange
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.qualityProfileItemGroup,
|
||||
mode === 'editGroups' && styles.editGroups,
|
||||
mode === 'editSizes' && styles.editSizes,
|
||||
isDragging && styles.isDragging
|
||||
)}
|
||||
>
|
||||
<div className={styles.qualityProfileItemGroupInfo}>
|
||||
{
|
||||
mode === 'editGroups' &&
|
||||
<div className={styles.qualityNameContainer}>
|
||||
<IconButton
|
||||
className={styles.deleteGroupButton}
|
||||
name={icons.UNGROUP}
|
||||
title={translate('Ungroup')}
|
||||
onPress={this.onDeleteGroupPress}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
className={styles.nameInput}
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={this.onNameChange}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
mode === 'default' &&
|
||||
<label
|
||||
className={styles.qualityNameLabel}
|
||||
>
|
||||
<CheckInput
|
||||
className={styles.checkInput}
|
||||
containerClassName={styles.checkInputContainer}
|
||||
name="allowed"
|
||||
value={allowed}
|
||||
onChange={this.onAllowedChange}
|
||||
/>
|
||||
|
||||
<div className={styles.nameContainer}>
|
||||
<div className={classNames(
|
||||
styles.name,
|
||||
!allowed && styles.notAllowed
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<div className={styles.groupQualities}>
|
||||
{
|
||||
items.map(({ quality }) => {
|
||||
return (
|
||||
<Label key={quality.id}>
|
||||
{quality.name}
|
||||
</Label>
|
||||
);
|
||||
}).reverse()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
|
||||
{
|
||||
mode === 'editSizes' &&
|
||||
<label
|
||||
className={styles.editSizesQualityNameLabel}
|
||||
>
|
||||
<div className={styles.nameContainer}>
|
||||
<div className={classNames(
|
||||
styles.name,
|
||||
!allowed && styles.notAllowed
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
|
||||
{
|
||||
mode === 'editSizes' ? null :
|
||||
connectDragSource(
|
||||
<div className={styles.dragHandle}>
|
||||
<Icon
|
||||
className={styles.dragIcon}
|
||||
name={icons.REORDER}
|
||||
title={translate('Reorder')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
mode === 'default' ?
|
||||
null :
|
||||
<div className={mode === 'editGroups' ? styles.items : undefined}>
|
||||
{
|
||||
items.map(({ quality }, index) => {
|
||||
return (
|
||||
<QualityProfileItemDragSource
|
||||
key={quality.id}
|
||||
mode={mode}
|
||||
groupId={groupId}
|
||||
qualityId={quality.id}
|
||||
name={quality.name}
|
||||
allowed={allowed}
|
||||
minSize={quality.minSize}
|
||||
maxSize={quality.maxSize}
|
||||
preferredSize={quality.preferredSize}
|
||||
items={items}
|
||||
qualityIndex={`${qualityIndex}.${index + 1}`}
|
||||
isDragging={isDragging}
|
||||
isDraggingUp={isDraggingUp}
|
||||
isDraggingDown={isDraggingDown}
|
||||
isInGroup={true}
|
||||
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
|
||||
onQualityProfileItemDragMove={onQualityProfileItemDragMove}
|
||||
onQualityProfileItemDragEnd={onQualityProfileItemDragEnd}
|
||||
onSizeChange={onSizeChange}
|
||||
/>
|
||||
);
|
||||
}).reverse()
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
QualityProfileItemGroup.propTypes = {
|
||||
mode: PropTypes.string.isRequired,
|
||||
groupId: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
allowed: PropTypes.bool.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
qualityIndex: PropTypes.string.isRequired,
|
||||
isDragging: PropTypes.bool.isRequired,
|
||||
isDraggingUp: PropTypes.bool.isRequired,
|
||||
isDraggingDown: PropTypes.bool.isRequired,
|
||||
connectDragSource: PropTypes.func,
|
||||
onItemGroupAllowedChange: PropTypes.func.isRequired,
|
||||
onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
|
||||
onItemGroupNameChange: PropTypes.func.isRequired,
|
||||
onDeleteGroupPress: PropTypes.func.isRequired,
|
||||
onQualityProfileItemDragMove: PropTypes.func.isRequired,
|
||||
onQualityProfileItemDragEnd: PropTypes.func.isRequired,
|
||||
onSizeChange: PropTypes.func
|
||||
};
|
||||
|
||||
QualityProfileItemGroup.defaultProps = {
|
||||
mode: 'default',
|
||||
// The drag preview will not connect the drag handle.
|
||||
connectDragSource: (node) => node
|
||||
};
|
||||
|
||||
export default QualityProfileItemGroup;
|
|
@ -0,0 +1,194 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useCallback } from 'react';
|
||||
import { ConnectDragSource } from 'react-dnd';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import { QualityProfileQualityItem } from 'typings/QualityProfile';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import QualityProfileItemDragSource, {
|
||||
DragMoveState,
|
||||
} from './QualityProfileItemDragSource';
|
||||
import { SizeChanged } from './QualityProfileItemSize';
|
||||
import styles from './QualityProfileItemGroup.css';
|
||||
|
||||
interface QualityProfileItemGroupProps {
|
||||
dragRef: ConnectDragSource;
|
||||
mode?: string;
|
||||
groupId: number;
|
||||
name: string;
|
||||
allowed: boolean;
|
||||
items: QualityProfileQualityItem[];
|
||||
qualityIndex: string;
|
||||
isDragging: boolean;
|
||||
isDraggingUp: boolean;
|
||||
isDraggingDown: boolean;
|
||||
onGroupAllowedChange: (groupId: number, allowed: boolean) => void;
|
||||
onItemAllowedChange: (groupId: number, allowed: boolean) => void;
|
||||
onItemGroupNameChange: (groupId: number, name: string) => void;
|
||||
onDeleteGroupPress: (groupId: number) => void;
|
||||
onDragMove: (drag: DragMoveState) => void;
|
||||
onDragEnd: (didDrop: boolean) => void;
|
||||
onSizeChange: (sizeChange: SizeChanged) => void;
|
||||
}
|
||||
|
||||
function QualityProfileItemGroup({
|
||||
dragRef,
|
||||
mode = 'default',
|
||||
groupId,
|
||||
name,
|
||||
allowed,
|
||||
items,
|
||||
qualityIndex,
|
||||
isDragging,
|
||||
isDraggingUp,
|
||||
isDraggingDown,
|
||||
onDeleteGroupPress,
|
||||
onGroupAllowedChange,
|
||||
onItemAllowedChange,
|
||||
onItemGroupNameChange,
|
||||
onDragMove,
|
||||
onDragEnd,
|
||||
onSizeChange,
|
||||
}: QualityProfileItemGroupProps) {
|
||||
const handleAllowedChange = useCallback(
|
||||
({ value }: InputChanged<boolean>) => {
|
||||
onGroupAllowedChange?.(groupId, value);
|
||||
},
|
||||
[groupId, onGroupAllowedChange]
|
||||
);
|
||||
|
||||
const handleNameChange = useCallback(
|
||||
({ value }: InputChanged<string>) => {
|
||||
onItemGroupNameChange?.(groupId, value);
|
||||
},
|
||||
[groupId, onItemGroupNameChange]
|
||||
);
|
||||
|
||||
const handleDeleteGroupPress = useCallback(() => {
|
||||
onDeleteGroupPress?.(groupId);
|
||||
}, [groupId, onDeleteGroupPress]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.qualityProfileItemGroup,
|
||||
mode === 'editGroups' && styles.editGroups,
|
||||
mode === 'editSizes' && styles.editSizes,
|
||||
isDragging && styles.isDragging
|
||||
)}
|
||||
>
|
||||
<div className={styles.qualityProfileItemGroupInfo}>
|
||||
{mode === 'editGroups' ? (
|
||||
<div className={styles.qualityNameContainer}>
|
||||
<IconButton
|
||||
className={styles.deleteGroupButton}
|
||||
name={icons.UNGROUP}
|
||||
title={translate('Ungroup')}
|
||||
onPress={handleDeleteGroupPress}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
className={styles.nameInput}
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === 'default' ? (
|
||||
<label className={styles.qualityNameLabel}>
|
||||
<CheckInput
|
||||
className={styles.checkInput}
|
||||
containerClassName={styles.checkInputContainer}
|
||||
name="allowed"
|
||||
value={allowed}
|
||||
onChange={handleAllowedChange}
|
||||
/>
|
||||
|
||||
<div className={styles.nameContainer}>
|
||||
<div
|
||||
className={classNames(
|
||||
styles.name,
|
||||
!allowed && styles.notAllowed
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<div className={styles.groupQualities}>
|
||||
{items
|
||||
.map(({ quality }) => {
|
||||
return <Label key={quality.id}>{quality.name}</Label>;
|
||||
})
|
||||
.reverse()}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{mode === 'editSizes' ? (
|
||||
<label className={styles.editSizesQualityNameLabel}>
|
||||
<div className={styles.nameContainer}>
|
||||
<div
|
||||
className={classNames(
|
||||
styles.name,
|
||||
!allowed && styles.notAllowed
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{mode === 'editSizes' ? null : (
|
||||
<div ref={dragRef} className={styles.dragHandle}>
|
||||
<Icon
|
||||
className={styles.dragIcon}
|
||||
name={icons.REORDER}
|
||||
title={translate('Reorder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode === 'default' ? null : (
|
||||
<div className={mode === 'editGroups' ? styles.items : undefined}>
|
||||
{items
|
||||
.map(({ quality }, index) => {
|
||||
return (
|
||||
<QualityProfileItemDragSource
|
||||
key={quality.id}
|
||||
mode={mode}
|
||||
groupId={groupId}
|
||||
qualityId={quality.id}
|
||||
name={quality.name}
|
||||
allowed={allowed}
|
||||
minSize={quality.minSize}
|
||||
maxSize={quality.maxSize}
|
||||
preferredSize={quality.preferredSize}
|
||||
qualityIndex={`${qualityIndex}.${index + 1}`}
|
||||
isDraggingUp={isDraggingUp}
|
||||
isDraggingDown={isDraggingDown}
|
||||
isInGroup={true}
|
||||
onItemAllowedChange={onItemAllowedChange}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onSizeChange={onSizeChange}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.reverse()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QualityProfileItemGroup;
|
|
@ -23,12 +23,16 @@ interface SizeProps {
|
|||
maxSize: number | null;
|
||||
}
|
||||
|
||||
export interface OnSizeChangeArguments extends SizeProps {
|
||||
id: number;
|
||||
export interface SizeChanged extends SizeProps {
|
||||
qualityId: number;
|
||||
}
|
||||
|
||||
export interface QualityProfileItemSizeProps extends OnSizeChangeArguments {
|
||||
onSizeChange: (props: OnSizeChangeArguments) => void;
|
||||
export interface QualityProfileItemSizeProps {
|
||||
id: number;
|
||||
minSize: number | null;
|
||||
preferredSize: number | null;
|
||||
maxSize: number | null;
|
||||
onSizeChange: (props: SizeChanged) => void;
|
||||
}
|
||||
|
||||
function trackRenderer(props: HTMLProps<HTMLDivElement>) {
|
||||
|
@ -45,10 +49,13 @@ function getSliderValue(value: number | null, defaultValue: number): number {
|
|||
return roundNumber(sliderValue);
|
||||
}
|
||||
|
||||
export default function QualityProfileItemSize(
|
||||
props: QualityProfileItemSizeProps
|
||||
) {
|
||||
const { id, minSize, maxSize, preferredSize, onSizeChange } = props;
|
||||
export default function QualityProfileItemSize({
|
||||
id,
|
||||
minSize,
|
||||
maxSize,
|
||||
preferredSize,
|
||||
onSizeChange,
|
||||
}: QualityProfileItemSizeProps) {
|
||||
const [sizes, setSizes] = useState<SizeProps>({
|
||||
minSize: getSliderValue(minSize, MIN),
|
||||
preferredSize: getSliderValue(preferredSize, SLIDER_MAX - MIN_DISTANCE),
|
||||
|
@ -61,21 +68,14 @@ export default function QualityProfileItemSize(
|
|||
number,
|
||||
number
|
||||
]) => {
|
||||
// console.log('Sizes:', sliderMinSize, sliderPreferredSize, sliderMaxSize);
|
||||
console.log(
|
||||
'Min Sizes: ',
|
||||
sliderMinSize,
|
||||
roundNumber(Math.pow(sliderMinSize, 1.1))
|
||||
);
|
||||
|
||||
setSizes({
|
||||
minSize: sliderMinSize,
|
||||
preferredSize: sliderPreferredSize,
|
||||
maxSize: sliderMaxSize,
|
||||
});
|
||||
|
||||
onSizeChange({
|
||||
id,
|
||||
onSizeChange?.({
|
||||
qualityId: id,
|
||||
minSize: roundNumber(Math.pow(sliderMinSize, 1.1)),
|
||||
preferredSize:
|
||||
sliderPreferredSize === MAX - MIN_DISTANCE
|
||||
|
@ -98,8 +98,8 @@ export default function QualityProfileItemSize(
|
|||
maxSize: sizes.maxSize,
|
||||
});
|
||||
|
||||
onSizeChange({
|
||||
id,
|
||||
onSizeChange?.({
|
||||
qualityId: id,
|
||||
minSize: value,
|
||||
preferredSize: sizes.preferredSize,
|
||||
maxSize: sizes.maxSize,
|
||||
|
@ -116,8 +116,8 @@ export default function QualityProfileItemSize(
|
|||
maxSize: sizes.maxSize,
|
||||
});
|
||||
|
||||
onSizeChange({
|
||||
id,
|
||||
onSizeChange?.({
|
||||
qualityId: id,
|
||||
minSize: sizes.minSize,
|
||||
preferredSize: value,
|
||||
maxSize: sizes.maxSize,
|
||||
|
@ -134,8 +134,8 @@ export default function QualityProfileItemSize(
|
|||
maxSize: value,
|
||||
});
|
||||
|
||||
onSizeChange({
|
||||
id,
|
||||
onSizeChange?.({
|
||||
qualityId: id,
|
||||
minSize: sizes.minSize,
|
||||
preferredSize: sizes.preferredSize,
|
||||
maxSize: value,
|
||||
|
|
|
@ -1,204 +0,0 @@
|
|||
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 Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import Measure from 'Components/Measure';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import QualityProfileItemDragPreview from './QualityProfileItemDragPreview';
|
||||
import QualityProfileItemDragSource from './QualityProfileItemDragSource';
|
||||
import styles from './QualityProfileItems.css';
|
||||
|
||||
class QualityProfileItems extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
defaultHeight: 0,
|
||||
editGroupsHeight: 0,
|
||||
editSizesHeight: 0
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMeasure = ({ height }) => {
|
||||
const heightKey = `${this.props.mode}Height`;
|
||||
|
||||
this.setState({
|
||||
[heightKey]: height
|
||||
});
|
||||
};
|
||||
|
||||
onEditGroupsPress = () => {
|
||||
this.props.onChangeMode('editGroups');
|
||||
};
|
||||
|
||||
onEditSizesPress = () => {
|
||||
this.props.onChangeMode('editSizes');
|
||||
};
|
||||
|
||||
onDefaultModePress = () => {
|
||||
this.props.onChangeMode('default');
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
mode,
|
||||
dropQualityIndex,
|
||||
dropPosition,
|
||||
qualityProfileItems,
|
||||
errors,
|
||||
warnings,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const isDragging = dropQualityIndex !== null;
|
||||
const isDraggingUp = isDragging && dropPosition === 'above';
|
||||
const isDraggingDown = isDragging && dropPosition === 'below';
|
||||
const height = this.state[`${mode}Height`];
|
||||
|
||||
return (
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>
|
||||
{translate('Qualities')}
|
||||
</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormInputHelpText
|
||||
text={translate('QualitiesHelpText')}
|
||||
/>
|
||||
|
||||
{
|
||||
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}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
<Button
|
||||
className={styles.editGroupsButton}
|
||||
kind={kinds.PRIMARY}
|
||||
onPress={mode === 'editGroups' ? this.onDefaultModePress : this.onEditGroupsPress}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
className={styles.editButtonIcon}
|
||||
name={mode === 'editGroups' ? icons.REORDER : icons.GROUP}
|
||||
/>
|
||||
|
||||
{
|
||||
mode === 'editGroups' ? translate('DoneEditingGroups') : translate('EditGroups')
|
||||
}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className={styles.editSizesButton}
|
||||
kind={kinds.PRIMARY}
|
||||
onPress={mode === 'editSizes' ? this.onDefaultModePress : this.onEditSizesPress}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
className={styles.editButtonIcon}
|
||||
name={mode === 'editSizes' ? icons.REORDER : icons.FILE}
|
||||
/>
|
||||
|
||||
{
|
||||
mode === 'editSizes' ? translate('DoneEditingSizes') : translate('EditSizes')
|
||||
}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Measure
|
||||
whitelist={['height']}
|
||||
includeMargin={false}
|
||||
onMeasure={this.onMeasure}
|
||||
>
|
||||
<div
|
||||
className={styles.qualities}
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
{
|
||||
qualityProfileItems.map(({ id, name, allowed, quality, items, minSize, maxSize, preferredSize }, index) => {
|
||||
const identifier = quality ? quality.id : id;
|
||||
|
||||
return (
|
||||
<QualityProfileItemDragSource
|
||||
key={identifier}
|
||||
mode={mode}
|
||||
groupId={id}
|
||||
qualityId={quality && quality.id}
|
||||
name={quality ? quality.name : name}
|
||||
allowed={allowed}
|
||||
items={items}
|
||||
minSize={minSize}
|
||||
maxSize={maxSize}
|
||||
preferredSize={preferredSize}
|
||||
qualityIndex={`${index + 1}`}
|
||||
isInGroup={false}
|
||||
isDragging={isDragging}
|
||||
isDraggingUp={isDraggingUp}
|
||||
isDraggingDown={isDraggingDown}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}).reverse()
|
||||
}
|
||||
|
||||
<QualityProfileItemDragPreview />
|
||||
</div>
|
||||
</Measure>
|
||||
</div>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
QualityProfileItems.propTypes = {
|
||||
mode: PropTypes.string.isRequired,
|
||||
dragQualityIndex: PropTypes.string,
|
||||
dropQualityIndex: PropTypes.string,
|
||||
dropPosition: PropTypes.string,
|
||||
qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
errors: PropTypes.arrayOf(PropTypes.object),
|
||||
warnings: PropTypes.arrayOf(PropTypes.object),
|
||||
onChangeMode: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
QualityProfileItems.defaultProps = {
|
||||
errors: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
export default QualityProfileItems;
|
209
frontend/src/Settings/Profiles/Quality/QualityProfileItems.tsx
Normal file
209
frontend/src/Settings/Profiles/Quality/QualityProfileItems.tsx
Normal file
|
@ -0,0 +1,209 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import useMeasure from 'react-use-measure';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputHelpText from 'Components/Form/FormInputHelpText';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import { Failure } from 'typings/pending';
|
||||
import { QualityProfileItems as Items } from 'typings/QualityProfile';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import QualityProfileItemDragSource, {
|
||||
QualityProfileItemDragSourceActionProps,
|
||||
} from './QualityProfileItemDragSource';
|
||||
import styles from './QualityProfileItems.css';
|
||||
|
||||
export type EditQualityProfileMode = 'default' | 'editGroups' | 'editSizes';
|
||||
|
||||
interface QualityProfileItemsProps
|
||||
extends QualityProfileItemDragSourceActionProps {
|
||||
mode: EditQualityProfileMode;
|
||||
dragQualityIndex: string | null;
|
||||
dropQualityIndex: string | null;
|
||||
dropPosition: string | null;
|
||||
qualityProfileItems: Items;
|
||||
errors?: Failure[];
|
||||
warnings?: Failure[];
|
||||
onChangeMode: (mode: EditQualityProfileMode) => void;
|
||||
}
|
||||
|
||||
function QualityProfileItems({
|
||||
mode,
|
||||
dropQualityIndex,
|
||||
dropPosition,
|
||||
qualityProfileItems,
|
||||
errors = [],
|
||||
warnings = [],
|
||||
onChangeMode,
|
||||
...otherProps
|
||||
}: QualityProfileItemsProps) {
|
||||
const [measureRef, { height: measuredHeight }] = useMeasure();
|
||||
const [defaultHeight, setDefaultHeight] = useState(0);
|
||||
const [editGroupsHeight, setEditGroupsHeight] = useState(0);
|
||||
const [editSizesHeight, setEditSizesHeight] = useState(0);
|
||||
|
||||
const isDragging = dropQualityIndex !== null;
|
||||
const isDraggingUp = isDragging && dropPosition === 'above';
|
||||
const isDraggingDown = isDragging && dropPosition === 'below';
|
||||
|
||||
const height = useMemo(() => {
|
||||
if (mode === 'default' && defaultHeight > 0) {
|
||||
return defaultHeight;
|
||||
} else if (mode === 'editGroups' && editGroupsHeight > 0) {
|
||||
return editGroupsHeight;
|
||||
} else if (mode === 'editSizes' && editSizesHeight > 0) {
|
||||
return editSizesHeight;
|
||||
}
|
||||
|
||||
return 'auto';
|
||||
}, [mode, defaultHeight, editGroupsHeight, editSizesHeight]);
|
||||
|
||||
const handleEditGroupsPress = useCallback(() => {
|
||||
onChangeMode('editGroups');
|
||||
}, [onChangeMode]);
|
||||
|
||||
const handleEditSizesPress = useCallback(() => {
|
||||
onChangeMode('editSizes');
|
||||
}, [onChangeMode]);
|
||||
|
||||
const handleDefaultModePress = useCallback(() => {
|
||||
onChangeMode('default');
|
||||
}, [onChangeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'default') {
|
||||
setDefaultHeight(measuredHeight);
|
||||
} else if (mode === 'editGroups') {
|
||||
setEditGroupsHeight(measuredHeight);
|
||||
} else if (mode === 'editSizes') {
|
||||
setEditSizesHeight(measuredHeight);
|
||||
}
|
||||
}, [mode, measuredHeight]);
|
||||
|
||||
return (
|
||||
<FormGroup size={sizes.EXTRA_SMALL}>
|
||||
<FormLabel size={sizes.SMALL}>{translate('Qualities')}</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormInputHelpText text={translate('QualitiesHelpText')} />
|
||||
|
||||
{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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
className={styles.editGroupsButton}
|
||||
kind={kinds.PRIMARY}
|
||||
onPress={
|
||||
mode === 'editGroups'
|
||||
? handleDefaultModePress
|
||||
: handleEditGroupsPress
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
className={styles.editButtonIcon}
|
||||
name={mode === 'editGroups' ? icons.REORDER : icons.GROUP}
|
||||
/>
|
||||
|
||||
{mode === 'editGroups'
|
||||
? translate('DoneEditingGroups')
|
||||
: translate('EditGroups')}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className={styles.editSizesButton}
|
||||
kind={kinds.PRIMARY}
|
||||
onPress={
|
||||
mode === 'editSizes' ? handleDefaultModePress : handleEditSizesPress
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
className={styles.editButtonIcon}
|
||||
name={mode === 'editSizes' ? icons.REORDER : icons.FILE}
|
||||
/>
|
||||
|
||||
{mode === 'editSizes'
|
||||
? translate('DoneEditingSizes')
|
||||
: translate('EditSizes')}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<div
|
||||
ref={measureRef}
|
||||
className={styles.qualities}
|
||||
style={{ minHeight: `${height}px` }}
|
||||
>
|
||||
{qualityProfileItems
|
||||
.map((item, index) => {
|
||||
if ('quality' in item) {
|
||||
const { quality, allowed, minSize, maxSize, preferredSize } =
|
||||
item;
|
||||
|
||||
return (
|
||||
<QualityProfileItemDragSource
|
||||
key={item.quality.id}
|
||||
{...otherProps}
|
||||
mode={mode}
|
||||
groupId={undefined}
|
||||
qualityId={quality.id}
|
||||
name={quality.name}
|
||||
allowed={allowed}
|
||||
minSize={minSize}
|
||||
maxSize={maxSize}
|
||||
preferredSize={preferredSize}
|
||||
qualityIndex={`${index + 1}`}
|
||||
isInGroup={false}
|
||||
isDraggingUp={isDraggingUp}
|
||||
isDraggingDown={isDraggingDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { id, name, allowed, items } = item;
|
||||
|
||||
return (
|
||||
<QualityProfileItemDragSource
|
||||
key={id}
|
||||
{...otherProps}
|
||||
mode={mode}
|
||||
groupId={id}
|
||||
qualityId={undefined}
|
||||
name={name}
|
||||
allowed={allowed}
|
||||
items={items}
|
||||
qualityIndex={`${index + 1}`}
|
||||
isDraggingUp={isDraggingUp}
|
||||
isDraggingDown={isDraggingDown}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.reverse()}
|
||||
</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default QualityProfileItems;
|
|
@ -0,0 +1,18 @@
|
|||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createQualityProfileSelectorForHook } from 'Store/Selectors/createQualityProfileSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface QualityProfileNameProps {
|
||||
qualityProfileId: number;
|
||||
}
|
||||
|
||||
function QualityProfileName({ qualityProfileId }: QualityProfileNameProps) {
|
||||
const qualityProfile = useSelector(
|
||||
createQualityProfileSelectorForHook(qualityProfileId)
|
||||
);
|
||||
|
||||
return <span>{qualityProfile?.name ?? translate('Unknown')}</span>;
|
||||
}
|
||||
|
||||
export default QualityProfileName;
|
|
@ -1,31 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createQualityProfileSelector(),
|
||||
(qualityProfile) => {
|
||||
return {
|
||||
name: qualityProfile.name
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function QualityProfileNameConnector({ name, ...otherProps }) {
|
||||
return (
|
||||
<span>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
QualityProfileNameConnector.propTypes = {
|
||||
qualityProfileId: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps)(QualityProfileNameConnector);
|
|
@ -1,107 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Card from 'Components/Card';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Icon from 'Components/Icon';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
|
||||
import QualityProfile from './QualityProfile';
|
||||
import styles from './QualityProfiles.css';
|
||||
|
||||
class QualityProfiles extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isQualityProfileModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onCloneQualityProfilePress = (id) => {
|
||||
this.props.onCloneQualityProfilePress(id);
|
||||
this.setState({ isQualityProfileModalOpen: true });
|
||||
};
|
||||
|
||||
onEditQualityProfilePress = () => {
|
||||
this.setState({ isQualityProfileModalOpen: true });
|
||||
};
|
||||
|
||||
onModalClose = () => {
|
||||
this.setState({ isQualityProfileModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
items,
|
||||
isDeleting,
|
||||
onConfirmDeleteQualityProfile,
|
||||
onCloneQualityProfilePress,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<FieldSet legend={translate('QualityProfiles')}>
|
||||
<PageSectionContent
|
||||
errorMessage={translate('QualityProfilesLoadError')}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.qualityProfiles}>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<QualityProfile
|
||||
key={item.id}
|
||||
{...item}
|
||||
isDeleting={isDeleting}
|
||||
onConfirmDeleteQualityProfile={onConfirmDeleteQualityProfile}
|
||||
onCloneQualityProfilePress={this.onCloneQualityProfilePress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
<Card
|
||||
className={styles.addQualityProfile}
|
||||
onPress={this.onEditQualityProfilePress}
|
||||
>
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
name={icons.ADD}
|
||||
size={45}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<EditQualityProfileModalConnector
|
||||
isOpen={this.state.isQualityProfileModalOpen}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
</PageSectionContent>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
QualityProfiles.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
isDeleting: PropTypes.bool.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onConfirmDeleteQualityProfile: PropTypes.func.isRequired,
|
||||
onCloneQualityProfilePress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default QualityProfiles;
|
93
frontend/src/Settings/Profiles/Quality/QualityProfiles.tsx
Normal file
93
frontend/src/Settings/Profiles/Quality/QualityProfiles.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { QualityProfilesAppState } from 'App/State/SettingsAppState';
|
||||
import Card from 'Components/Card';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Icon from 'Components/Icon';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import {
|
||||
cloneQualityProfile,
|
||||
fetchQualityProfiles,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import QualityProfileModel from 'typings/QualityProfile';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditQualityProfileModal from './EditQualityProfileModal';
|
||||
import QualityProfile from './QualityProfile';
|
||||
import styles from './QualityProfiles.css';
|
||||
|
||||
function QualityProfiles() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { error, isFetching, isPopulated, isDeleting, items } = useSelector(
|
||||
createSortedSectionSelector<QualityProfileModel, QualityProfilesAppState>(
|
||||
'settings.qualityProfiles',
|
||||
sortByProp('name')
|
||||
)
|
||||
) as QualityProfilesAppState;
|
||||
|
||||
const [isQualityProfileModalOpen, setIsQualityProfileModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleEditQualityProfilePress = useCallback(() => {
|
||||
setIsQualityProfileModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditQualityProfileClosePress = useCallback(() => {
|
||||
setIsQualityProfileModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleCloneQualityProfilePress = useCallback(
|
||||
(id: number) => {
|
||||
dispatch(cloneQualityProfile({ id }));
|
||||
setIsQualityProfileModalOpen(true);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchQualityProfiles());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<FieldSet legend={translate('QualityProfiles')}>
|
||||
<PageSectionContent
|
||||
errorMessage={translate('QualityProfilesLoadError')}
|
||||
error={error}
|
||||
isFetching={isFetching}
|
||||
isPopulated={isPopulated}
|
||||
>
|
||||
<div className={styles.qualityProfiles}>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<QualityProfile
|
||||
key={item.id}
|
||||
{...item}
|
||||
isDeleting={isDeleting}
|
||||
onCloneQualityProfilePress={handleCloneQualityProfilePress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Card
|
||||
className={styles.addQualityProfile}
|
||||
onPress={handleEditQualityProfilePress}
|
||||
>
|
||||
<div className={styles.center}>
|
||||
<Icon name={icons.ADD} size={45} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<EditQualityProfileModal
|
||||
isOpen={isQualityProfileModalOpen}
|
||||
onModalClose={handleEditQualityProfileClosePress}
|
||||
/>
|
||||
</PageSectionContent>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export default QualityProfiles;
|
|
@ -1,63 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { cloneQualityProfile, deleteQualityProfile, fetchQualityProfiles } from 'Store/Actions/settingsActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import QualityProfiles from './QualityProfiles';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSortedSectionSelector('settings.qualityProfiles', sortByProp('name')),
|
||||
(qualityProfiles) => qualityProfiles
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchFetchQualityProfiles: fetchQualityProfiles,
|
||||
dispatchDeleteQualityProfile: deleteQualityProfile,
|
||||
dispatchCloneQualityProfile: cloneQualityProfile
|
||||
};
|
||||
|
||||
class QualityProfilesConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatchFetchQualityProfiles();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onConfirmDeleteQualityProfile = (id) => {
|
||||
this.props.dispatchDeleteQualityProfile({ id });
|
||||
};
|
||||
|
||||
onCloneQualityProfilePress = (id) => {
|
||||
this.props.dispatchCloneQualityProfile({ id });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<QualityProfiles
|
||||
onConfirmDeleteQualityProfile={this.onConfirmDeleteQualityProfile}
|
||||
onCloneQualityProfilePress={this.onCloneQualityProfilePress}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
QualityProfilesConnector.propTypes = {
|
||||
dispatchFetchQualityProfiles: PropTypes.func.isRequired,
|
||||
dispatchDeleteQualityProfile: PropTypes.func.isRequired,
|
||||
dispatchCloneQualityProfile: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(QualityProfilesConnector);
|
|
@ -3,7 +3,7 @@ import formatBytes from 'Utilities/Number/formatBytes';
|
|||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface QualityDefinitionLimitsProps {
|
||||
bytes?: number;
|
||||
bytes: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ import AppState from 'App/State/AppState';
|
|||
import FieldSet from 'Components/FieldSet';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import useShowAdvancedSettings from 'Helpers/Hooks/useShowAdvancedSettings';
|
||||
import {
|
||||
fetchQualityDefinitions,
|
||||
saveQualityDefinitions,
|
||||
|
@ -16,7 +15,7 @@ import {
|
|||
SetChildSave,
|
||||
} from 'typings/Settings/SettingsState';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import QualityDefinitionConnector from './QualityDefinitionConnector';
|
||||
import QualityDefinition from './QualityDefinition';
|
||||
import styles from './QualityDefinitions.css';
|
||||
|
||||
function createQualityDefinitionsSelector() {
|
||||
|
@ -50,7 +49,6 @@ function QualityDefinitions({
|
|||
onChildStateChange,
|
||||
}: QualityDefinitionsProps) {
|
||||
const dispatch = useDispatch();
|
||||
const showAdvancedSettings = useShowAdvancedSettings();
|
||||
const { items, isFetching, isPopulated, isSaving, error, hasPendingChanges } =
|
||||
useSelector(createQualityDefinitionsSelector());
|
||||
|
||||
|
@ -90,32 +88,13 @@ function QualityDefinitions({
|
|||
<div className={styles.header}>
|
||||
<div className={styles.quality}>{translate('Quality')}</div>
|
||||
<div className={styles.title}>{translate('Title')}</div>
|
||||
<div className={styles.sizeLimit}>{translate('SizeLimit')}</div>
|
||||
|
||||
{showAdvancedSettings ? (
|
||||
<div className={styles.megabytesPerMinute}>
|
||||
{translate('MegabytesPerMinute')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.definitions}>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<QualityDefinitionConnector
|
||||
key={item.id}
|
||||
{...item}
|
||||
advancedSettings={showAdvancedSettings}
|
||||
/>
|
||||
);
|
||||
return <QualityDefinition key={item.id} {...item} />;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.sizeLimitHelpTextContainer}>
|
||||
<div className={styles.sizeLimitHelpText}>
|
||||
{translate('QualityLimitsSeriesRuntimeHelpText')}
|
||||
</div>
|
||||
</div>
|
||||
</PageSectionContent>
|
||||
</FieldSet>
|
||||
);
|
||||
|
|
|
@ -1,105 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import QualityDefinitions from './Definition/QualityDefinitions';
|
||||
import ResetQualityDefinitionsModal from './Reset/ResetQualityDefinitionsModal';
|
||||
|
||||
class Quality extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._saveCallback = null;
|
||||
|
||||
this.state = {
|
||||
isSaving: false,
|
||||
hasPendingChanges: false,
|
||||
isConfirmQualityDefinitionResetModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onChildMounted = (saveCallback) => {
|
||||
this._saveCallback = saveCallback;
|
||||
};
|
||||
|
||||
onChildStateChange = (payload) => {
|
||||
this.setState(payload);
|
||||
};
|
||||
|
||||
onResetQualityDefinitionsPress = () => {
|
||||
this.setState({ isConfirmQualityDefinitionResetModalOpen: true });
|
||||
};
|
||||
|
||||
onCloseResetQualityDefinitionsModal = () => {
|
||||
this.setState({ isConfirmQualityDefinitionResetModalOpen: false });
|
||||
};
|
||||
|
||||
onSavePress = () => {
|
||||
if (this._saveCallback) {
|
||||
this._saveCallback();
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isSaving,
|
||||
isResettingQualityDefinitions,
|
||||
hasPendingChanges
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<PageContent title={translate('QualitySettings')}>
|
||||
<SettingsToolbarConnector
|
||||
isSaving={isSaving}
|
||||
hasPendingChanges={hasPendingChanges}
|
||||
additionalButtons={
|
||||
<Fragment>
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('ResetDefinitions')}
|
||||
iconName={icons.REFRESH}
|
||||
isSpinning={isResettingQualityDefinitions}
|
||||
onPress={this.onResetQualityDefinitionsPress}
|
||||
/>
|
||||
</Fragment>
|
||||
}
|
||||
onSavePress={this.onSavePress}
|
||||
/>
|
||||
|
||||
<PageContentBody>
|
||||
<QualityDefinitions
|
||||
onChildMounted={this.onChildMounted}
|
||||
onChildStateChange={this.onChildStateChange}
|
||||
/>
|
||||
</PageContentBody>
|
||||
|
||||
<ResetQualityDefinitionsModal
|
||||
isOpen={this.state.isConfirmQualityDefinitionResetModalOpen}
|
||||
onModalClose={this.onCloseResetQualityDefinitionsModal}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Quality.propTypes = {
|
||||
isResettingQualityDefinitions: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default Quality;
|
|
@ -1,25 +0,0 @@
|
|||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Series from 'Series/Series';
|
||||
import ImportList from 'typings/ImportList';
|
||||
import createAllSeriesSelector from './createAllSeriesSelector';
|
||||
|
||||
function createProfileInUseSelector(profileProp: string) {
|
||||
return createSelector(
|
||||
(_: AppState, { id }: { id: number }) => id,
|
||||
createAllSeriesSelector(),
|
||||
(state: AppState) => state.settings.importLists.items,
|
||||
(id, series, lists) => {
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
series.some((s) => s[profileProp as keyof Series] === id) ||
|
||||
lists.some((list) => list[profileProp as keyof ImportList] === id)
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default createProfileInUseSelector;
|
|
@ -1,7 +1,7 @@
|
|||
import _ from 'lodash';
|
||||
import { createSelector } from 'reselect';
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import {
|
||||
AppSectionItemSchemaState,
|
||||
AppSectionProviderState,
|
||||
AppSectionSchemaState,
|
||||
} from 'App/State/AppSectionState';
|
||||
|
@ -11,30 +11,26 @@ import selectSettings, {
|
|||
} from 'Store/Selectors/selectSettings';
|
||||
import getSectionState from 'Utilities/State/getSectionState';
|
||||
|
||||
type SchemaState<T> = AppSectionSchemaState<T> | AppSectionItemSchemaState<T>;
|
||||
|
||||
function selector<
|
||||
T extends ModelBaseSetting,
|
||||
S extends AppSectionProviderState<T> & AppSectionSchemaState<T>
|
||||
S extends AppSectionProviderState<T> & SchemaState<T>
|
||||
>(id: number | undefined, section: S) {
|
||||
if (!id) {
|
||||
const item = _.isArray(section.schema)
|
||||
? section.selectedSchema
|
||||
: section.schema;
|
||||
const settings = selectSettings(
|
||||
Object.assign({ name: '' }, item),
|
||||
section.pendingChanges ?? {},
|
||||
section.saveError
|
||||
);
|
||||
|
||||
if (id) {
|
||||
const {
|
||||
isSchemaFetching: isFetching,
|
||||
isSchemaPopulated: isPopulated,
|
||||
schemaError: error,
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
isTesting,
|
||||
pendingChanges,
|
||||
} = section;
|
||||
|
||||
const item = section.items.find((i) => i.id === id)!;
|
||||
const settings = selectSettings<T>(item, pendingChanges, saveError);
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
|
@ -43,24 +39,31 @@ function selector<
|
|||
saveError,
|
||||
isTesting,
|
||||
...settings,
|
||||
pendingChanges,
|
||||
item: settings.settings,
|
||||
};
|
||||
}
|
||||
|
||||
const item =
|
||||
'selectedSchema' in section
|
||||
? section.selectedSchema
|
||||
: (section.schema as T);
|
||||
|
||||
const settings = selectSettings(
|
||||
Object.assign({ name: '' }, item),
|
||||
section.pendingChanges ?? {},
|
||||
section.saveError
|
||||
);
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
isSchemaFetching: isFetching,
|
||||
isSchemaPopulated: isPopulated,
|
||||
schemaError: error,
|
||||
isSaving,
|
||||
saveError,
|
||||
isTesting,
|
||||
pendingChanges,
|
||||
} = section;
|
||||
|
||||
const item = section.items.find((i) => i.id === id)!;
|
||||
const settings = selectSettings<T>(item, pendingChanges, saveError);
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
|
@ -69,13 +72,14 @@ function selector<
|
|||
saveError,
|
||||
isTesting,
|
||||
...settings,
|
||||
pendingChanges,
|
||||
item: settings.settings,
|
||||
};
|
||||
}
|
||||
|
||||
export default function createProviderSettingsSelector<
|
||||
T extends ModelBase,
|
||||
S extends AppSectionProviderState<T> & AppSectionSchemaState<T>
|
||||
S extends AppSectionProviderState<T> & SchemaState<T>
|
||||
>(sectionName: string) {
|
||||
// @ts-expect-error - This isn't fully typed
|
||||
return createSelector(
|
||||
|
@ -87,7 +91,7 @@ export default function createProviderSettingsSelector<
|
|||
|
||||
export function createProviderSettingsSelectorHook<
|
||||
T extends ModelBaseSetting,
|
||||
S extends AppSectionProviderState<T> & AppSectionSchemaState<T>
|
||||
S extends AppSectionProviderState<T> & SchemaState<T>
|
||||
>(sectionName: string, id: number | undefined) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings,
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import createAllSeriesSelector from './createAllSeriesSelector';
|
||||
|
||||
function createQualityProfileInUseSelector(id: number | undefined) {
|
||||
return createSelector(
|
||||
createAllSeriesSelector(),
|
||||
(state: AppState) => state.settings.importLists.items,
|
||||
(series, lists) => {
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
series.some((s) => s.qualityProfileId === id) ||
|
||||
lists.some((list) => list.qualityProfileId === id)
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default createQualityProfileInUseSelector;
|
|
@ -1,13 +1,13 @@
|
|||
import Quality from 'Quality/Quality';
|
||||
import { QualityProfileQualityItem } from 'typings/QualityProfile';
|
||||
import { QualityProfileItems } from 'typings/QualityProfile';
|
||||
|
||||
export default function getQualities(qualities?: QualityProfileQualityItem[]) {
|
||||
export default function getQualities(qualities?: QualityProfileItems) {
|
||||
if (!qualities) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return qualities.reduce<Quality[]>((acc, item) => {
|
||||
if (item.quality) {
|
||||
if ('quality' in item) {
|
||||
acc.push(item.quality);
|
||||
} else {
|
||||
const groupQualities = item.items.reduce<Quality[]>((acc, i) => {
|
||||
|
|
|
@ -2,18 +2,30 @@ import Quality from 'Quality/Quality';
|
|||
import { QualityProfileFormatItem } from './CustomFormat';
|
||||
|
||||
export interface QualityProfileQualityItem {
|
||||
id?: number;
|
||||
quality?: Quality;
|
||||
quality: Quality;
|
||||
allowed: boolean;
|
||||
minSize: number | null;
|
||||
maxSize: number | null;
|
||||
preferredSize: number | null;
|
||||
}
|
||||
|
||||
export interface QualityProfileGroup {
|
||||
id: number;
|
||||
items: QualityProfileQualityItem[];
|
||||
allowed: boolean;
|
||||
name?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type QualityProfileItems = (
|
||||
| QualityProfileQualityItem
|
||||
| QualityProfileGroup
|
||||
)[];
|
||||
|
||||
interface QualityProfile {
|
||||
name: string;
|
||||
upgradeAllowed: boolean;
|
||||
cutoff: number;
|
||||
items: QualityProfileQualityItem[];
|
||||
items: QualityProfileItems;
|
||||
minFormatScore: number;
|
||||
cutoffFormatScore: number;
|
||||
minUpgradeFormatScore: number;
|
||||
|
|
|
@ -2,5 +2,5 @@ import Column from 'Components/Table/Column';
|
|||
|
||||
export interface TableOptionsChangePayload {
|
||||
pageSize?: number;
|
||||
columns: Column[];
|
||||
columns?: Column[];
|
||||
}
|
||||
|
|
|
@ -49,15 +49,16 @@
|
|||
"normalize.css": "8.0.1",
|
||||
"prop-types": "15.8.1",
|
||||
"qs": "6.13.0",
|
||||
"rdndmb-html5-to-touch": "8.1.2",
|
||||
"react": "18.3.1",
|
||||
"react-addons-shallow-compare": "15.6.3",
|
||||
"react-async-script": "1.2.0",
|
||||
"react-autosuggest": "10.1.0",
|
||||
"react-custom-scrollbars-2": "4.5.0",
|
||||
"react-dnd": "14.0.4",
|
||||
"react-dnd-html5-backend": "14.0.2",
|
||||
"react-dnd-multi-backend": "6.0.2",
|
||||
"react-dnd-touch-backend": "14.1.1",
|
||||
"react-dnd": "16.0.1",
|
||||
"react-dnd-html5-backend": "16.0.1",
|
||||
"react-dnd-multi-backend": "8.1.2",
|
||||
"react-dnd-touch-backend": "16.0.1",
|
||||
"react-document-title": "2.0.3",
|
||||
"react-dom": "18.3.1",
|
||||
"react-focus-lock": "2.9.4",
|
||||
|
|
116
yarn.lock
116
yarn.lock
|
@ -1186,20 +1186,20 @@
|
|||
"@nodelib/fs.scandir" "2.1.5"
|
||||
fastq "^1.6.0"
|
||||
|
||||
"@react-dnd/asap@^4.0.0":
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-4.0.1.tgz#5291850a6b58ce6f2da25352a64f1b0674871aab"
|
||||
integrity sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==
|
||||
"@react-dnd/asap@^5.0.1":
|
||||
version "5.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-5.0.2.tgz#1f81f124c1cd6f39511c11a881cfb0f715343488"
|
||||
integrity sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==
|
||||
|
||||
"@react-dnd/invariant@^2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-2.0.0.tgz#09d2e81cd39e0e767d7da62df9325860f24e517e"
|
||||
integrity sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==
|
||||
"@react-dnd/invariant@^4.0.1":
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-4.0.2.tgz#b92edffca10a26466643349fac7cdfb8799769df"
|
||||
integrity sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==
|
||||
|
||||
"@react-dnd/shallowequal@^2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz#a3031eb54129f2c66b2753f8404266ec7bf67f0a"
|
||||
integrity sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==
|
||||
"@react-dnd/shallowequal@^4.0.1":
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz#d1b4befa423f692fa4abf1c79209702e7d8ae4b4"
|
||||
integrity sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==
|
||||
|
||||
"@rtsao/scc@^1.1.0":
|
||||
version "1.1.0"
|
||||
|
@ -2768,19 +2768,19 @@ dir-glob@^3.0.1:
|
|||
dependencies:
|
||||
path-type "^4.0.0"
|
||||
|
||||
dnd-core@14.0.1:
|
||||
version "14.0.1"
|
||||
resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-14.0.1.tgz#76d000e41c494983210fb20a48b835f81a203c2e"
|
||||
integrity sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==
|
||||
dnd-core@^16.0.1:
|
||||
version "16.0.1"
|
||||
resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-16.0.1.tgz#a1c213ed08961f6bd1959a28bb76f1a868360d19"
|
||||
integrity sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==
|
||||
dependencies:
|
||||
"@react-dnd/asap" "^4.0.0"
|
||||
"@react-dnd/invariant" "^2.0.0"
|
||||
redux "^4.1.1"
|
||||
"@react-dnd/asap" "^5.0.1"
|
||||
"@react-dnd/invariant" "^4.0.1"
|
||||
redux "^4.2.0"
|
||||
|
||||
dnd-multi-backend@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/dnd-multi-backend/-/dnd-multi-backend-6.0.0.tgz#4ed68229a3f6f1fb9e9bc45b4034e8330005280d"
|
||||
integrity sha512-qfUO4V0IACs24xfE9m9OUnwIzoL+SWzSiFbKVIHE0pFddJeZ93BZOdHS1XEYr8X3HNh+CfnfjezXgOMgjvh74g==
|
||||
dnd-multi-backend@^8.1.2:
|
||||
version "8.1.2"
|
||||
resolved "https://registry.yarnpkg.com/dnd-multi-backend/-/dnd-multi-backend-8.1.2.tgz#bf6a6ea9f6a9f5d58cabe12fd927753a753aeb92"
|
||||
integrity sha512-KPDVEsiM+6gNEegqZYTWJQgJxYV4vB91tUrvoKJjaS0wwWqT/jNU0P7xJAwCue/cbasJNvk2dFZH7tC+bjX1Rg==
|
||||
|
||||
doctrine@^2.1.0:
|
||||
version "2.1.0"
|
||||
|
@ -5414,6 +5414,15 @@ raw-body@~1.1.0:
|
|||
bytes "1"
|
||||
string_decoder "0.10"
|
||||
|
||||
rdndmb-html5-to-touch@8.1.2:
|
||||
version "8.1.2"
|
||||
resolved "https://registry.yarnpkg.com/rdndmb-html5-to-touch/-/rdndmb-html5-to-touch-8.1.2.tgz#ab5379974e66e57624a95a79632248b2d32bc354"
|
||||
integrity sha512-efi3MaXYxWaLMd5xzF1bVvmX8erTMhYHSlaMjQe+tynf4IdtgRYfKLwYg+4Z5eq4k7idrjKHQOIMDE6D8LjnOA==
|
||||
dependencies:
|
||||
dnd-multi-backend "^8.1.2"
|
||||
react-dnd-html5-backend "^16.0.1"
|
||||
react-dnd-touch-backend "^16.0.1"
|
||||
|
||||
react-addons-shallow-compare@15.6.3:
|
||||
version "15.6.3"
|
||||
resolved "https://registry.yarnpkg.com/react-addons-shallow-compare/-/react-addons-shallow-compare-15.6.3.tgz#28a94b0dfee71530852c66a69053d59a1baf04cb"
|
||||
|
@ -5456,45 +5465,42 @@ react-custom-scrollbars-2@4.5.0:
|
|||
prop-types "^15.5.10"
|
||||
raf "^3.1.0"
|
||||
|
||||
react-dnd-html5-backend@14.0.2:
|
||||
version "14.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-14.0.2.tgz#25019388f6abdeeda3a6fea835dff155abb2085c"
|
||||
integrity sha512-QgN6rYrOm4UUj6tIvN8ovImu6uP48xBXF2rzVsp6tvj6d5XQ7OjHI4SJ/ZgGobOneRAU3WCX4f8DGCYx0tuhlw==
|
||||
react-dnd-html5-backend@16.0.1, react-dnd-html5-backend@^16.0.1:
|
||||
version "16.0.1"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz#87faef15845d512a23b3c08d29ecfd34871688b6"
|
||||
integrity sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==
|
||||
dependencies:
|
||||
dnd-core "14.0.1"
|
||||
dnd-core "^16.0.1"
|
||||
|
||||
react-dnd-multi-backend@6.0.2:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd-multi-backend/-/react-dnd-multi-backend-6.0.2.tgz#485878014dfbac46fcc898961871be6e5277c3f2"
|
||||
integrity sha512-SwpqRv0HkJYu244FbHf9NbvGzGy14Ir9wIAhm909uvOVaHgsOq6I1THMSWSgpwUI31J3Bo5uS19tuvGpVPjzZw==
|
||||
react-dnd-multi-backend@8.1.2:
|
||||
version "8.1.2"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd-multi-backend/-/react-dnd-multi-backend-8.1.2.tgz#2be039e33d98d063d1f3d89d1a8ce1f487b900aa"
|
||||
integrity sha512-Ecj+gwr5B7zRiWqkDU5sUvUmufcu97WnsZFHnqHrWFJhTXAXQnhrperHLFktNP2CnQYtAgbucodr1if0MWpEaA==
|
||||
dependencies:
|
||||
dnd-multi-backend "^6.0.0"
|
||||
prop-types "^15.7.2"
|
||||
react-dnd-preview "^6.0.2"
|
||||
dnd-multi-backend "^8.1.2"
|
||||
react-dnd-preview "^8.1.2"
|
||||
|
||||
react-dnd-preview@^6.0.2:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd-preview/-/react-dnd-preview-6.0.2.tgz#dd34931c270853c80438e1275e6c9e77174f8afe"
|
||||
integrity sha512-F2+uK4Be+q+7mZfNh9kaZols7wp1hX6G7UBTVaTpDsBpMhjFvY7/v7odxYSerSFBShh23MJl33a4XOVRFj1zoQ==
|
||||
dependencies:
|
||||
prop-types "^15.7.2"
|
||||
react-dnd-preview@^8.1.2:
|
||||
version "8.1.2"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd-preview/-/react-dnd-preview-8.1.2.tgz#a679f62a7bdec30b167ed5a10c7f7ed58095b167"
|
||||
integrity sha512-j5M1NcQBItOCYXONRbCNs6MzW7u4KygeOGZlztNNguTs1/f2d7q1fRnQjFLjCpgeg5Gy/JrTFrbRThZglJP5dg==
|
||||
|
||||
react-dnd-touch-backend@14.1.1:
|
||||
version "14.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd-touch-backend/-/react-dnd-touch-backend-14.1.1.tgz#d8875ef1cf8dcbf1741a4e03dd5b147c4fbda5e4"
|
||||
integrity sha512-ITmfzn3fJrkUBiVLO6aJZcnu7T8C+GfwZitFryGsXKn5wYcUv+oQBeh9FYcMychmVbDdeUCfvEtTk9O+DKmAaw==
|
||||
react-dnd-touch-backend@16.0.1, react-dnd-touch-backend@^16.0.1:
|
||||
version "16.0.1"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd-touch-backend/-/react-dnd-touch-backend-16.0.1.tgz#e73f8169e2b9fac0f687970f875cac0a4d02d6e2"
|
||||
integrity sha512-NonoCABzzjyWGZuDxSG77dbgMZ2Wad7eQiCd/ECtsR2/NBLTjGksPUx9UPezZ1nQ/L7iD130Tz3RUshL/ClKLA==
|
||||
dependencies:
|
||||
"@react-dnd/invariant" "^2.0.0"
|
||||
dnd-core "14.0.1"
|
||||
"@react-dnd/invariant" "^4.0.1"
|
||||
dnd-core "^16.0.1"
|
||||
|
||||
react-dnd@14.0.4:
|
||||
version "14.0.4"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-14.0.4.tgz#ffb4ea0e2a3a5532f9c6294d565742008a52b8b0"
|
||||
integrity sha512-AFJJXzUIWp5WAhgvI85ESkDCawM0lhoVvfo/lrseLXwFdH3kEO3v8I2C81QPqBW2UEyJBIPStOhPMGYGFtq/bg==
|
||||
react-dnd@16.0.1:
|
||||
version "16.0.1"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-16.0.1.tgz#2442a3ec67892c60d40a1559eef45498ba26fa37"
|
||||
integrity sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==
|
||||
dependencies:
|
||||
"@react-dnd/invariant" "^2.0.0"
|
||||
"@react-dnd/shallowequal" "^2.0.0"
|
||||
dnd-core "14.0.1"
|
||||
"@react-dnd/invariant" "^4.0.1"
|
||||
"@react-dnd/shallowequal" "^4.0.1"
|
||||
dnd-core "^16.0.1"
|
||||
fast-deep-equal "^3.1.3"
|
||||
hoist-non-react-statics "^3.3.2"
|
||||
|
||||
|
@ -5787,7 +5793,7 @@ redux-thunk@2.4.2:
|
|||
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.4.2.tgz#b9d05d11994b99f7a91ea223e8b04cf0afa5ef3b"
|
||||
integrity sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==
|
||||
|
||||
redux@4.2.1, redux@^4.0.0, redux@^4.1.1:
|
||||
redux@4.2.1, redux@^4.0.0, redux@^4.2.0:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197"
|
||||
integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue