Convert Manual Import to Typescript

pull/8457/head
Qstick 1 year ago
parent d2112f2bdc
commit eeb997430c

@ -28,7 +28,8 @@ module.exports = {
globals: {
expect: false,
chai: false,
sinon: false
sinon: false,
JSX: true
},
parserOptions: {

@ -123,7 +123,7 @@ class AddNewMovieSearchResult extends Component {
monitored={monitored}
hasFile={hasFile}
status={status}
posterWidth={posterWidth}
width={posterWidth}
detailedProgressBar={true}
queueStatus={queueStatus}
queueState={queueState}

@ -1,10 +1,10 @@
import { reduce } from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import ImportMovieFooterConnector from './ImportMovieFooterConnector';
@ -33,7 +33,17 @@ class ImportMovie extends Component {
// Listeners
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState, { parseIds: false });
return reduce(
this.state.selectedState,
(result, value, id) => {
if (value) {
result.push(id);
}
return result;
},
[]
);
};
onSelectAllChange = ({ value }) => {

@ -0,0 +1,48 @@
import SortDirection from 'Helpers/Props/SortDirection';
export interface Error {
responseJSON: {
message: string;
};
}
export interface AppSectionDeleteState {
isDeleting: boolean;
deleteError: Error;
}
export interface AppSectionSaveState {
isSaving: boolean;
saveError: Error;
}
export interface PagedAppSectionState {
pageSize: number;
}
export interface AppSectionSchemaState<T> {
isSchemaFetching: boolean;
isSchemaPopulated: boolean;
schemaError: Error;
schema: {
items: T[];
};
}
export interface AppSectionItemState<T> {
isFetching: boolean;
isPopulated: boolean;
error: Error;
item: T;
}
interface AppSectionState<T> {
isFetching: boolean;
isPopulated: boolean;
error: Error;
items: T[];
sortKey: string;
sortDirection: SortDirection;
}
export default AppSectionState;

@ -0,0 +1,50 @@
import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
import MovieFilesAppState from './MovieFilesAppState';
import MoviesAppState, { MovieIndexAppState } from './MoviesAppState';
import QueueAppState from './QueueAppState';
import SettingsAppState from './SettingsAppState';
import TagsAppState from './TagsAppState';
interface FilterBuilderPropOption {
id: string;
name: string;
}
export interface FilterBuilderProp<T> {
name: string;
label: string;
type: string;
valueType?: string;
optionsSelector?: (items: T[]) => FilterBuilderPropOption[];
}
export interface PropertyFilter {
key: string;
value: boolean | string | number | string[] | number[];
type: string;
}
export interface Filter {
key: string;
label: string;
filers: PropertyFilter[];
}
export interface CustomFilter {
id: number;
type: string;
label: string;
filers: PropertyFilter[];
}
interface AppState {
movieFiles: MovieFilesAppState;
interactiveImport: InteractiveImportAppState;
movieIndex: MovieIndexAppState;
settings: SettingsAppState;
movies: MoviesAppState;
tags: TagsAppState;
queue: QueueAppState;
}
export default AppState;

@ -0,0 +1,8 @@
import { CustomFilter } from './AppState';
interface ClientSideCollectionAppState {
totalItems: number;
customFilters: CustomFilter[];
}
export default ClientSideCollectionAppState;

@ -0,0 +1,10 @@
import AppSectionState, {
AppSectionDeleteState,
} from 'App/State/AppSectionState';
import { CustomFilter } from './AppState';
interface CustomFiltersAppState
extends AppSectionState<CustomFilter>,
AppSectionDeleteState {}
export default CustomFiltersAppState;

@ -0,0 +1,12 @@
import AppSectionState from 'App/State/AppSectionState';
import RecentFolder from 'InteractiveImport/Folder/RecentFolder';
import ImportMode from '../../InteractiveImport/ImportMode';
import InteractiveImport from '../../InteractiveImport/InteractiveImport';
interface InteractiveImportAppState extends AppSectionState<InteractiveImport> {
originalItems: InteractiveImport[];
importMode: ImportMode;
recentFolders: RecentFolder[];
}
export default InteractiveImportAppState;

@ -0,0 +1,10 @@
import AppSectionState, {
AppSectionDeleteState,
} from 'App/State/AppSectionState';
import { MovieFile } from 'MovieFile/MovieFile';
interface MovieFilesAppState
extends AppSectionState<MovieFile>,
AppSectionDeleteState {}
export default MovieFilesAppState;

@ -0,0 +1,61 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import Column from 'Components/Table/Column';
import SortDirection from 'Helpers/Props/SortDirection';
import Movie from 'Movie/Movie';
import { Filter, FilterBuilderProp } from './AppState';
export interface MovieIndexAppState {
sortKey: string;
sortDirection: SortDirection;
secondarySortKey: string;
secondarySortDirection: SortDirection;
view: string;
posterOptions: {
detailedProgressBar: boolean;
size: string;
showTitle: boolean;
showMonitored: boolean;
showQualityProfile: boolean;
showReleaseDate: boolean;
showCinemaRelease: boolean;
showSearchAction: boolean;
};
overviewOptions: {
detailedProgressBar: boolean;
size: string;
showMonitored: boolean;
showStudio: boolean;
showQualityProfile: boolean;
showAdded: boolean;
showPath: boolean;
showSizeOnDisk: boolean;
showSearchAction: boolean;
};
tableOptions: {
showSearchAction: boolean;
};
selectedFilterKey: string;
filterBuilderProps: FilterBuilderProp<Movie>[];
filters: Filter[];
columns: Column[];
}
interface MoviesAppState
extends AppSectionState<Movie>,
AppSectionDeleteState,
AppSectionSaveState {
itemMap: Record<number, number>;
deleteOptions: {
addImportExclusion: boolean;
};
}
export default MoviesAppState;

@ -0,0 +1,51 @@
import ModelBase from 'App/ModelBase';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
import AppSectionState, { AppSectionItemState, Error } from './AppSectionState';
export interface StatusMessage {
title: string;
messages: string[];
}
export interface Queue extends ModelBase {
languages: Language[];
quality: QualityModel;
customFormats: CustomFormat[];
size: number;
title: string;
sizeleft: number;
timeleft: string;
estimatedCompletionTime: string;
status: string;
trackedDownloadStatus: string;
trackedDownloadState: string;
statusMessages: StatusMessage[];
errorMessage: string;
downloadId: string;
protocol: string;
downloadClient: string;
outputPath: string;
movieHasFile: boolean;
movieId?: number;
}
export interface QueueDetailsAppState extends AppSectionState<Queue> {
params: unknown;
}
export interface QueuePagedAppState extends AppSectionState<Queue> {
isGrabbing: boolean;
grabError: Error;
isRemoving: boolean;
removeError: Error;
}
interface QueueAppState {
status: AppSectionItemState<Queue>;
details: QueueDetailsAppState;
paged: QueuePagedAppState;
}
export default QueueAppState;

@ -0,0 +1,28 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSchemaState,
} from 'App/State/AppSectionState';
import Language from 'Language/Language';
import DownloadClient from 'typings/DownloadClient';
import QualityProfile from 'typings/QualityProfile';
import { UiSettings } from 'typings/UiSettings';
export interface DownloadClientAppState
extends AppSectionState<DownloadClient>,
AppSectionDeleteState {}
export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>,
AppSectionSchemaState<QualityProfile> {}
export type LanguageSettingsAppState = AppSectionState<Language>;
export type UiSettingsAppState = AppSectionState<UiSettings>;
interface SettingsAppState {
downloadClients: DownloadClientAppState;
language: LanguageSettingsAppState;
uiSettings: UiSettingsAppState;
qualityProfiles: QualityProfilesAppState;
}
export default SettingsAppState;

@ -0,0 +1,12 @@
import ModelBase from 'App/ModelBase';
import AppSectionState, {
AppSectionDeleteState,
} from 'App/State/AppSectionState';
export interface Tag extends ModelBase {
label: string;
}
interface TagsAppState extends AppSectionState<Tag>, AppSectionDeleteState {}
export default TagsAppState;

@ -134,7 +134,7 @@ class CollectionMovie extends Component {
hasFile={hasFile}
status={status}
bottomRadius={true}
posterWidth={posterWidth}
width={posterWidth}
detailedProgressBar={detailedProgressBar}
isAvailable={isAvailable}
/>

@ -0,0 +1,37 @@
import ModelBase from 'App/ModelBase';
export interface CommandBody {
sendUpdatesToClient: boolean;
updateScheduledTask: boolean;
completionMessage: string;
requiresDiskAccess: boolean;
isExclusive: boolean;
isLongRunning: boolean;
name: string;
lastExecutionTime: string;
lastStartTime: string;
trigger: string;
suppressMessages: boolean;
movieId?: number;
}
interface Command extends ModelBase {
name: string;
commandName: string;
message: string;
body: CommandBody;
priority: string;
status: string;
result: string;
queued: string;
started: string;
ended: string;
duration: string;
trigger: string;
stateChangeTime: string;
sendUpdatesToClient: boolean;
updateScheduledTask: boolean;
lastExecutionTime: string;
}
export default Command;

@ -271,6 +271,7 @@ FormInputGroup.propTypes = {
helpTexts: PropTypes.arrayOf(PropTypes.string),
helpTextWarning: PropTypes.string,
helpLink: PropTypes.string,
autoFocus: PropTypes.bool,
includeNoChange: PropTypes.bool,
includeNoChangeDisabled: PropTypes.bool,
selectedValueOptions: PropTypes.object,

@ -68,6 +68,7 @@ class PathInputConnector extends Component {
}
PathInputConnector.propTypes = {
...PathInput.props,
includeFiles: PropTypes.bool.isRequired,
dispatchFetchPaths: PropTypes.func.isRequired,
dispatchClearPaths: PropTypes.func.isRequired

@ -1,110 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import styles from './Link.css';
class Link extends Component {
//
// Listeners
onClick = (event) => {
const {
isDisabled,
onPress
} = this.props;
if (!isDisabled && onPress) {
onPress(event);
}
};
//
// Render
render() {
const {
className,
component,
to,
target,
isDisabled,
noRouter,
onPress,
...otherProps
} = this.props;
const linkProps = { target };
let el = component;
if (to && typeof to === 'string') {
if ((/\w+?:\/\//).test(to)) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_blank';
linkProps.rel = 'noreferrer';
} else if (noRouter) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_self';
} else {
el = RouterLink;
linkProps.to = `${window.Radarr.urlBase}/${to.replace(/^\//, '')}`;
linkProps.target = target;
}
} else if (to && typeof to === 'object') {
el = RouterLink;
linkProps.target = target;
if (to.pathname.startsWith(`${window.Radarr.urlBase}/`)) {
linkProps.to = to;
} else {
const pathname = `${window.Radarr.urlBase}/${to.pathname.replace(/^\//, '')}`;
linkProps.to = {
...to,
pathname
};
}
}
if (el === 'button' || el === 'input') {
linkProps.type = otherProps.type || 'button';
linkProps.disabled = isDisabled;
}
linkProps.className = classNames(
className,
styles.link,
to && styles.to,
isDisabled && 'isDisabled'
);
const props = {
...otherProps,
...linkProps
};
props.onClick = this.onClick;
return (
React.createElement(el, props)
);
}
}
Link.propTypes = {
className: PropTypes.string,
component: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
to: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
target: PropTypes.string,
isDisabled: PropTypes.bool,
noRouter: PropTypes.bool,
onPress: PropTypes.func
};
Link.defaultProps = {
component: 'button',
noRouter: false
};
export default Link;

@ -0,0 +1,96 @@
import classNames from 'classnames';
import React, {
ComponentClass,
FunctionComponent,
SyntheticEvent,
useCallback,
} from 'react';
import { Link as RouterLink } from 'react-router-dom';
import styles from './Link.css';
interface ReactRouterLinkProps {
to?: string;
}
export interface LinkProps extends React.HTMLProps<HTMLAnchorElement> {
className?: string;
component?:
| string
| FunctionComponent<LinkProps>
| ComponentClass<LinkProps, unknown>;
to?: string;
target?: string;
isDisabled?: boolean;
noRouter?: boolean;
onPress?(event: SyntheticEvent): void;
}
function Link(props: LinkProps) {
const {
className,
component = 'button',
to,
target,
type,
isDisabled,
noRouter = false,
onPress,
...otherProps
} = props;
const onClick = useCallback(
(event: SyntheticEvent) => {
if (!isDisabled && onPress) {
onPress(event);
}
},
[isDisabled, onPress]
);
const linkProps: React.HTMLProps<HTMLAnchorElement> & ReactRouterLinkProps = {
target,
};
let el = component;
if (to) {
if (/\w+?:\/\//.test(to)) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_blank';
linkProps.rel = 'noreferrer';
} else if (noRouter) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_self';
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
el = RouterLink;
linkProps.to = `${window.Radarr.urlBase}/${to.replace(/^\//, '')}`;
linkProps.target = target;
}
}
if (el === 'button' || el === 'input') {
linkProps.type = type || 'button';
linkProps.disabled = isDisabled;
}
linkProps.className = classNames(
className,
styles.link,
to && styles.to,
isDisabled && 'isDisabled'
);
const elementProps = {
...otherProps,
type,
...linkProps,
};
elementProps.onClick = onClick;
return React.createElement(el, elementProps);
}
export default Link;

@ -55,10 +55,7 @@ class PageHeader extends Component {
<div className={styles.logoContainer}>
<Link
className={styles.logoLink}
to={{
pathname: '/',
state: { restoreScrollPosition: true }
}}
to={'/'}
>
<img
className={isSmallScreen ? styles.logo : styles.logoFull}

@ -1,5 +1,5 @@
import React, { forwardRef, ReactNode, useCallback } from 'react';
import Scroller from 'Components/Scroller/Scroller';
import React, { ForwardedRef, forwardRef, ReactNode, useCallback } from 'react';
import Scroller, { OnScroll } from 'Components/Scroller/Scroller';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import { isLocked } from 'Utilities/scrollLock';
import styles from './PageContentBody.css';
@ -9,14 +9,11 @@ interface PageContentBodyProps {
innerClassName: string;
children: ReactNode;
initialScrollTop?: number;
onScroll?: (payload) => void;
onScroll?: (payload: OnScroll) => void;
}
const PageContentBody = forwardRef(
(
props: PageContentBodyProps,
ref: React.MutableRefObject<HTMLDivElement>
) => {
(props: PageContentBodyProps, ref: ForwardedRef<HTMLDivElement>) => {
const {
className = styles.contentBody,
innerClassName = styles.innerContentBody,
@ -26,7 +23,7 @@ const PageContentBody = forwardRef(
} = props;
const onScrollWrapper = useCallback(
(payload) => {
(payload: OnScroll) => {
if (onScroll && !isLocked()) {
onScroll(payload);
}

@ -53,7 +53,7 @@
background-color: var(--dangerColor);
&:global(.colorImpaired) {
background: repeating-linear-gradient(90deg, var(--dangerColor), var(--dangerColor) 5px, var(--dangerColor) 5px, var(--dimColor) 10px);
background: repeating-linear-gradient(90deg, color(#f05050 shade(5%)), color(#f05050 shade(5%)) 5px, color(#f05050 shade(15%)) 5px, color(#f05050 shade(15%)) 10px);
}
}
@ -69,7 +69,7 @@
background-color: var(--warningColor);
&:global(.colorImpaired) {
background: repeating-linear-gradient(45deg, var(--warningColor), var(--warningColor) 5px, var(--warningColor) 5px, var(--dimColor) 10px);
background: repeating-linear-gradient(45deg, #ffa500, #ffa500 5px, color(#ffa500 tint(15%)) 5px, color(#ffa500 tint(15%)) 10px);
}
}

@ -1,9 +1,21 @@
import classNames from 'classnames';
import { throttle } from 'lodash';
import React, { forwardRef, ReactNode, useEffect, useRef } from 'react';
import React, {
ForwardedRef,
forwardRef,
MutableRefObject,
ReactNode,
useEffect,
useRef,
} from 'react';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import styles from './Scroller.css';
export interface OnScroll {
scrollLeft: number;
scrollTop: number;
}
interface ScrollerProps {
className?: string;
scrollDirection?: ScrollDirection;
@ -12,11 +24,11 @@ interface ScrollerProps {
scrollTop?: number;
initialScrollTop?: number;
children?: ReactNode;
onScroll?: (payload) => void;
onScroll?: (payload: OnScroll) => void;
}
const Scroller = forwardRef(
(props: ScrollerProps, ref: React.MutableRefObject<HTMLDivElement>) => {
(props: ScrollerProps, ref: ForwardedRef<HTMLDivElement>) => {
const {
className,
autoFocus = false,
@ -30,7 +42,7 @@ const Scroller = forwardRef(
} = props;
const internalRef = useRef();
const currentRef = ref ?? internalRef;
const currentRef = (ref as MutableRefObject<HTMLDivElement>) ?? internalRef;
useEffect(
() => {

@ -1,25 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Link from 'Components/Link/Link';
import TableRowCell from './TableRowCell';
import styles from './TableRowCellButton.css';
function TableRowCellButton({ className, ...otherProps }) {
return (
<Link
className={className}
component={TableRowCell}
{...otherProps}
/>
);
}
TableRowCellButton.propTypes = {
className: PropTypes.string.isRequired
};
TableRowCellButton.defaultProps = {
className: styles.cell
};
export default TableRowCellButton;

@ -0,0 +1,19 @@
import React, { ReactNode } from 'react';
import Link, { LinkProps } from 'Components/Link/Link';
import TableRowCell from './TableRowCell';
import styles from './TableRowCellButton.css';
interface TableRowCellButtonProps extends LinkProps {
className?: string;
children: ReactNode;
}
function TableRowCellButton(props: TableRowCellButtonProps) {
const { className = styles.cell, ...otherProps } = props;
return (
<Link className={className} component={TableRowCell} {...otherProps} />
);
}
export default TableRowCellButton;

@ -1,8 +1,10 @@
import React from 'react';
interface Column {
name: string;
label: string;
columnLabel: string;
isSortable: boolean;
label: string | React.ReactNode;
columnLabel?: string;
isSortable?: boolean;
isVisible: boolean;
isModifiable?: boolean;
}

@ -121,6 +121,7 @@ function Table(props) {
}
Table.propTypes = {
...TableHeaderCell.props,
className: PropTypes.string,
horizontalScroll: PropTypes.bool.isRequired,
selectAll: PropTypes.bool.isRequired,

@ -58,24 +58,22 @@ class VirtualTable extends Component {
scrollRestored
} = this.state;
if (this._grid &&
(prevState.width !== width ||
hasDifferentItemsOrOrder(prevProps.items, items))) {
if (this._grid && (prevState.width !== width || hasDifferentItemsOrOrder(prevProps.items, items))) {
// recomputeGridSize also forces Grid to discard its cache of rendered cells
this._grid.recomputeGridSize();
}
if (this._grid && scrollTop !== undefined && scrollTop !== 0 && !scrollRestored) {
this.setState({ scrollRestored: true });
this._grid.scrollToPosition({ scrollTop });
}
if (scrollIndex != null && scrollIndex !== prevProps.scrollIndex) {
this._grid.scrollToCell({
rowIndex: scrollIndex,
columnIndex: 0
});
}
if (this._grid && scrollTop !== undefined && scrollTop !== 0 && !scrollRestored) {
this.setState({ scrollRestored: true });
this._grid.scrollToPosition({ scrollTop });
}
}
//
@ -103,10 +101,9 @@ class VirtualTable extends Component {
className,
items,
scroller,
focusScroller,
scrollTop: ignored,
header,
headerHeight,
rowHeight,
rowRenderer,
...otherProps
} = this.props;
@ -145,11 +142,11 @@ class VirtualTable extends Component {
<Scroller
className={className}
scrollDirection={scrollDirections.HORIZONTAL}
autoFocus={focusScroller}
>
{header}
<div ref={registerChild}>
<Grid
{...otherProps}
ref={this.setGridRef}
autoContainerWidth={true}
autoHeight={true}
@ -157,7 +154,7 @@ class VirtualTable extends Component {
width={width}
height={height}
headerHeight={height - headerHeight}
rowHeight={ROW_HEIGHT}
rowHeight={rowHeight}
rowCount={items.length}
columnCount={1}
columnWidth={width}
@ -171,7 +168,6 @@ class VirtualTable extends Component {
className={styles.tableBodyContainer}
style={gridStyle}
containerStyle={containerStyle}
{...otherProps}
/>
</div>
</Scroller>
@ -191,16 +187,16 @@ VirtualTable.propTypes = {
scrollIndex: PropTypes.number,
scrollTop: PropTypes.number,
scroller: PropTypes.instanceOf(Element).isRequired,
focusScroller: PropTypes.bool.isRequired,
header: PropTypes.node.isRequired,
headerHeight: PropTypes.number.isRequired,
rowRenderer: PropTypes.func.isRequired
rowRenderer: PropTypes.func.isRequired,
rowHeight: PropTypes.number.isRequired
};
VirtualTable.defaultProps = {
className: styles.tableContainer,
headerHeight: 38,
focusScroller: true
rowHeight: ROW_HEIGHT
};
export default VirtualTable;

@ -1,24 +1,30 @@
import PropTypes from 'prop-types';
import React from 'react';
import { RouteComponentProps } from 'react-router-dom';
import scrollPositions from 'Store/scrollPositions';
function withScrollPosition(WrappedComponent, scrollPositionKey) {
function ScrollPosition(props) {
interface WrappedComponentProps {
initialScrollTop: number;
}
interface ScrollPositionProps {
history: RouteComponentProps['history'];
location: RouteComponentProps['location'];
match: RouteComponentProps['match'];
}
function withScrollPosition(
WrappedComponent: React.FC<WrappedComponentProps>,
scrollPositionKey: string
) {
function ScrollPosition(props: ScrollPositionProps) {
const { history } = props;
const initialScrollTop =
history.action === 'POP' ||
(history.location.state && history.location.state.restoreScrollPosition)
? scrollPositions[scrollPositionKey]
: 0;
history.action === 'POP' ? scrollPositions[scrollPositionKey] : 0;
return <WrappedComponent {...props} initialScrollTop={initialScrollTop} />;
}
ScrollPosition.propTypes = {
history: PropTypes.object.isRequired,
};
return ScrollPosition;
}

@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
export default function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}

@ -1,169 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import PathInputConnector from 'Components/Form/PathInputConnector';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import RecentFolderRow from './RecentFolderRow';
import styles from './InteractiveImportSelectFolderModalContent.css';
const recentFoldersColumns = [
{
name: 'folder',
label: translate('Folder')
},
{
name: 'lastUsed',
label: translate('LastUsed')
},
{
name: 'actions',
label: ''
}
];
class InteractiveImportSelectFolderModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
folder: ''
};
}
//
// Listeners
onPathChange = ({ value }) => {
this.setState({ folder: value });
};
onRecentPathPress = (folder) => {
this.setState({ folder });
};
onQuickImportPress = () => {
this.props.onQuickImportPress(this.state.folder);
};
onInteractiveImportPress = () => {
this.props.onInteractiveImportPress(this.state.folder);
};
//
// Render
render() {
const {
recentFolders,
onRemoveRecentFolderPress,
onModalClose
} = this.props;
const folder = this.state.folder;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('ManualImport')} - {translate('SelectFolder')}
</ModalHeader>
<ModalBody>
<PathInputConnector
name="folder"
value={folder}
onChange={this.onPathChange}
/>
{
!!recentFolders.length &&
<div className={styles.recentFoldersContainer}>
<Table
columns={recentFoldersColumns}
>
<TableBody>
{
recentFolders.slice(0).reverse().map((recentFolder) => {
return (
<RecentFolderRow
key={recentFolder.folder}
folder={recentFolder.folder}
lastUsed={recentFolder.lastUsed}
onPress={this.onRecentPathPress}
onRemoveRecentFolderPress={onRemoveRecentFolderPress}
/>
);
})
}
</TableBody>
</Table>
</div>
}
<div className={styles.buttonsContainer}>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
kind={kinds.PRIMARY}
size={sizes.LARGE}
isDisabled={!folder}
onPress={this.onQuickImportPress}
>
<Icon
className={styles.buttonIcon}
name={icons.QUICK}
/>
{translate('QuickImport')}
</Button>
</div>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
kind={kinds.PRIMARY}
size={sizes.LARGE}
isDisabled={!folder}
onPress={this.onInteractiveImportPress}
>
<Icon
className={styles.buttonIcon}
name={icons.INTERACTIVE}
/>
{translate('InteractiveImport')}
</Button>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
{translate('Cancel')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
InteractiveImportSelectFolderModalContent.propTypes = {
recentFolders: PropTypes.arrayOf(PropTypes.object).isRequired,
onQuickImportPress: PropTypes.func.isRequired,
onInteractiveImportPress: PropTypes.func.isRequired,
onRemoveRecentFolderPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default InteractiveImportSelectFolderModalContent;

@ -0,0 +1,172 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import PathInputConnector from 'Components/Form/PathInputConnector';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds, sizes } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import {
addRecentFolder,
removeRecentFolder,
} from 'Store/Actions/interactiveImportActions';
import translate from 'Utilities/String/translate';
import RecentFolderRow from './RecentFolderRow';
import styles from './InteractiveImportSelectFolderModalContent.css';
const recentFoldersColumns = [
{
name: 'folder',
label: translate('Folder'),
},
{
name: 'lastUsed',
label: translate('LastUsed'),
},
{
name: 'actions',
label: '',
},
];
interface InteractiveImportSelectFolderModalContentProps {
modalTitle: string;
onFolderSelect(folder: string): void;
onModalClose(): void;
}
function InteractiveImportSelectFolderModalContent(
props: InteractiveImportSelectFolderModalContentProps
) {
const { modalTitle, onFolderSelect, onModalClose } = props;
const [folder, setFolder] = useState('');
const dispatch = useDispatch();
const recentFolders = useSelector(
createSelector(
(state: AppState) => state.interactiveImport.recentFolders,
(recentFolders) => {
return recentFolders;
}
)
);
const onPathChange = useCallback(
({ value }: { value: string }) => {
setFolder(value);
},
[setFolder]
);
const onRecentPathPress = useCallback(
(value: string) => {
setFolder(value);
},
[setFolder]
);
const onQuickImportPress = useCallback(() => {
dispatch(addRecentFolder({ folder }));
dispatch(
executeCommand({
name: commandNames.DOWNLOADED_MOVIES_SCAN,
path: folder,
})
);
onModalClose();
}, [folder, onModalClose, dispatch]);
const onInteractiveImportPress = useCallback(() => {
dispatch(addRecentFolder({ folder }));
onFolderSelect(folder);
}, [folder, onFolderSelect, dispatch]);
const onRemoveRecentFolderPress = useCallback(
(folderToRemove: string) => {
dispatch(removeRecentFolder({ folder: folderToRemove }));
},
[dispatch]
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle} - {translate('SelectFolder')}
</ModalHeader>
<ModalBody>
<PathInputConnector
name="folder"
value={folder}
onChange={onPathChange}
/>
{recentFolders.length ? (
<div className={styles.recentFoldersContainer}>
<Table columns={recentFoldersColumns}>
<TableBody>
{recentFolders
.slice(0)
.reverse()
.map((recentFolder) => {
return (
<RecentFolderRow
key={recentFolder.folder}
folder={recentFolder.folder}
lastUsed={recentFolder.lastUsed}
onPress={onRecentPathPress}
onRemoveRecentFolderPress={onRemoveRecentFolderPress}
/>
);
})}
</TableBody>
</Table>
</div>
) : null}
<div className={styles.buttonsContainer}>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
kind={kinds.PRIMARY}
size={sizes.LARGE}
isDisabled={!folder}
onPress={onQuickImportPress}
>
<Icon className={styles.buttonIcon} name={icons.QUICK} />
{translate('MoveAutomatically')}
</Button>
</div>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
kind={kinds.PRIMARY}
size={sizes.LARGE}
isDisabled={!folder}
onPress={onInteractiveImportPress}
>
<Icon className={styles.buttonIcon} name={icons.INTERACTIVE} />
{translate('InteractiveImport')}
</Button>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default InteractiveImportSelectFolderModalContent;

@ -1,80 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import { executeCommand } from 'Store/Actions/commandActions';
import { addRecentFolder, removeRecentFolder } from 'Store/Actions/interactiveImportActions';
import InteractiveImportSelectFolderModalContent from './InteractiveImportSelectFolderModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.interactiveImport.recentFolders,
(recentFolders) => {
return {
recentFolders
};
}
);
}
const mapDispatchToProps = {
addRecentFolder,
removeRecentFolder,
executeCommand
};
class InteractiveImportSelectFolderModalContentConnector extends Component {
//
// Listeners
onQuickImportPress = (folder) => {
this.props.addRecentFolder({ folder });
this.props.executeCommand({
name: commandNames.DOWNLOADED_MOVIES_SCAN,
path: folder
});
this.props.onModalClose();
};
onInteractiveImportPress = (folder) => {
this.props.addRecentFolder({ folder });
this.props.onFolderSelect(folder);
};
onRemoveRecentFolderPress = (folder) => {
this.props.removeRecentFolder({ folder });
};
//
// Render
render() {
if (this.path) {
return null;
}
return (
<InteractiveImportSelectFolderModalContent
{...this.props}
onQuickImportPress={this.onQuickImportPress}
onInteractiveImportPress={this.onInteractiveImportPress}
onRemoveRecentFolderPress={this.onRemoveRecentFolderPress}
/>
);
}
}
InteractiveImportSelectFolderModalContentConnector.propTypes = {
path: PropTypes.string,
onFolderSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
addRecentFolder: PropTypes.func.isRequired,
removeRecentFolder: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportSelectFolderModalContentConnector);

@ -0,0 +1,6 @@
interface RecentFolder {
folder: string;
lastUsed: string;
}
export default RecentFolder;

@ -0,0 +1,3 @@
type ImportMode = 'auto' | 'move' | 'copy' | 'chooseImportMode';
export default ImportMode;

@ -26,6 +26,12 @@
justify-content: flex-end;
}
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: 10px;
}
.importMode,
.bulkSelect {
composes: select from '~Components/Form/SelectInput.css';

@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'bulkSelect': string;
'deleteButton': string;
'errorMessage': string;
'filterContainer': string;
'filterText': string;

@ -1,435 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SelectInput from 'Components/Form/SelectInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import SelectedMenuItem from 'Components/Menu/SelectedMenuItem';
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 Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { align, icons, kinds, scrollDirections } from 'Helpers/Props';
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
import SelectMovieModal from 'InteractiveImport/Movie/SelectMovieModal';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import InteractiveImportRow from './InteractiveImportRow';
import styles from './InteractiveImportModalContent.css';
const columns = [
{
name: 'relativePath',
label: translate('RelativePath'),
isSortable: true,
isVisible: true
},
{
name: 'movie',
label: translate('Movie'),
isSortable: true,
isVisible: true
},
{
name: 'releaseGroup',
label: translate('ReleaseGroup'),
isVisible: true
},
{
name: 'quality',
label: translate('Quality'),
isSortable: true,
isVisible: true
},
{
name: 'languages',
label: translate('Languages'),
isSortable: true,
isVisible: true
},
{
name: 'size',
label: translate('Size'),
isSortable: true,
isVisible: true
},
{
name: 'customFormats',
label: React.createElement(Icon, {
name: icons.INTERACTIVE,
title: translate('CustomFormat')
}),
isSortable: true,
isVisible: true
},
{
name: 'rejections',
label: React.createElement(Icon, {
name: icons.DANGER,
kind: kinds.DANGER
}),
isSortable: true,
isVisible: true
}
];
const filterExistingFilesOptions = {
ALL: translate('All'),
NEW: translate('New')
};
const importModeOptions = [
{ key: 'chooseImportMode', value: translate('ChooseImportMode'), disabled: true },
{ key: 'move', value: translate('MoveFiles') },
{ key: 'copy', value: translate('HardlinkCopyFiles') }
];
const SELECT = 'select';
const MOVIE = 'movie';
const LANGUAGE = 'language';
const QUALITY = 'quality';
const RELEASE_GROUP = 'releaseGroup';
class InteractiveImportModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
invalidRowsSelected: [],
selectModalOpen: null
};
}
//
// Control
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
};
//
// Listeners
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
};
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
};
onValidRowChange = (id, isValid) => {
this.setState((state) => {
if (isValid) {
return {
invalidRowsSelected: _.without(state.invalidRowsSelected, id)
};
}
return {
invalidRowsSelected: [...state.invalidRowsSelected, id]
};
});
};
onImportSelectedPress = () => {
const {
downloadId,
showImportMode,
importMode,
onImportSelectedPress
} = this.props;
const selected = this.getSelectedIds();
const finalImportMode = downloadId || !showImportMode ? 'auto' : importMode;
onImportSelectedPress(selected, finalImportMode);
};
onFilterExistingFilesChange = (value) => {
this.props.onFilterExistingFilesChange(value !== filterExistingFilesOptions.ALL);
};
onImportModeChange = ({ value }) => {
this.props.onImportModeChange(value);
};
onSelectModalSelect = ({ value }) => {
this.setState({ selectModalOpen: value });
};
onSelectModalClose = () => {
this.setState({ selectModalOpen: null });
};
//
// Render
render() {
const {
downloadId,
allowMovieChange,
showFilterExistingFiles,
showImportMode,
filterExistingFiles,
title,
folder,
isFetching,
isPopulated,
error,
items,
sortKey,
sortDirection,
importMode,
interactiveImportErrorMessage,
onSortPress,
onModalClose
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
invalidRowsSelected,
selectModalOpen
} = this.state;
const selectedIds = this.getSelectedIds();
const errorMessage = getErrorMessage(error, translate('UnableToLoadManualImportItems'));
const bulkSelectOptions = [
{ key: SELECT, value: translate('SelectDotDot'), disabled: true },
{ key: LANGUAGE, value: translate('SelectLanguage') },
{ key: QUALITY, value: translate('SelectQuality') },
{ key: RELEASE_GROUP, value: translate('SelectReleaseGroup') }
];
if (allowMovieChange) {
bulkSelectOptions.splice(1, 0, {
key: MOVIE,
value: translate('SelectMovie')
});
}
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('ManualImport')} - {title || folder}
</ModalHeader>
<ModalBody scrollDirection={scrollDirections.BOTH}>
{
showFilterExistingFiles &&
<div className={styles.filterContainer}>
<Menu alignMenu={align.RIGHT}>
<MenuButton>
<Icon
name={icons.FILTER}
size={22}
/>
<div className={styles.filterText}>
{
filterExistingFiles ? translate('UnmappedFilesOnly') : translate('AllFiles')
}
</div>
</MenuButton>
<MenuContent>
<SelectedMenuItem
name={filterExistingFilesOptions.ALL}
isSelected={!filterExistingFiles}
onPress={this.onFilterExistingFilesChange}
>
{translate('AllFiles')}
</SelectedMenuItem>
<SelectedMenuItem
name={filterExistingFilesOptions.NEW}
isSelected={filterExistingFiles}
onPress={this.onFilterExistingFilesChange}
>
{translate('UnmappedFilesOnly')}
</SelectedMenuItem>
</MenuContent>
</Menu>
</div>
}
{
isFetching &&
<LoadingIndicator />
}
{
error &&
<div>{errorMessage}</div>
}
{
isPopulated && !!items.length && !isFetching && !isFetching &&
<Table
columns={columns}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<InteractiveImportRow
key={item.id}
isSelected={selectedState[item.id]}
{...item}
allowMovieChange={allowMovieChange}
onSelectedChange={this.onSelectedChange}
onValidRowChange={this.onValidRowChange}
/>
);
})
}
</TableBody>
</Table>
}
{
isPopulated && !items.length && !isFetching &&
translate('NoVideoFilesFoundSelectedFolder')
}
</ModalBody>
<ModalFooter className={styles.footer}>
<div className={styles.leftButtons}>
{
!downloadId && showImportMode ?
<SelectInput
className={styles.importMode}
name="importMode"
value={importMode}
values={importModeOptions}
onChange={this.onImportModeChange}
/> :
null
}
<SelectInput
className={styles.bulkSelect}
name="select"
value={SELECT}
values={bulkSelectOptions}
isDisabled={!selectedIds.length}
onChange={this.onSelectModalSelect}
/>
</div>
<div className={styles.rightButtons}>
<Button onPress={onModalClose}>
{translate('Cancel')}
</Button>
{
interactiveImportErrorMessage &&
<span className={styles.errorMessage}>{interactiveImportErrorMessage}</span>
}
<Button
kind={kinds.SUCCESS}
isDisabled={!selectedIds.length || !!invalidRowsSelected.length}
onPress={this.onImportSelectedPress}
>
{translate('Import')}
</Button>
</div>
</ModalFooter>
<SelectMovieModal
isOpen={selectModalOpen === MOVIE}
ids={selectedIds}
onModalClose={this.onSelectModalClose}
/>
<SelectLanguageModal
isOpen={selectModalOpen === LANGUAGE}
ids={selectedIds}
languageIds={[0]}
onModalClose={this.onSelectModalClose}
/>
<SelectQualityModal
isOpen={selectModalOpen === QUALITY}
ids={selectedIds}
qualityId={0}
proper={false}
real={false}
onModalClose={this.onSelectModalClose}
/>
<SelectReleaseGroupModal
isOpen={selectModalOpen === RELEASE_GROUP}
ids={selectedIds}
releaseGroup=""
onModalClose={this.onSelectModalClose}
/>
</ModalContent>
);
}
}
InteractiveImportModalContent.propTypes = {
downloadId: PropTypes.string,
allowMovieChange: PropTypes.bool.isRequired,
showImportMode: PropTypes.bool.isRequired,
showFilterExistingFiles: PropTypes.bool.isRequired,
filterExistingFiles: PropTypes.bool.isRequired,
importMode: PropTypes.string.isRequired,
title: PropTypes.string,
folder: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.string,
interactiveImportErrorMessage: PropTypes.string,
onSortPress: PropTypes.func.isRequired,
onFilterExistingFilesChange: PropTypes.func.isRequired,
onImportModeChange: PropTypes.func.isRequired,
onImportSelectedPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
InteractiveImportModalContent.defaultProps = {
allowMovieChange: true,
showFilterExistingFiles: false,
showImportMode: true,
importMode: 'move'
};
export default InteractiveImportModalContent;

@ -0,0 +1,787 @@
import { cloneDeep, without } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
import * as commandNames from 'Commands/commandNames';
import SelectInput from 'Components/Form/SelectInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import SelectedMenuItem from 'Components/Menu/SelectedMenuItem';
import ConfirmModal from 'Components/Modal/ConfirmModal';
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 Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import usePrevious from 'Helpers/Hooks/usePrevious';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { align, icons, kinds, scrollDirections } from 'Helpers/Props';
import ImportMode from 'InteractiveImport/ImportMode';
import InteractiveImport, {
InteractiveImportCommandOptions,
} from 'InteractiveImport/InteractiveImport';
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
import SelectMovieModal from 'InteractiveImport/Movie/SelectMovieModal';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
import Language from 'Language/Language';
import Movie from 'Movie/Movie';
import { MovieFile } from 'MovieFile/MovieFile';
import { QualityModel } from 'Quality/Quality';
import { executeCommand } from 'Store/Actions/commandActions';
import {
clearInteractiveImport,
fetchInteractiveImportItems,
reprocessInteractiveImportItems,
setInteractiveImportMode,
setInteractiveImportSort,
updateInteractiveImportItems,
} from 'Store/Actions/interactiveImportActions';
import {
deleteMovieFiles,
updateMovieFiles,
} from 'Store/Actions/movieFileActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SortCallback } from 'typings/callbacks';
import { SelectStateInputProps } from 'typings/props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import InteractiveImportRow from './InteractiveImportRow';
import styles from './InteractiveImportModalContent.css';
type SelectType = 'select' | 'movie' | 'releaseGroup' | 'quality' | 'language';
type FilterExistingFiles = 'all' | 'new';
// TODO: This feels janky to do, but not sure of a better way currently
type OnSelectedChangeCallback = React.ComponentProps<
typeof InteractiveImportRow
>['onSelectedChange'];
const COLUMNS = [
{
name: 'relativePath',
label: translate('RelativePath'),
isSortable: true,
isVisible: true,
},
{
name: 'movie',
label: translate('Movie'),
isSortable: true,
isVisible: true,
},
{
name: 'releaseGroup',
label: translate('ReleaseGroup'),
isVisible: true,
},
{
name: 'quality',
label: translate('Quality'),
isSortable: true,
isVisible: true,
},
{
name: 'languages',
label: translate('Languages'),
isSortable: true,
isVisible: true,
},
{
name: 'size',
label: translate('Size'),
isSortable: true,
isVisible: true,
},
{
name: 'customFormats',
label: React.createElement(Icon, {
name: icons.INTERACTIVE,
title: translate('CustomFormat'),
}),
isSortable: true,
isVisible: true,
},
{
name: 'rejections',
label: React.createElement(Icon, {
name: icons.DANGER,
kind: kinds.DANGER,
}),
isSortable: true,
isVisible: true,
},
];
const importModeOptions = [
{
key: 'chooseImportMode',
value: translate('ChooseImportMode'),
disabled: true,
},
{ key: 'move', value: translate('MoveFiles') },
{ key: 'copy', value: translate('HardlinkCopyFiles') },
];
function isSameMovieFile(
file: InteractiveImport,
originalFile?: InteractiveImport
) {
const { movie } = file;
if (!originalFile) {
return false;
}
if (!originalFile.movie || movie?.id !== originalFile.movie.id) {
return false;
}
return true;
}
const movieFilesInfoSelector = createSelector(
(state: AppState) => state.movieFiles.isDeleting,
(state: AppState) => state.movieFiles.deleteError,
(isDeleting, deleteError) => {
return {
isDeleting,
deleteError,
};
}
);
const importModeSelector = createSelector(
(state: AppState) => state.interactiveImport.importMode,
(importMode) => {
return importMode;
}
);
interface InteractiveImportModalContentProps {
downloadId?: string;
movieId?: number;
seasonNumber?: number;
showMovie?: boolean;
allowMovieChange?: boolean;
showDelete?: boolean;
showImportMode?: boolean;
showFilterExistingFiles?: boolean;
title?: string;
folder?: string;
sortKey?: string;
sortDirection?: string;
initialSortKey?: string;
initialSortDirection?: string;
modalTitle: string;
onModalClose(): void;
}
function InteractiveImportModalContent(
props: InteractiveImportModalContentProps
) {
const {
downloadId,
movieId,
seasonNumber,
allowMovieChange = true,
showMovie = true,
showFilterExistingFiles = false,
showDelete = false,
showImportMode = true,
title,
folder,
initialSortKey,
initialSortDirection,
modalTitle,
onModalClose,
} = props;
const {
isFetching,
isPopulated,
error,
items,
originalItems,
sortKey,
sortDirection,
}: InteractiveImportAppState = useSelector(
createClientSideCollectionSelector('interactiveImport')
);
const { isDeleting, deleteError } = useSelector(movieFilesInfoSelector);
const importMode = useSelector(importModeSelector);
const [invalidRowsSelected, setInvalidRowsSelected] = useState<number[]>([]);
const [withoutMovieFileIdRowsSelected, setWithoutMovieFileIdRowsSelected] =
useState<number[]>([]);
const [selectModalOpen, setSelectModalOpen] = useState<SelectType | null>(
null
);
const [isConfirmDeleteModalOpen, setIsConfirmDeleteModalOpen] =
useState(false);
const [filterExistingFiles, setFilterExistingFiles] = useState(false);
const [interactiveImportErrorMessage, setInteractiveImportErrorMessage] =
useState<string | null>(null);
const [selectState, setSelectState] = useSelectState();
const [bulkSelectOptions, setBulkSelectOptions] = useState([
{ key: 'select', value: translate('SelectDotDot'), disabled: true },
{ key: 'quality', value: translate('SelectQuality') },
{ key: 'releaseGroup', value: translate('SelectReleaseGroup') },
{ key: 'language', value: translate('SelectLanguage') },
]);
const { allSelected, allUnselected, selectedState } = selectState;
const previousIsDeleting = usePrevious(isDeleting);
const dispatch = useDispatch();
const columns: Column[] = useMemo(() => {
const result: Column[] = cloneDeep(COLUMNS);
if (!showMovie) {
const movieColumn = result.find((c) => c.name === 'movie');
if (movieColumn) {
movieColumn.isVisible = false;
}
}
return result;
}, [showMovie]);
const selectedIds: number[] = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
useEffect(
() => {
if (allowMovieChange) {
const newBulkSelectOptions = [...bulkSelectOptions];
newBulkSelectOptions.splice(1, 0, {
key: 'movie',
value: translate('SelectMovie'),
});
setBulkSelectOptions(newBulkSelectOptions);
}
if (initialSortKey) {
const sortProps: { sortKey: string; sortDirection?: string } = {
sortKey: initialSortKey,
};
if (initialSortDirection) {
sortProps.sortDirection = initialSortDirection;
}
dispatch(setInteractiveImportSort(sortProps));
}
dispatch(
fetchInteractiveImportItems({
downloadId,
movieId,
seasonNumber,
folder,
filterExistingFiles,
})
);
// returned function will be called on component unmount
return () => {
dispatch(clearInteractiveImport());
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
useEffect(() => {
if (!isDeleting && previousIsDeleting && !deleteError) {
onModalClose();
}
}, [previousIsDeleting, isDeleting, deleteError, onModalClose]);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const onSelectedChange = useCallback<OnSelectedChangeCallback>(
({ id, value, hasMovieFileId, shiftKey = false }) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
setWithoutMovieFileIdRowsSelected(
hasMovieFileId || !value
? without(withoutMovieFileIdRowsSelected, id)
: [...withoutMovieFileIdRowsSelected, id]
);
},
[
items,
withoutMovieFileIdRowsSelected,
setSelectState,
setWithoutMovieFileIdRowsSelected,
]
);
const onValidRowChange = useCallback(
(id: number, isValid: boolean) => {
if (isValid && invalidRowsSelected.includes(id)) {
setInvalidRowsSelected(without(invalidRowsSelected, id));
} else if (!isValid && !invalidRowsSelected.includes(id)) {
setInvalidRowsSelected([...invalidRowsSelected, id]);
}
},
[invalidRowsSelected, setInvalidRowsSelected]
);
const onDeleteSelectedPress = useCallback(() => {
setIsConfirmDeleteModalOpen(true);
}, [setIsConfirmDeleteModalOpen]);
const onConfirmDelete = useCallback(() => {
setIsConfirmDeleteModalOpen(false);
const movieFileIds = items.reduce((acc: number[], item) => {
if (selectedIds.indexOf(item.id) > -1 && item.movieFileId) {
acc.push(item.movieFileId);
}
return acc;
}, []);
dispatch(deleteMovieFiles({ movieFileIds }));
}, [items, selectedIds, setIsConfirmDeleteModalOpen, dispatch]);
const onConfirmDeleteModalClose = useCallback(() => {
setIsConfirmDeleteModalOpen(false);
}, [setIsConfirmDeleteModalOpen]);
const onImportSelectedPress = useCallback(() => {
const finalImportMode = downloadId || !showImportMode ? 'auto' : importMode;
const existingFiles: Partial<MovieFile>[] = [];
const files: InteractiveImportCommandOptions[] = [];
if (finalImportMode === 'chooseImportMode') {
setInteractiveImportErrorMessage('An import mode must be selected');
return;
}
items.forEach((item) => {
const isSelected = selectedIds.indexOf(item.id) > -1;
if (isSelected) {
const { movie, releaseGroup, quality, languages, movieFileId } = item;
if (!movie) {
setInteractiveImportErrorMessage(
translate('InteractiveImportErrMovie')
);
return;
}
if (!quality) {
setInteractiveImportErrorMessage(
translate('InteractiveImportErrQuality')
);
return;
}
if (!languages) {
setInteractiveImportErrorMessage(
translate('InteractiveImportErrLanguage')
);
return;
}
setInteractiveImportErrorMessage(null);
if (movieFileId) {
const originalItem = originalItems.find((i) => i.id === item.id);
if (isSameMovieFile(item, originalItem)) {
existingFiles.push({
id: movieFileId,
releaseGroup,
quality,
languages,
});
return;
}
}
files.push({
path: item.path,
folderName: item.folderName,
movieId: movie.id,
releaseGroup,
quality,
languages,
downloadId,
movieFileId,
});
}
});
let shouldClose = false;
if (existingFiles.length) {
dispatch(
updateMovieFiles({
files: existingFiles,
})
);
shouldClose = true;
}
if (files.length) {
dispatch(
executeCommand({
name: commandNames.INTERACTIVE_IMPORT,
files,
importMode: finalImportMode,
})
);
shouldClose = true;
}
if (shouldClose) {
onModalClose();
}
}, [
downloadId,
showImportMode,
importMode,
items,
originalItems,
selectedIds,
onModalClose,
dispatch,
]);
const onSortPress = useCallback<SortCallback>(
(sortKey, sortDirection) => {
dispatch(setInteractiveImportSort({ sortKey, sortDirection }));
},
[dispatch]
);
const onFilterExistingFilesChange = useCallback<
(value: FilterExistingFiles) => void
>(
(value) => {
const filter = value !== 'all';
setFilterExistingFiles(filter);
dispatch(
fetchInteractiveImportItems({
downloadId,
movieId,
folder,
filterExistingFiles: filter,
})
);
},
[downloadId, movieId, folder, setFilterExistingFiles, dispatch]
);
const onImportModeChange = useCallback<
({ value }: { value: ImportMode }) => void
>(
({ value }) => {
dispatch(setInteractiveImportMode({ importMode: value }));
},
[dispatch]
);
const onSelectModalSelect = useCallback<
({ value }: { value: SelectType }) => void
>(
({ value }) => {
setSelectModalOpen(value);
},
[setSelectModalOpen]
);
const onSelectModalClose = useCallback(() => {
setSelectModalOpen(null);
}, [setSelectModalOpen]);
const onMovieSelect = useCallback(
(movie: Movie) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
movie,
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, setSelectModalOpen, dispatch]
);
const onReleaseGroupSelect = useCallback(
(releaseGroup: string) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
releaseGroup,
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, dispatch]
);
const onLanguagesSelect = useCallback(
(newLanguages: Language[]) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
languages: newLanguages,
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, dispatch]
);
const onQualitySelect = useCallback(
(quality: QualityModel) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
quality,
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, dispatch]
);
const errorMessage = getErrorMessage(
error,
translate('UnableToLoadManualImportItems')
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle} - {title || folder}
</ModalHeader>
<ModalBody scrollDirection={scrollDirections.BOTH}>
{showFilterExistingFiles && (
<div className={styles.filterContainer}>
<Menu alignMenu={align.RIGHT}>
<MenuButton>
<Icon name={icons.FILTER} size={22} />
<div className={styles.filterText}>
{filterExistingFiles
? translate('UnmappedFilesOnly')
: translate('AllFiles')}
</div>
</MenuButton>
<MenuContent>
<SelectedMenuItem
name={'all'}
isSelected={!filterExistingFiles}
onPress={onFilterExistingFilesChange}
>
{translate('AllFiles')}
</SelectedMenuItem>
<SelectedMenuItem
name={'new'}
isSelected={filterExistingFiles}
onPress={onFilterExistingFilesChange}
>
{translate('UnmappedFilesOnly')}
</SelectedMenuItem>
</MenuContent>
</Menu>
</div>
)}
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={columns}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
onSelectAllChange={onSelectAllChange}
>
<TableBody>
{items.map((item) => {
return (
<InteractiveImportRow
key={item.id}
isSelected={selectedState[item.id]}
{...item}
allowMovieChange={allowMovieChange}
columns={columns}
modalTitle={modalTitle}
onSelectedChange={onSelectedChange}
onValidRowChange={onValidRowChange}
/>
);
})}
</TableBody>
</Table>
) : null}
{isPopulated && !items.length && !isFetching
? translate('NoVideoFilesFoundSelectedFolder')
: null}
</ModalBody>
<ModalFooter className={styles.footer}>
<div className={styles.leftButtons}>
{showDelete ? (
<SpinnerButton
className={styles.deleteButton}
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={
!selectedIds.length || !!withoutMovieFileIdRowsSelected.length
}
onPress={onDeleteSelectedPress}
>
{translate('Delete')}
</SpinnerButton>
) : null}
{!downloadId && showImportMode ? (
<SelectInput
className={styles.importMode}
name="importMode"
value={importMode}
values={importModeOptions}
onChange={onImportModeChange}
/>
) : null}
<SelectInput
className={styles.bulkSelect}
name="select"
value={'select'}
values={bulkSelectOptions}
isDisabled={!selectedIds.length}
onChange={onSelectModalSelect}
/>
</div>
<div className={styles.rightButtons}>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
{interactiveImportErrorMessage && (
<span className={styles.errorMessage}>
{interactiveImportErrorMessage}
</span>
)}
<Button
kind={kinds.SUCCESS}
isDisabled={!selectedIds.length || !!invalidRowsSelected.length}
onPress={onImportSelectedPress}
>
{translate('Import')}
</Button>
</div>
</ModalFooter>
<SelectMovieModal
isOpen={selectModalOpen === 'movie'}
modalTitle={modalTitle}
onMovieSelect={onMovieSelect}
onModalClose={onSelectModalClose}
/>
<SelectReleaseGroupModal
isOpen={selectModalOpen === 'releaseGroup'}
releaseGroup=""
modalTitle={modalTitle}
onReleaseGroupSelect={onReleaseGroupSelect}
onModalClose={onSelectModalClose}
/>
<SelectLanguageModal
isOpen={selectModalOpen === 'language'}
languageIds={[0]}
modalTitle={modalTitle}
onLanguagesSelect={onLanguagesSelect}
onModalClose={onSelectModalClose}
/>
<SelectQualityModal
isOpen={selectModalOpen === 'quality'}
qualityId={0}
proper={false}
real={false}
modalTitle={modalTitle}
onQualitySelect={onQualitySelect}
onModalClose={onSelectModalClose}
/>
<ConfirmModal
isOpen={isConfirmDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteSelectedMovieFiles')}
message={translate('DeleteSelectedMovieFilesMessage')}
confirmLabel="Delete"
onConfirm={onConfirmDelete}
onCancel={onConfirmDeleteModalClose}
/>
</ModalContent>
);
}
export default InteractiveImportModalContent;

@ -1,206 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import { executeCommand } from 'Store/Actions/commandActions';
import { clearInteractiveImport, fetchInteractiveImportItems, setInteractiveImportMode, setInteractiveImportSort } from 'Store/Actions/interactiveImportActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import translate from 'Utilities/String/translate';
import InteractiveImportModalContent from './InteractiveImportModalContent';
function createMapStateToProps() {
return createSelector(
createClientSideCollectionSelector('interactiveImport'),
(interactiveImport) => {
return interactiveImport;
}
);
}
const mapDispatchToProps = {
dispatchFetchInteractiveImportItems: fetchInteractiveImportItems,
dispatchSetInteractiveImportSort: setInteractiveImportSort,
dispatchSetInteractiveImportMode: setInteractiveImportMode,
dispatchClearInteractiveImport: clearInteractiveImport,
dispatchExecuteCommand: executeCommand
};
class InteractiveImportModalContentConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
interactiveImportErrorMessage: null,
filterExistingFiles: true
};
}
componentDidMount() {
const {
downloadId,
movieId,
folder
} = this.props;
const {
filterExistingFiles
} = this.state;
this.props.dispatchFetchInteractiveImportItems({
downloadId,
movieId,
folder,
filterExistingFiles
});
}
componentDidUpdate(prevProps, prevState) {
const {
filterExistingFiles
} = this.state;
if (prevState.filterExistingFiles !== filterExistingFiles) {
const {
downloadId,
movieId,
folder
} = this.props;
this.props.dispatchFetchInteractiveImportItems({
downloadId,
movieId,
folder,
filterExistingFiles
});
}
}
componentWillUnmount() {
this.props.dispatchClearInteractiveImport();
}
//
// Listeners
onSortPress = (sortKey, sortDirection) => {
this.props.dispatchSetInteractiveImportSort({ sortKey, sortDirection });
};
onFilterExistingFilesChange = (filterExistingFiles) => {
this.setState({ filterExistingFiles });
};
onImportModeChange = (importMode) => {
this.props.dispatchSetInteractiveImportMode({ importMode });
};
onImportSelectedPress = (selected, importMode) => {
const {
items
} = this.props;
const files = [];
if (importMode === 'chooseImportMode') {
this.setState({ interactiveImportErrorMessage: 'An import mode must be selected' });
return;
}
items.forEach((item) => {
const isSelected = selected.indexOf(item.id) > -1;
if (isSelected) {
const {
movie,
quality,
languages,
releaseGroup
} = item;
if (!movie) {
this.setState({ interactiveImportErrorMessage: translate('InteractiveImportErrMovie') });
return false;
}
if (!quality) {
this.setState({ interactiveImportErrorMessage: translate('InteractiveImportErrQuality') });
return false;
}
if (!languages) {
this.setState({ interactiveImportErrorMessage: translate('InteractiveImportErrLanguage') });
return false;
}
files.push({
path: item.path,
folderName: item.folderName,
movieId: movie.id,
releaseGroup,
quality,
languages,
downloadId: this.props.downloadId
});
}
});
if (!files.length) {
return;
}
this.props.dispatchExecuteCommand({
name: commandNames.INTERACTIVE_IMPORT,
files,
importMode
});
this.props.onModalClose();
};
//
// Render
render() {
const {
interactiveImportErrorMessage,
filterExistingFiles
} = this.state;
return (
<InteractiveImportModalContent
{...this.props}
interactiveImportErrorMessage={interactiveImportErrorMessage}
filterExistingFiles={filterExistingFiles}
onSortPress={this.onSortPress}
onFilterExistingFilesChange={this.onFilterExistingFilesChange}
onImportModeChange={this.onImportModeChange}
onImportSelectedPress={this.onImportSelectedPress}
/>
);
}
}
InteractiveImportModalContentConnector.propTypes = {
downloadId: PropTypes.string,
movieId: PropTypes.number,
folder: PropTypes.string,
filterExistingFiles: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
dispatchFetchInteractiveImportItems: PropTypes.func.isRequired,
dispatchSetInteractiveImportSort: PropTypes.func.isRequired,
dispatchSetInteractiveImportMode: PropTypes.func.isRequired,
dispatchClearInteractiveImport: PropTypes.func.isRequired,
dispatchExecuteCommand: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
InteractiveImportModalContentConnector.defaultProps = {
filterExistingFiles: true
};
export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportModalContentConnector);

@ -1,364 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
import SelectMovieModal from 'InteractiveImport/Movie/SelectMovieModal';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
import MovieFormats from 'Movie/MovieFormats';
import MovieLanguage from 'Movie/MovieLanguage';
import MovieQuality from 'Movie/MovieQuality';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder';
import styles from './InteractiveImportRow.css';
class InteractiveImportRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isSelectMovieModalOpen: false,
isSelectReleaseGroupModalOpen: false,
isSelectQualityModalOpen: false,
isSelectLanguageModalOpen: false
};
}
componentDidMount() {
const {
id,
movie,
quality,
languages
} = this.props;
if (
movie &&
quality &&
languages
) {
this.props.onSelectedChange({ id, value: true });
}
}
componentDidUpdate(prevProps) {
const {
id,
movie,
quality,
languages,
isSelected,
onValidRowChange
} = this.props;
if (
prevProps.movie === movie &&
prevProps.quality === quality &&
prevProps.languages === languages &&
prevProps.isSelected === isSelected
) {
return;
}
const isValid = !!(
movie &&
quality &&
languages
);
if (isSelected && !isValid) {
onValidRowChange(id, false);
} else {
onValidRowChange(id, true);
}
}
//
// Control
selectRowAfterChange = (value) => {
const {
id,
isSelected
} = this.props;
if (!isSelected && value === true) {
this.props.onSelectedChange({ id, value });
}
};
//
// Listeners
onSelectMoviePress = () => {
this.setState({ isSelectMovieModalOpen: true });
};
onSelectReleaseGroupPress = () => {
this.setState({ isSelectReleaseGroupModalOpen: true });
};
onSelectQualityPress = () => {
this.setState({ isSelectQualityModalOpen: true });
};
onSelectLanguagePress = () => {
this.setState({ isSelectLanguageModalOpen: true });
};
onSelectMovieModalClose = (changed) => {
this.setState({ isSelectMovieModalOpen: false });
this.selectRowAfterChange(changed);
};
onSelectReleaseGroupModalClose = (changed) => {
this.setState({ isSelectReleaseGroupModalOpen: false });
this.selectRowAfterChange(changed);
};
onSelectQualityModalClose = (changed) => {
this.setState({ isSelectQualityModalOpen: false });
this.selectRowAfterChange(changed);
};
onSelectLanguageModalClose = (changed) => {
this.setState({ isSelectLanguageModalOpen: false });
this.selectRowAfterChange(changed);
};
//
// Render
render() {
const {
id,
allowMovieChange,
relativePath,
movie,
quality,
languages,
releaseGroup,
size,
customFormats,
rejections,
isReprocessing,
isSelected,
onSelectedChange
} = this.props;
const {
isSelectMovieModalOpen,
isSelectQualityModalOpen,
isSelectLanguageModalOpen,
isSelectReleaseGroupModalOpen
} = this.state;
const movieTitle = movie ? movie.title + ( movie.year > 0 ? ` (${movie.year})` : '') : '';
const showMoviePlaceholder = isSelected && !movie;
const showQualityPlaceholder = isSelected && !quality;
const showLanguagePlaceholder = isSelected && !languages && !isReprocessing;
const showReleaseGroupPlaceholder = isSelected && !releaseGroup;
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
<TableRowCell
className={styles.relativePath}
title={relativePath}
>
{relativePath}
</TableRowCell>
<TableRowCellButton
isDisabled={!allowMovieChange}
title={allowMovieChange ? translate('ClickToChangeMovie') : undefined}
onPress={this.onSelectMoviePress}
>
{
showMoviePlaceholder ? <InteractiveImportRowCellPlaceholder /> : movieTitle
}
</TableRowCellButton>
<TableRowCellButton
title={translate('ClickToChangeReleaseGroup')}
onPress={this.onSelectReleaseGroupPress}
>
{
showReleaseGroupPlaceholder ?
<InteractiveImportRowCellPlaceholder /> :
releaseGroup
}
</TableRowCellButton>
<TableRowCellButton
className={styles.quality}
title={translate('ClickToChangeQuality')}
onPress={this.onSelectQualityPress}
>
{
showQualityPlaceholder &&
<InteractiveImportRowCellPlaceholder />
}
{
!showQualityPlaceholder && !!quality &&
<MovieQuality
className={styles.label}
quality={quality}
/>
}
</TableRowCellButton>
<TableRowCellButton
className={styles.languages}
title={translate('ClickToChangeLanguage')}
onPress={this.onSelectLanguagePress}
>
{
showLanguagePlaceholder &&
<InteractiveImportRowCellPlaceholder />
}
{
!showLanguagePlaceholder && !!languages && !isReprocessing ?
<MovieLanguage
className={styles.label}
languages={languages}
/> :
null
}
{
isReprocessing ?
<LoadingIndicator className={styles.reprocessing}
size={20}
/> : null
}
</TableRowCellButton>
<TableRowCell>
{formatBytes(size)}
</TableRowCell>
<TableRowCell>
{
customFormats?.length ?
<Popover
anchor={
<Icon name={icons.INTERACTIVE} />
}
title={translate('Formats')}
body={
<div className={styles.customFormatTooltip}>
<MovieFormats formats={customFormats} />
</div>
}
position={tooltipPositions.LEFT}
/> :
null
}
</TableRowCell>
<TableRowCell>
{
rejections.length ?
<Popover
anchor={
<Icon
name={icons.DANGER}
kind={kinds.DANGER}
/>
}
title={translate('ReleaseRejected')}
body={
<ul>
{
rejections.map((rejection, index) => {
return (
<li key={index}>
{rejection.reason}
</li>
);
})
}
</ul>
}
position={tooltipPositions.LEFT}
canFlip={false}
/> :
null
}
</TableRowCell>
<SelectMovieModal
isOpen={isSelectMovieModalOpen}
ids={[id]}
relativePath={relativePath}
onModalClose={this.onSelectMovieModalClose}
/>
<SelectReleaseGroupModal
isOpen={isSelectReleaseGroupModalOpen}
ids={[id]}
releaseGroup={releaseGroup ?? ''}
onModalClose={this.onSelectReleaseGroupModalClose}
/>
<SelectQualityModal
isOpen={isSelectQualityModalOpen}
ids={[id]}
qualityId={quality ? quality.quality.id : 0}
proper={quality ? quality.revision.version > 1 : false}
real={quality ? quality.revision.real > 0 : false}
onModalClose={this.onSelectQualityModalClose}
/>
<SelectLanguageModal
isOpen={isSelectLanguageModalOpen}
ids={[id]}
languageIds={languages ? languages.map((l) => l.id) : []}
onModalClose={this.onSelectLanguageModalClose}
/>
</TableRow>
);
}
}
InteractiveImportRow.propTypes = {
id: PropTypes.number.isRequired,
allowMovieChange: PropTypes.bool.isRequired,
relativePath: PropTypes.string.isRequired,
movie: PropTypes.object,
quality: PropTypes.object,
languages: PropTypes.arrayOf(PropTypes.object),
releaseGroup: PropTypes.string,
size: PropTypes.number.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
isReprocessing: PropTypes.bool,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired,
onValidRowChange: PropTypes.func.isRequired
};
export default InteractiveImportRow;

@ -0,0 +1,362 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
import SelectMovieModal from 'InteractiveImport/Movie/SelectMovieModal';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
import Language from 'Language/Language';
import Movie from 'Movie/Movie';
import MovieFormats from 'Movie/MovieFormats';
import MovieLanguage from 'Movie/MovieLanguage';
import MovieQuality from 'Movie/MovieQuality';
import { QualityModel } from 'Quality/Quality';
import {
reprocessInteractiveImportItems,
updateInteractiveImportItem,
} from 'Store/Actions/interactiveImportActions';
import { SelectStateInputProps } from 'typings/props';
import Rejection from 'typings/Rejection';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder';
import styles from './InteractiveImportRow.css';
type SelectType = 'movie' | 'releaseGroup' | 'quality' | 'language';
type SelectedChangeProps = SelectStateInputProps & {
hasMovieFileId: boolean;
};
interface InteractiveImportRowProps {
id: number;
allowMovieChange: boolean;
relativePath: string;
movie?: Movie;
releaseGroup?: string;
quality?: QualityModel;
languages?: Language[];
size: number;
customFormats?: object[];
rejections: Rejection[];
columns: Column[];
movieFileId?: number;
isReprocessing?: boolean;
isSelected?: boolean;
modalTitle: string;
onSelectedChange(result: SelectedChangeProps): void;
onValidRowChange(id: number, isValid: boolean): void;
}
function InteractiveImportRow(props: InteractiveImportRowProps) {
const {
id,
allowMovieChange,
relativePath,
movie,
quality,
languages,
releaseGroup,
size,
customFormats,
rejections,
isSelected,
modalTitle,
movieFileId,
columns,
onSelectedChange,
onValidRowChange,
} = props;
const dispatch = useDispatch();
const isMovieColumnVisible = useMemo(
() => columns.find((c) => c.name === 'movie')?.isVisible ?? false,
[columns]
);
const [selectModalOpen, setSelectModalOpen] = useState<SelectType | null>(
null
);
useEffect(
() => {
if (allowMovieChange && movie && quality && languages) {
onSelectedChange({
id,
hasMovieFileId: !!movieFileId,
value: true,
shiftKey: false,
});
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
useEffect(() => {
const isValid = !!(movie && quality && languages);
if (isSelected && !isValid) {
onValidRowChange(id, false);
} else {
onValidRowChange(id, true);
}
}, [id, movie, quality, languages, isSelected, onValidRowChange]);
const onSelectedChangeWrapper = useCallback(
(result: SelectedChangeProps) => {
onSelectedChange({
...result,
hasMovieFileId: !!movieFileId,
});
},
[movieFileId, onSelectedChange]
);
const selectRowAfterChange = useCallback(() => {
if (!isSelected) {
onSelectedChange({
id,
hasMovieFileId: !!movieFileId,
value: true,
shiftKey: false,
});
}
}, [id, movieFileId, isSelected, onSelectedChange]);
const onSelectModalClose = useCallback(() => {
setSelectModalOpen(null);
}, [setSelectModalOpen]);
const onSelectMoviePress = useCallback(() => {
setSelectModalOpen('movie');
}, [setSelectModalOpen]);
const onMovieSelect = useCallback(
(movie: Movie) => {
dispatch(
updateInteractiveImportItem({
id,
movie,
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const onSelectReleaseGroupPress = useCallback(() => {
setSelectModalOpen('releaseGroup');
}, [setSelectModalOpen]);
const onReleaseGroupSelect = useCallback(
(releaseGroup: string) => {
dispatch(
updateInteractiveImportItem({
id,
releaseGroup,
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const onSelectQualityPress = useCallback(() => {
setSelectModalOpen('quality');
}, [setSelectModalOpen]);
const onQualitySelect = useCallback(
(quality: QualityModel) => {
dispatch(
updateInteractiveImportItem({
id,
quality,
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const onSelectLanguagePress = useCallback(() => {
setSelectModalOpen('language');
}, [setSelectModalOpen]);
const onLanguagesSelect = useCallback(
(languages: Language[]) => {
dispatch(
updateInteractiveImportItem({
id,
languages,
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const movieTitle = movie ? movie.title : '';
const showMoviePlaceholder = isSelected && !movie;
const showReleaseGroupPlaceholder = isSelected && !releaseGroup;
const showQualityPlaceholder = isSelected && !quality;
const showLanguagePlaceholder = isSelected && !languages;
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChangeWrapper}
/>
<TableRowCell className={styles.relativePath} title={relativePath}>
{relativePath}
</TableRowCell>
{isMovieColumnVisible ? (
<TableRowCellButton
isDisabled={!allowMovieChange}
title={allowMovieChange ? translate('ClickToChangeMovie') : undefined}
onPress={onSelectMoviePress}
>
{showMoviePlaceholder ? (
<InteractiveImportRowCellPlaceholder />
) : (
movieTitle
)}
</TableRowCellButton>
) : null}
<TableRowCellButton
title={translate('ClickToChangeReleaseGroup')}
onPress={onSelectReleaseGroupPress}
>
{showReleaseGroupPlaceholder ? (
<InteractiveImportRowCellPlaceholder isOptional={true} />
) : (
releaseGroup
)}
</TableRowCellButton>
<TableRowCellButton
className={styles.quality}
title={translate('ClickToChangeQuality')}
onPress={onSelectQualityPress}
>
{showQualityPlaceholder && <InteractiveImportRowCellPlaceholder />}
{!showQualityPlaceholder && !!quality && (
<MovieQuality className={styles.label} quality={quality} />
)}
</TableRowCellButton>
<TableRowCellButton
className={styles.languages}
title={translate('ClickToChangeLanguage')}
onPress={onSelectLanguagePress}
>
{showLanguagePlaceholder && <InteractiveImportRowCellPlaceholder />}
{!showLanguagePlaceholder && !!languages && (
<MovieLanguage className={styles.label} languages={languages} />
)}
</TableRowCellButton>
<TableRowCell>{formatBytes(size)}</TableRowCell>
<TableRowCell>
{customFormats?.length ? (
<Popover
anchor={<Icon name={icons.INTERACTIVE} />}
title={translate('Formats')}
body={
<div className={styles.customFormatTooltip}>
<MovieFormats formats={customFormats} />
</div>
}
position={tooltipPositions.LEFT}
/>
) : null}
</TableRowCell>
<TableRowCell>
{rejections.length ? (
<Popover
anchor={<Icon name={icons.DANGER} kind={kinds.DANGER} />}
title={translate('ReleaseRejected')}
body={
<ul>
{rejections.map((rejection, index) => {
return <li key={index}>{rejection.reason}</li>;
})}
</ul>
}
position={tooltipPositions.LEFT}
canFlip={false}
/>
) : null}
</TableRowCell>
<SelectMovieModal
isOpen={selectModalOpen === 'movie'}
modalTitle={modalTitle}
onMovieSelect={onMovieSelect}
onModalClose={onSelectModalClose}
/>
<SelectReleaseGroupModal
isOpen={selectModalOpen === 'releaseGroup'}
releaseGroup={releaseGroup ?? ''}
modalTitle={modalTitle}
onReleaseGroupSelect={onReleaseGroupSelect}
onModalClose={onSelectModalClose}
/>
<SelectQualityModal
isOpen={selectModalOpen === 'quality'}
qualityId={quality ? quality.quality.id : 0}
proper={quality ? quality.revision.version > 1 : false}
real={quality ? quality.revision.real > 0 : false}
modalTitle={modalTitle}
onQualitySelect={onQualitySelect}
onModalClose={onSelectModalClose}
/>
<SelectLanguageModal
isOpen={selectModalOpen === 'language'}
languageIds={languages ? languages.map((l) => l.id) : []}
modalTitle={modalTitle}
onLanguagesSelect={onLanguagesSelect}
onModalClose={onSelectModalClose}
/>
</TableRow>
);
}
export default InteractiveImportRow;

@ -5,3 +5,7 @@
height: 25px;
border: 2px dashed var(--dangerColor);
}
.optional {
border: 2px dashed var(--gray);
}

@ -1,6 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'optional': string;
'placeholder': string;
}
export const cssExports: CssExports;

@ -1,10 +0,0 @@
import React from 'react';
import styles from './InteractiveImportRowCellPlaceholder.css';
function InteractiveImportRowCellPlaceholder() {
return (
<span className={styles.placeholder} />
);
}
export default InteractiveImportRowCellPlaceholder;

@ -0,0 +1,22 @@
import classNames from 'classnames';
import React from 'react';
import styles from './InteractiveImportRowCellPlaceholder.css';
interface InteractiveImportRowCellPlaceholderProps {
isOptional?: boolean;
}
function InteractiveImportRowCellPlaceholder(
props: InteractiveImportRowCellPlaceholderProps
) {
return (
<span
className={classNames(
styles.placeholder,
props.isOptional && styles.optional
)}
/>
);
}
export default InteractiveImportRowCellPlaceholder;

@ -0,0 +1,34 @@
import ModelBase from 'App/ModelBase';
import Language from 'Language/Language';
import Movie from 'Movie/Movie';
import { QualityModel } from 'Quality/Quality';
import Rejection from 'typings/Rejection';
export interface InteractiveImportCommandOptions {
path: string;
folderName: string;
movieId: number;
releaseGroup?: string;
quality: QualityModel;
languages: Language[];
downloadId?: string;
movieFileId?: number;
}
interface InteractiveImport extends ModelBase {
path: string;
relativePath: string;
folderName: string;
name: string;
size: number;
releaseGroup: string;
quality: QualityModel;
languages: Language[];
movie?: Movie;
qualityWeight: number;
customFormats: object[];
rejections: Rejection[];
movieFileId?: number;
}
export default InteractiveImport;

@ -1,79 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import InteractiveImportSelectFolderModalContentConnector from './Folder/InteractiveImportSelectFolderModalContentConnector';
import InteractiveImportModalContentConnector from './Interactive/InteractiveImportModalContentConnector';
class InteractiveImportModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
folder: null
};
}
componentDidUpdate(prevProps) {
if (prevProps.isOpen && !this.props.isOpen) {
this.setState({ folder: null });
}
}
//
// Listeners
onFolderSelect = (folder) => {
this.setState({ folder });
};
//
// Render
render() {
const {
isOpen,
folder,
downloadId,
onModalClose,
...otherProps
} = this.props;
const folderPath = folder || this.state.folder;
return (
<Modal
isOpen={isOpen}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
{
folderPath || downloadId ?
<InteractiveImportModalContentConnector
folder={folderPath}
downloadId={downloadId}
{...otherProps}
onModalClose={onModalClose}
/> :
<InteractiveImportSelectFolderModalContentConnector
{...otherProps}
onFolderSelect={this.onFolderSelect}
onModalClose={onModalClose}
/>
}
</Modal>
);
}
}
InteractiveImportModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
folder: PropTypes.string,
downloadId: PropTypes.string,
onModalClose: PropTypes.func.isRequired
};
export default InteractiveImportModal;

@ -0,0 +1,74 @@
import React, { useCallback, useEffect, useState } from 'react';
import Modal from 'Components/Modal/Modal';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import InteractiveImportSelectFolderModalContent from './Folder/InteractiveImportSelectFolderModalContent';
import InteractiveImportModalContent from './Interactive/InteractiveImportModalContent';
interface InteractiveImportModalProps {
isOpen: boolean;
folder?: string;
downloadId?: string;
modalTitle?: string;
onModalClose(): void;
}
function InteractiveImportModal(props: InteractiveImportModalProps) {
const {
isOpen,
folder,
downloadId,
modalTitle = translate('ManualImport'),
onModalClose,
...otherProps
} = props;
const [folderPath, setFolderPath] = useState<string | undefined>(folder);
const previousIsOpen = usePrevious(isOpen);
const onFolderSelect = useCallback(
(path: string) => {
setFolderPath(path);
},
[setFolderPath]
);
useEffect(() => {
setFolderPath(folder);
}, [folder, setFolderPath]);
useEffect(() => {
if (previousIsOpen && !isOpen) {
setFolderPath(folder);
}
}, [folder, previousIsOpen, isOpen, setFolderPath]);
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
{folderPath || downloadId ? (
<InteractiveImportModalContent
{...otherProps}
folder={folderPath}
downloadId={downloadId}
modalTitle={modalTitle}
onModalClose={onModalClose}
/>
) : (
<InteractiveImportSelectFolderModalContent
{...otherProps}
modalTitle={modalTitle}
onFolderSelect={onFolderSelect}
onModalClose={onModalClose}
/>
)}
</Modal>
);
}
export default InteractiveImportModal;

@ -1,39 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import SelectLanguageModalContentConnector from './SelectLanguageModalContentConnector';
class SelectLanguageModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
size={sizes.MEDIUM}
>
<SelectLanguageModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
SelectLanguageModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectLanguageModal;

@ -0,0 +1,31 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import Language from 'Language/Language';
import SelectLanguageModalContent from './SelectLanguageModalContent';
interface SelectLanguageModalProps {
isOpen: boolean;
languageIds: number[];
modalTitle: string;
onLanguagesSelect(languages: Language[]): void;
onModalClose(): void;
}
function SelectLanguageModal(props: SelectLanguageModalProps) {
const { isOpen, languageIds, modalTitle, onLanguagesSelect, onModalClose } =
props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}>
<SelectLanguageModalContent
languageIds={languageIds}
modalTitle={modalTitle}
onLanguagesSelect={onLanguagesSelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectLanguageModal;

@ -1,153 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './SelectLanguageModalContent.css';
class SelectLanguageModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
languageIds
} = props;
this.state = {
languageIds
};
}
//
// Listeners
onLanguageChange = ({ value, name }) => {
const {
languageIds
} = this.state;
const changedId = parseInt(name);
let newLanguages = languageIds;
if (value) {
newLanguages.push(changedId);
}
if (!value) {
newLanguages = languageIds.filter((i) => i !== changedId);
}
this.setState({ languageIds: newLanguages });
};
onLanguageSelect = () => {
this.props.onLanguageSelect(this.state);
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
onModalClose
} = this.props;
const {
languageIds
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('ManualImportSelectLanguage')}
</ModalHeader>
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>
{translate('UnableToLoadLanguages')}
</div>
}
{
isPopulated && !error &&
<Form>
{
items.map(( language ) => {
return (
<FormGroup
key={language.id}
size={sizes.EXTRA_SMALL}
className={styles.languageInput}
>
<FormLabel>{language.name}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name={language.id.toString()}
value={languageIds.includes(language.id)}
onChange={this.onLanguageChange}
/>
</FormGroup>
);
})
}
</Form>
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
{translate('Cancel')}
</Button>
<Button
kind={kinds.SUCCESS}
onPress={this.onLanguageSelect}
>
{translate('SelectLanguages')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
SelectLanguageModalContent.propTypes = {
languageIds: PropTypes.arrayOf(PropTypes.number).isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onLanguageSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
SelectLanguageModalContent.defaultProps = {
languages: []
};
export default SelectLanguageModalContent;

@ -0,0 +1,126 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { LanguageSettingsAppState } from 'App/State/SettingsAppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import Language from 'Language/Language';
import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
import translate from 'Utilities/String/translate';
import styles from './SelectLanguageModalContent.css';
interface SelectLanguageModalContentProps {
languageIds: number[];
modalTitle: string;
onLanguagesSelect(languages: Language[]): void;
onModalClose(): void;
}
function createFilteredLanguagesSelector() {
return createSelector(createLanguagesSelector(), (languages) => {
const { isFetching, isPopulated, error, items } =
languages as LanguageSettingsAppState;
const filterItems = ['Any', 'Original'];
const filteredLanguages = items.filter(
(lang: Language) => !filterItems.includes(lang.name)
);
return {
isFetching,
isPopulated,
error,
items: filteredLanguages,
};
});
}
function SelectLanguageModalContent(props: SelectLanguageModalContentProps) {
const { modalTitle, onLanguagesSelect, onModalClose } = props;
const { isFetching, isPopulated, error, items } = useSelector(
createFilteredLanguagesSelector()
);
const [languageIds, setLanguageIds] = useState(props.languageIds);
const onLanguageChange = useCallback(
({ name, value }: { name: string; value: boolean }) => {
const changedId = parseInt(name);
let newLanguages = [...languageIds];
if (value) {
newLanguages.push(changedId);
} else {
newLanguages = languageIds.filter((i) => i !== changedId);
}
setLanguageIds(newLanguages);
},
[languageIds, setLanguageIds]
);
const onLanguagesSelectWrapper = useCallback(() => {
const languages = items.filter((lang) => languageIds.includes(lang.id));
onLanguagesSelect(languages);
}, [items, languageIds, onLanguagesSelect]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle} - {translate('SelectLanguage')}
</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<div>{translate('UnableToLoadLanguages')}</div>
) : null}
{isPopulated && !error ? (
<Form>
{items.map((language) => {
return (
<FormGroup
key={language.id}
size={sizes.EXTRA_SMALL}
className={styles.languageInput}
>
<FormLabel>{language.name}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name={language.id.toString()}
value={languageIds.includes(language.id)}
onChange={onLanguageChange}
/>
</FormGroup>
);
})}
</Form>
) : null}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.SUCCESS} onPress={onLanguagesSelectWrapper}>
{translate('SelectLanguages')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default SelectLanguageModalContent;

@ -1,107 +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 { reprocessInteractiveImportItems, updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions';
import { fetchLanguages } from 'Store/Actions/settingsActions';
import SelectLanguageModalContent from './SelectLanguageModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.languages,
(languages) => {
const {
isFetching,
isPopulated,
error,
items
} = languages;
const filterItems = ['Any', 'Original'];
const filteredLanguages = items.filter((lang) => !filterItems.includes(lang.name));
return {
isFetching,
isPopulated,
error,
items: filteredLanguages
};
}
);
}
const mapDispatchToProps = {
dispatchFetchLanguages: fetchLanguages,
dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems,
dispatchUpdateInteractiveImportItems: updateInteractiveImportItems
};
class SelectLanguageModalContentConnector extends Component {
//
// Lifecycle
componentDidMount = () => {
if (!this.props.isPopulated) {
this.props.dispatchFetchLanguages();
}
};
//
// Listeners
onLanguageSelect = ({ languageIds }) => {
const {
ids,
dispatchUpdateInteractiveImportItems,
dispatchReprocessInteractiveImportItems
} = this.props;
const languages = [];
languageIds.forEach((languageId) => {
const language = _.find(this.props.items,
(item) => item.id === parseInt(languageId));
if (language !== undefined) {
languages.push(language);
}
});
dispatchUpdateInteractiveImportItems({
ids,
languages
});
dispatchReprocessInteractiveImportItems({ ids });
this.props.onModalClose(true);
};
//
// Render
render() {
return (
<SelectLanguageModalContent
{...this.props}
onLanguageSelect={this.onLanguageSelect}
/>
);
}
}
SelectLanguageModalContentConnector.propTypes = {
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
dispatchFetchLanguages: PropTypes.func.isRequired,
dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired,
dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SelectLanguageModalContentConnector);

@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import SelectMovieModalContentConnector from './SelectMovieModalContentConnector';
class SelectMovieModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<SelectMovieModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
SelectMovieModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectMovieModal;

@ -0,0 +1,27 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import Movie from 'Movie/Movie';
import SelectMovieModalContent from './SelectMovieModalContent';
interface SelectMovieModalProps {
isOpen: boolean;
modalTitle: string;
onMovieSelect(movie: Movie): void;
onModalClose(): void;
}
function SelectMovieModal(props: SelectMovieModalProps) {
const { isOpen, modalTitle, onMovieSelect, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<SelectMovieModalContent
modalTitle={modalTitle}
onMovieSelect={onMovieSelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectMovieModal;

@ -1,237 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextInput from 'Components/Form/TextInput';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import FuseWorker from 'Components/Page/Header/fuse.worker';
import Scroller from 'Components/Scroller/Scroller';
import VirtualTable from 'Components/Table/VirtualTable';
import VirtualTableRow from 'Components/Table/VirtualTableRow';
import { scrollDirections } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import SelectMovieRow from './SelectMovieRow';
import styles from './SelectMovieModalContent.css';
class SelectMovieModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._worker = null;
this.state = {
scroller: null,
filter: '',
loading: false,
suggestions: props.items
};
}
componentWillUnmount() {
if (this._worker) {
this._worker.removeEventListener('message', this.onSuggestionsReceived, false);
this._worker.terminate();
this._worker = null;
}
}
getWorker() {
if (!this._worker) {
this._worker = new FuseWorker();
this._worker.addEventListener('message', this.onSuggestionsReceived, false);
}
return this._worker;
}
//
// Control
setScrollerRef = (ref) => {
this.setState({ scroller: ref });
};
rowRenderer = ({ key, rowIndex, style }) => {
const item = this.state.suggestions[rowIndex];
return (
<VirtualTableRow
key={key}
style={style}
>
<SelectMovieRow
key={item.id}
id={item.id}
title={item.title}
year={item.year}
onMovieSelect={this.props.onMovieSelect}
/>
</VirtualTableRow>
);
};
//
// Listeners
onFilterChange = ({ value }) => {
if (value) {
this.setState({
loading: true,
filter: value.toLowerCase()
});
this.requestSuggestions(value);
} else {
this.setState({
loading: false,
filter: '',
suggestions: this.props.items
});
this.requestSuggestions.cancel();
}
};
requestSuggestions = _.debounce((value) => {
if (!this.state.loading) {
return;
}
const requestLoading = this.state.requestLoading;
this.setState({
requestValue: value,
requestLoading: true
});
if (!requestLoading) {
const payload = {
value,
movies: this.props.items
};
this.getWorker().postMessage(payload);
}
}, 250);
onSuggestionsReceived = (message) => {
const {
value,
suggestions
} = message.data;
if (!this.state.loading) {
this.setState({
requestValue: null,
requestLoading: false
});
} else if (value === this.state.requestValue) {
this.setState({
suggestions: suggestions.map((suggestion) => suggestion.item),
requestValue: null,
requestLoading: false,
loading: false
});
} else {
this.setState({
suggestions: suggestions.map((suggestion) => suggestion.item),
requestLoading: true
});
const payload = {
value: this.state.requestValue,
movies: this.props.items
};
this.getWorker().postMessage(payload);
}
};
//
// Render
render() {
const {
relativePath,
onModalClose
} = this.props;
const {
scroller,
filter,
loading,
suggestions
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
<div className={styles.header}>
{translate('ManualImportSelectMovie')}
</div>
</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<TextInput
className={styles.filterInput}
placeholder={translate('FilterPlaceHolder')}
name="filter"
value={filter}
autoFocus={true}
onChange={this.onFilterChange}
/>
<Scroller
registerScroller={this.setScrollerRef}
className={styles.scroller}
autoFocus={false}
>
<div>
{
loading || !scroller ?
<LoadingIndicator /> :
<VirtualTable
header={
<div />
}
items={suggestions}
isSmallScreen={false}
scroller={scroller}
focusScroller={false}
rowRenderer={this.rowRenderer}
/>
}
</div>
</Scroller>
</ModalBody>
<ModalFooter className={styles.footer}>
<div className={styles.path}>{relativePath}</div>
<div className={styles.buttons}>
<Button onPress={onModalClose}>
{translate('Cancel')}
</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
}
SelectMovieModalContent.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
relativePath: PropTypes.string.isRequired,
onMovieSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectMovieModalContent;

@ -0,0 +1,93 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import TextInput from 'Components/Form/TextInput';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Scroller from 'Components/Scroller/Scroller';
import { scrollDirections } from 'Helpers/Props';
import Movie from 'Movie/Movie';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import SelectMovieRow from './SelectMovieRow';
import styles from './SelectMovieModalContent.css';
interface SelectMovieModalContentProps {
modalTitle: string;
onMovieSelect(movie: Movie): void;
onModalClose(): void;
}
function SelectMovieModalContent(props: SelectMovieModalContentProps) {
const { modalTitle, onMovieSelect, onModalClose } = props;
const allMovies: Movie[] = useSelector(createAllMoviesSelector());
const [filter, setFilter] = useState('');
const onFilterChange = useCallback(
({ value }: { value: string }) => {
setFilter(value);
},
[setFilter]
);
const onMovieSelectWrapper = useCallback(
(movieId: number) => {
const movie = allMovies.find((s) => s.id === movieId) as Movie;
onMovieSelect(movie);
},
[allMovies, onMovieSelect]
);
const items = useMemo(() => {
const sorted = [...allMovies].sort((a, b) =>
a.sortTitle.localeCompare(b.sortTitle)
);
return sorted.filter((item) =>
item.title.toLowerCase().includes(filter.toLowerCase())
);
}, [allMovies, filter]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{modalTitle} - Select Movie</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<TextInput
className={styles.filterInput}
placeholder="Filter movies"
name="filter"
value={filter}
autoFocus={true}
onChange={onFilterChange}
/>
<Scroller className={styles.scroller} autoFocus={false}>
{items.map((item) => {
return (
<SelectMovieRow
key={item.id}
id={item.id}
title={item.title}
year={item.year}
onMovieSelect={onMovieSelectWrapper}
/>
);
})}
</Scroller>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
);
}
export default SelectMovieModalContent;

@ -1,115 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { reprocessInteractiveImportItems, updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
import SelectMovieModalContent from './SelectMovieModalContent';
function createCleanMovieSelector() {
return createSelector(
createAllMoviesSelector(),
(items) => {
return items.map((movie) => {
const {
id,
title,
titleSlug,
sortTitle,
year,
images,
alternateTitles = []
} = movie;
return {
id,
title,
titleSlug,
sortTitle,
year,
images,
alternateTitles,
firstCharacter: title.charAt(0).toLowerCase()
};
}).sort((a, b) => {
if (a.sortTitle < b.sortTitle) {
return -1;
}
if (a.sortTitle > b.sortTitle) {
return 1;
}
return 0;
});
}
);
}
function createMapStateToProps() {
return createDeepEqualSelector(
createCleanMovieSelector(),
(movies) => {
return {
items: movies
};
}
);
}
const mapDispatchToProps = {
dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems,
dispatchUpdateInteractiveImportItem: updateInteractiveImportItem
};
class SelectMovieModalContentConnector extends Component {
//
// Listeners
onMovieSelect = (movieId) => {
const {
ids,
items,
dispatchUpdateInteractiveImportItem,
dispatchReprocessInteractiveImportItems,
onModalClose
} = this.props;
const movie = items.find((s) => s.id === movieId);
ids.forEach((id) => {
dispatchUpdateInteractiveImportItem({
id,
movie
});
});
dispatchReprocessInteractiveImportItems({ ids });
onModalClose(true);
};
//
// Render
render() {
return (
<SelectMovieModalContent
{...this.props}
onMovieSelect={this.onMovieSelect}
/>
);
}
}
SelectMovieModalContentConnector.propTypes = {
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired,
dispatchUpdateInteractiveImportItem: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SelectMovieModalContentConnector);

@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import SelectQualityModalContentConnector from './SelectQualityModalContentConnector';
class SelectQualityModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<SelectQualityModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
SelectQualityModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectQualityModal;

@ -0,0 +1,41 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { QualityModel } from 'Quality/Quality';
import SelectQualityModalContent from './SelectQualityModalContent';
interface SelectQualityModalProps {
isOpen: boolean;
qualityId: number;
proper: boolean;
real: boolean;
modalTitle: string;
onQualitySelect(quality: QualityModel): void;
onModalClose(): void;
}
function SelectQualityModal(props: SelectQualityModalProps) {
const {
isOpen,
qualityId,
proper,
real,
modalTitle,
onQualitySelect,
onModalClose,
} = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<SelectQualityModalContent
qualityId={qualityId}
proper={proper}
real={real}
modalTitle={modalTitle}
onQualitySelect={onQualitySelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectQualityModal;

@ -1,169 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
class SelectQualityModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
qualityId,
proper,
real
} = props;
this.state = {
qualityId,
proper,
real
};
}
//
// Listeners
onQualityChange = ({ value }) => {
this.setState({ qualityId: parseInt(value) });
};
onProperChange = ({ value }) => {
this.setState({ proper: value });
};
onRealChange = ({ value }) => {
this.setState({ real: value });
};
onQualitySelect = () => {
this.props.onQualitySelect(this.state);
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
onModalClose
} = this.props;
const {
qualityId,
proper,
real
} = this.state;
const qualityOptions = items.map(({ id, name }) => {
return {
key: id,
value: name
};
});
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('ManualImportSelectQuality')}
</ModalHeader>
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>
{translate('UnableToLoadQualities')}
</div>
}
{
isPopulated && !error &&
<Form>
<FormGroup>
<FormLabel>{translate('Quality')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="quality"
value={qualityId}
values={qualityOptions}
onChange={this.onQualityChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Proper')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="proper"
value={proper}
onChange={this.onProperChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Real')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="real"
value={real}
onChange={this.onRealChange}
/>
</FormGroup>
</Form>
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
{translate('Cancel')}
</Button>
<Button
kind={kinds.SUCCESS}
onPress={this.onQualitySelect}
>
{translate('SelectQuality')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
SelectQualityModalContent.propTypes = {
qualityId: PropTypes.number.isRequired,
proper: PropTypes.bool.isRequired,
real: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onQualitySelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectQualityModalContent;

@ -0,0 +1,186 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { Error } from 'App/State/AppSectionState';
import AppState from 'App/State/AppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import Quality, { QualityModel } from 'Quality/Quality';
import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
import { CheckInputChanged } from 'typings/inputs';
import getQualities from 'Utilities/Quality/getQualities';
import translate from 'Utilities/String/translate';
interface QualitySchemaState {
isFetching: boolean;
isPopulated: boolean;
error: Error;
items: Quality[];
}
function createQualitySchemaSelector() {
return createSelector(
(state: AppState) => state.settings.qualityProfiles,
(qualityProfiles): QualitySchemaState => {
const { isSchemaFetching, isSchemaPopulated, schemaError, schema } =
qualityProfiles;
const items = getQualities(schema.items) as Quality[];
return {
isFetching: isSchemaFetching,
isPopulated: isSchemaPopulated,
error: schemaError,
items,
};
}
);
}
interface SelectQualityModalContentProps {
qualityId: number;
proper: boolean;
real: boolean;
modalTitle: string;
onQualitySelect(quality: QualityModel): void;
onModalClose(): void;
}
function SelectQualityModalContent(props: SelectQualityModalContentProps) {
const { modalTitle, onQualitySelect, onModalClose } = props;
const [qualityId, setQualityId] = useState(props.qualityId);
const [proper, setProper] = useState(props.proper);
const [real, setReal] = useState(props.real);
const { isFetching, isPopulated, error, items } = useSelector(
createQualitySchemaSelector()
);
const dispatch = useDispatch();
useEffect(
() => {
dispatch(fetchQualityProfileSchema());
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const qualityOptions = useMemo(() => {
return items.map(({ id, name }) => {
return {
key: id,
value: name,
};
});
}, [items]);
const onQualityChange = useCallback(
({ value }: { value: string }) => {
setQualityId(parseInt(value));
},
[setQualityId]
);
const onProperChange = useCallback(
({ value }: CheckInputChanged) => {
setProper(value);
},
[setProper]
);
const onRealChange = useCallback(
({ value }: CheckInputChanged) => {
setReal(value);
},
[setReal]
);
const onQualitySelectWrapper = useCallback(() => {
const quality = items.find((item) => item.id === qualityId) as Quality;
const revision = {
version: proper ? 2 : 1,
real: real ? 1 : 0,
isRepack: false,
};
onQualitySelect({
quality,
revision,
});
}, [items, qualityId, proper, real, onQualitySelect]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle} - {translate('SelectQuality')}
</ModalHeader>
<ModalBody>
{isFetching && <LoadingIndicator />}
{!isFetching && error ? (
<div>{translate('UnableToLoadQualities')}</div>
) : null}
{isPopulated && !error ? (
<Form>
<FormGroup>
<FormLabel>{translate('Quality')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="quality"
value={qualityId}
values={qualityOptions}
onChange={onQualityChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Proper')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="proper"
value={proper}
onChange={onProperChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Real')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="real"
value={real}
onChange={onRealChange}
/>
</FormGroup>
</Form>
) : null}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.SUCCESS} onPress={onQualitySelectWrapper}>
{translate('SelectQuality')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default SelectQualityModalContent;

@ -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 { reprocessInteractiveImportItems, updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions';
import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
import getQualities from 'Utilities/Quality/getQualities';
import SelectQualityModalContent from './SelectQualityModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.qualityProfiles,
(qualityProfiles) => {
const {
isSchemaFetching: isFetching,
isSchemaPopulated: isPopulated,
schemaError: error,
schema
} = qualityProfiles;
return {
isFetching,
isPopulated,
error,
items: getQualities(schema.items)
};
}
);
}
const mapDispatchToProps = {
dispatchFetchQualityProfileSchema: fetchQualityProfileSchema,
dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems,
dispatchUpdateInteractiveImportItems: updateInteractiveImportItems
};
class SelectQualityModalContentConnector extends Component {
//
// Lifecycle
componentDidMount = () => {
if (!this.props.isPopulated) {
this.props.dispatchFetchQualityProfileSchema();
}
};
//
// Listeners
onQualitySelect = ({ qualityId, proper, real }) => {
const {
ids,
dispatchUpdateInteractiveImportItems,
dispatchReprocessInteractiveImportItems
} = this.props;
const quality = _.find(this.props.items,
(item) => item.id === qualityId);
const revision = {
version: proper ? 2 : 1,
real: real ? 1 : 0
};
dispatchUpdateInteractiveImportItems({
ids,
quality: {
quality,
revision
}
});
dispatchReprocessInteractiveImportItems({ ids });
this.props.onModalClose(true);
};
//
// Render
render() {
return (
<SelectQualityModalContent
{...this.props}
onQualitySelect={this.onQualitySelect}
/>
);
}
}
SelectQualityModalContentConnector.propTypes = {
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
dispatchFetchQualityProfileSchema: PropTypes.func.isRequired,
dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired,
dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SelectQualityModalContentConnector);

@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import SelectReleaseGroupModalContentConnector from './SelectReleaseGroupModalContentConnector';
class SelectReleaseGroupModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<SelectReleaseGroupModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
SelectReleaseGroupModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectReleaseGroupModal;

@ -0,0 +1,34 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import SelectReleaseGroupModalContent from './SelectReleaseGroupModalContent';
interface SelectReleaseGroupModalProps {
isOpen: boolean;
releaseGroup: string;
modalTitle: string;
onReleaseGroupSelect(releaseGroup: string): void;
onModalClose(): void;
}
function SelectReleaseGroupModal(props: SelectReleaseGroupModalProps) {
const {
isOpen,
releaseGroup,
modalTitle,
onReleaseGroupSelect,
onModalClose,
} = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<SelectReleaseGroupModalContent
releaseGroup={releaseGroup}
modalTitle={modalTitle}
onReleaseGroupSelect={onReleaseGroupSelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectReleaseGroupModal;

@ -1,104 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
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 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, scrollDirections } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './SelectReleaseGroupModalContent.css';
class SelectReleaseGroupModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
releaseGroup
} = props;
this.state = {
releaseGroup
};
}
//
// Listeners
onReleaseGroupChange = ({ value }) => {
this.setState({ releaseGroup: value });
};
onReleaseGroupSelect = () => {
this.props.onReleaseGroupSelect(this.state);
};
//
// Render
render() {
const {
onModalClose
} = this.props;
const {
releaseGroup
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('ManualImportSetReleaseGroup')}
</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<Form>
<FormGroup>
<FormLabel>{translate('ReleaseGroup')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="releaseGroup"
value={releaseGroup}
autoFocus={true}
onChange={this.onReleaseGroupChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
{translate('Cancel')}
</Button>
<Button
kind={kinds.SUCCESS}
onPress={this.onReleaseGroupSelect}
>
{translate('SetReleaseGroup')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
SelectReleaseGroupModalContent.propTypes = {
releaseGroup: PropTypes.string.isRequired,
onReleaseGroupSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectReleaseGroupModalContent;

@ -0,0 +1,75 @@
import React, { useCallback, useState } from 'react';
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 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, scrollDirections } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './SelectReleaseGroupModalContent.css';
interface SelectReleaseGroupModalContentProps {
releaseGroup: string;
modalTitle: string;
onReleaseGroupSelect(releaseGroup: string): void;
onModalClose(): void;
}
function SelectReleaseGroupModalContent(
props: SelectReleaseGroupModalContentProps
) {
const { modalTitle, onReleaseGroupSelect, onModalClose } = props;
const [releaseGroup, setReleaseGroup] = useState(props.releaseGroup);
const onReleaseGroupChange = useCallback(
({ value }: { value: string }) => {
setReleaseGroup(value);
},
[setReleaseGroup]
);
const onReleaseGroupSelectWrapper = useCallback(() => {
onReleaseGroupSelect(releaseGroup);
}, [releaseGroup, onReleaseGroupSelect]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle} - {translate('SetReleaseGroup')}
</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<Form>
<FormGroup>
<FormLabel>{translate('ReleaseGroup')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="releaseGroup"
value={releaseGroup}
autoFocus={true}
onChange={onReleaseGroupChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
<Button kind={kinds.SUCCESS} onPress={onReleaseGroupSelectWrapper}>
{translate('SetReleaseGroup')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default SelectReleaseGroupModalContent;

@ -1,54 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { reprocessInteractiveImportItems, updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions';
import SelectReleaseGroupModalContent from './SelectReleaseGroupModalContent';
const mapDispatchToProps = {
dispatchUpdateInteractiveImportItems: updateInteractiveImportItems,
dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems
};
class SelectReleaseGroupModalContentConnector extends Component {
//
// Listeners
onReleaseGroupSelect = ({ releaseGroup }) => {
const {
ids,
dispatchUpdateInteractiveImportItems,
dispatchReprocessInteractiveImportItems
} = this.props;
dispatchUpdateInteractiveImportItems({
ids,
releaseGroup
});
dispatchReprocessInteractiveImportItems({ ids });
this.props.onModalClose(true);
};
//
// Render
render() {
return (
<SelectReleaseGroupModalContent
{...this.props}
onReleaseGroupSelect={this.onReleaseGroupSelect}
/>
);
}
}
SelectReleaseGroupModalContentConnector.propTypes = {
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired,
dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(null, mapDispatchToProps)(SelectReleaseGroupModalContentConnector);

@ -0,0 +1,6 @@
interface Language {
id: number;
name: string;
}
export default Language;

@ -1,10 +1,18 @@
import PropTypes from 'prop-types';
import React from 'react';
import { CustomFilter } from 'App/State/AppState';
import FilterMenu from 'Components/Menu/FilterMenu';
import { align } from 'Helpers/Props';
import MovieIndexFilterModal from 'Movie/Index/MovieIndexFilterModal';
function MovieIndexFilterMenu(props) {
interface MovieIndexFilterMenuProps {
selectedFilterKey: string | number;
filters: object[];
customFilters: CustomFilter[];
isDisabled: boolean;
onFilterSelect(filterName: string): unknown;
}
function MovieIndexFilterMenu(props: MovieIndexFilterMenuProps) {
const {
selectedFilterKey,
filters,
@ -26,15 +34,6 @@ function MovieIndexFilterMenu(props) {
);
}
MovieIndexFilterMenu.propTypes = {
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool.isRequired,
onFilterSelect: PropTypes.func.isRequired,
};
MovieIndexFilterMenu.defaultProps = {
showCustomFilters: false,
};

@ -1,12 +1,19 @@
import PropTypes from 'prop-types';
import React from 'react';
import MenuContent from 'Components/Menu/MenuContent';
import SortMenu from 'Components/Menu/SortMenu';
import SortMenuItem from 'Components/Menu/SortMenuItem';
import { align, sortDirections } from 'Helpers/Props';
import { align } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import translate from 'Utilities/String/translate';
function MovieIndexSortMenu(props) {
interface MovieIndexSortMenuProps {
sortKey?: string;
sortDirection?: SortDirection;
isDisabled: boolean;
onSortSelect(sortKey: string): unknown;
}
function MovieIndexSortMenu(props: MovieIndexSortMenuProps) {
const { sortKey, sortDirection, isDisabled, onSortSelect } = props;
return (
@ -160,11 +167,4 @@ function MovieIndexSortMenu(props) {
);
}
MovieIndexSortMenu.propTypes = {
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
isDisabled: PropTypes.bool.isRequired,
onSortSelect: PropTypes.func.isRequired,
};
export default MovieIndexSortMenu;

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react';
import MenuContent from 'Components/Menu/MenuContent';
import ViewMenu from 'Components/Menu/ViewMenu';
@ -6,7 +5,13 @@ import ViewMenuItem from 'Components/Menu/ViewMenuItem';
import { align } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
function MovieIndexViewMenu(props) {
interface MovieIndexViewMenuProps {
view: string;
isDisabled: boolean;
onViewSelect(value: string): unknown;
}
function MovieIndexViewMenu(props: MovieIndexViewMenuProps) {
const { view, isDisabled, onViewSelect } = props;
return (
@ -32,10 +37,4 @@ function MovieIndexViewMenu(props) {
);
}
MovieIndexViewMenu.propTypes = {
view: PropTypes.string.isRequired,
isDisabled: PropTypes.bool.isRequired,
onViewSelect: PropTypes.func.isRequired,
};
export default MovieIndexViewMenu;

@ -1,6 +1,14 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { SelectProvider } from 'App/SelectContext';
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
import MoviesAppState, { MovieIndexAppState } from 'App/State/MoviesAppState';
import { RSS_SYNC } from 'Commands/commandNames';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
@ -17,12 +25,14 @@ import SortDirection from 'Helpers/Props/SortDirection';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import NoMovie from 'Movie/NoMovie';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchMovies } from 'Store/Actions/movieActions';
import {
setMovieFilter,
setMovieSort,
setMovieTableOption,
setMovieView,
} from 'Store/Actions/movieIndexActions';
import { fetchQueueDetails } from 'Store/Actions/queueActions';
import scrollPositions from 'Store/scrollPositions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
@ -77,23 +87,27 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
sortKey,
sortDirection,
view,
} = useSelector(createMovieClientSideCollectionItemsSelector('movieIndex'));
}: MoviesAppState & MovieIndexAppState & ClientSideCollectionAppState =
useSelector(createMovieClientSideCollectionItemsSelector('movieIndex'));
const isRssSyncExecuting = useSelector(
createCommandExecutingSelector(RSS_SYNC)
);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const dispatch = useDispatch();
const scrollerRef = useRef<HTMLDivElement>();
const scrollerRef = useRef<HTMLDivElement>(null);
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
const [isInteractiveImportModalOpen, setIsInteractiveImportModalOpen] =
useState(false);
const [jumpToCharacter, setJumpToCharacter] = useState<string | null>(null);
const [jumpToCharacter, setJumpToCharacter] = useState<string | undefined>(
undefined
);
const [isSelectMode, setIsSelectMode] = useState(false);
const onSelectModePress = useCallback(() => {
setIsSelectMode(!isSelectMode);
}, [isSelectMode, setIsSelectMode]);
useEffect(() => {
dispatch(fetchMovies());
dispatch(fetchQueueDetails({ all: true }));
}, [dispatch]);
const onRssSyncPress = useCallback(() => {
dispatch(
@ -103,15 +117,19 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
);
}, [dispatch]);
const onSelectModePress = useCallback(() => {
setIsSelectMode(!isSelectMode);
}, [isSelectMode, setIsSelectMode]);
const onTableOptionChange = useCallback(
(payload) => {
(payload: unknown) => {
dispatch(setMovieTableOption(payload));
},
[dispatch]
);
const onViewSelect = useCallback(
(value) => {
(value: string) => {
dispatch(setMovieView({ view: value }));
if (scrollerRef.current) {
@ -122,14 +140,14 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
);
const onSortSelect = useCallback(
(value) => {
(value: string) => {
dispatch(setMovieSort({ sortKey: value }));
},
[dispatch]
);
const onFilterSelect = useCallback(
(value) => {
(value: string) => {
dispatch(setMovieFilter({ selectedFilterKey: value }));
},
[dispatch]
@ -152,15 +170,15 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
}, [setIsInteractiveImportModalOpen]);
const onJumpBarItemPress = useCallback(
(character) => {
(character: string) => {
setJumpToCharacter(character);
},
[setJumpToCharacter]
);
const onScroll = useCallback(
({ scrollTop }) => {
setJumpToCharacter(null);
({ scrollTop }: { scrollTop: number }) => {
setJumpToCharacter(undefined);
scrollPositions.movieIndex = scrollTop;
},
[setJumpToCharacter]
@ -174,10 +192,10 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
};
}
const characters = items.reduce((acc, item) => {
const characters = items.reduce((acc: Record<string, number>, item) => {
let char = item.sortTitle.charAt(0);
if (!isNaN(char)) {
if (!isNaN(Number(char))) {
char = '#';
}
@ -312,13 +330,17 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
<PageContentBody
ref={scrollerRef}
className={styles.contentBody}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
innerClassName={styles[`${view}InnerContentBody`]}
initialScrollTop={props.initialScrollTop}
onScroll={onScroll}
>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && !!error ? <div>Unable to load movie</div> : null}
{!isFetching && !!error ? (
<div>{translate('UnableToLoadMovies')}</div>
) : null}
{isLoaded ? (
<div className={styles.contentBodyContainer}>

@ -1,12 +1,13 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import { setMovieFilter } from 'Store/Actions/movieIndexActions';
function createMovieSelector() {
return createSelector(
(state) => state.movies.items,
(state: AppState) => state.movies.items,
(movies) => {
return movies;
}
@ -15,14 +16,20 @@ function createMovieSelector() {
function createFilterBuilderPropsSelector() {
return createSelector(
(state) => state.movieIndex.filterBuilderProps,
(state: AppState) => state.movieIndex.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
export default function MovieIndexFilterModal(props) {
interface MovieIndexFilterModalProps {
isOpen: boolean;
}
export default function MovieIndexFilterModal(
props: MovieIndexFilterModalProps
) {
const sectionItems = useSelector(createMovieSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'movieIndex';
@ -30,7 +37,7 @@ export default function MovieIndexFilterModal(props) {
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload) => {
(payload: unknown) => {
dispatch(setMovieFilter(payload));
},
[dispatch]
@ -38,6 +45,7 @@ export default function MovieIndexFilterModal(props) {
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}

@ -3,6 +3,7 @@ import React from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
import MoviesAppState from 'App/State/MoviesAppState';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
@ -14,13 +15,15 @@ import styles from './MovieIndexFooter.css';
function createUnoptimizedSelector() {
return createSelector(
createClientSideCollectionSelector('movies', 'movieIndex'),
(movies) => {
(movies: MoviesAppState) => {
return movies.items.map((m) => {
const { monitored, status } = m;
const { monitored, status, hasFile, sizeOnDisk } = m;
return {
monitored,
status,
hasFile,
sizeOnDisk,
};
});
}

@ -1,6 +1,8 @@
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelect } from 'App/SelectContext';
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
import MoviesAppState, { MovieIndexAppState } from 'App/State/MoviesAppState';
import { REFRESH_MOVIE } from 'Commands/commandNames';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props';
@ -21,9 +23,11 @@ function MovieIndexRefreshMovieButton(
const isRefreshing = useSelector(
createCommandExecutingSelector(REFRESH_MOVIE)
);
const { items, totalItems } = useSelector(
createMovieClientSideCollectionItemsSelector('movieIndex')
);
const {
items,
totalItems,
}: MoviesAppState & MovieIndexAppState & ClientSideCollectionAppState =
useSelector(createMovieClientSideCollectionItemsSelector('movieIndex'));
const dispatch = useDispatch();
const { isSelectMode, selectedFilterKey } = props;

@ -1,6 +1,8 @@
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelect } from 'App/SelectContext';
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
import MoviesAppState, { MovieIndexAppState } from 'App/State/MoviesAppState';
import { MOVIE_SEARCH } from 'Commands/commandNames';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props';
@ -17,9 +19,11 @@ interface MovieIndexSearchButtonProps {
function MovieIndexSearchButton(props: MovieIndexSearchButtonProps) {
const isSearching = useSelector(createCommandExecutingSelector(MOVIE_SEARCH));
const { items, totalItems } = useSelector(
createMovieClientSideCollectionItemsSelector('movieIndex')
);
const {
items,
totalItems,
}: MoviesAppState & MovieIndexAppState & ClientSideCollectionAppState =
useSelector(createMovieClientSideCollectionItemsSelector('movieIndex'));
const dispatch = useDispatch();
const { isSelectMode, selectedFilterKey } = props;

@ -72,8 +72,6 @@ function MovieIndexOverview(props: MovieIndexOverviewProps) {
tmdbId,
imdbId,
youTubeTrailerId,
queueStatus,
queueState,
} = movie;
const dispatch = useDispatch();
@ -149,14 +147,14 @@ function MovieIndexOverview(props: MovieIndexOverviewProps) {
</div>
<MovieIndexProgressBar
movieId={movieId}
movieFile={movie.movieFile}
monitored={monitored}
hasFile={hasFile}
isAvailable={isAvailable}
status={status}
posterWidth={posterWidth}
width={posterWidth}
detailedProgressBar={overviewOptions.detailedProgressBar}
queueStatus={queueStatus}
queueState={queueState}
bottomRadius={false}
/>
</div>

@ -1,14 +1,45 @@
import { IconDefinition } from '@fortawesome/free-regular-svg-icons';
import React, { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { icons } from 'Helpers/Props';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import dimensions from 'Styles/Variables/dimensions';
import { UiSettings } from 'typings/UiSettings';
import formatDateTime from 'Utilities/Date/formatDateTime';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';
import MovieIndexOverviewInfoRow from './MovieIndexOverviewInfoRow';
import styles from './MovieIndexOverviewInfo.css';
interface RowProps {
name: string;
showProp: string;
valueProp: string;
}
interface RowInfoProps {
title: string;
iconName: IconDefinition;
label: string;
}
interface MovieIndexOverviewInfoProps {
height: number;
showStudio: boolean;
showMonitored: boolean;
showQualityProfile: boolean;
showAdded: boolean;
showPath: boolean;
showSizeOnDisk: boolean;
monitored: boolean;
studio?: string;
qualityProfile: object;
added?: string;
path: string;
sizeOnDisk?: number;
sortKey: string;
}
const infoRowHeight = parseInt(dimensions.movieIndexOverviewInfoRowHeight);
const rows = [
@ -44,7 +75,11 @@ const rows = [
},
];
function getInfoRowProps(row, props, uiSettings) {
function getInfoRowProps(
row: RowProps,
props: MovieIndexOverviewInfoProps,
uiSettings: UiSettings
): RowInfoProps | null {
const { name } = row;
if (name === 'monitored') {
@ -61,7 +96,7 @@ function getInfoRowProps(row, props, uiSettings) {
return {
title: 'Studio',
iconName: icons.STUDIO,
label: props.studio,
label: props.studio ?? '',
};
}
@ -69,6 +104,9 @@ function getInfoRowProps(row, props, uiSettings) {
return {
title: 'Quality Profile',
iconName: icons.PROFILE,
// TODO: Type QualityProfile
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts(2339)
label: props.qualityProfile.name,
};
}
@ -81,10 +119,11 @@ function getInfoRowProps(row, props, uiSettings) {
return {
title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`,
iconName: icons.ADD,
label: getRelativeDate(added, shortDateFormat, showRelativeDates, {
timeFormat,
timeForToday: true,
}),
label:
getRelativeDate(added, shortDateFormat, showRelativeDates, {
timeFormat,
timeForToday: true,
}) ?? '',
};
}
@ -103,21 +142,8 @@ function getInfoRowProps(row, props, uiSettings) {
label: formatBytes(props.sizeOnDisk),
};
}
}
interface MovieIndexOverviewInfoProps {
height: number;
showMonitored: boolean;
showQualityProfile: boolean;
showAdded: boolean;
showPath: boolean;
showSizeOnDisk: boolean;
monitored: boolean;
qualityProfile: object;
added?: string;
path: string;
sizeOnDisk?: number;
sortKey: string;
return null;
}
function MovieIndexOverviewInfo(props: MovieIndexOverviewInfoProps) {
@ -133,6 +159,8 @@ function MovieIndexOverviewInfo(props: MovieIndexOverviewInfoProps) {
const { name, showProp, valueProp } = row;
const isVisible =
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts(7053)
props[valueProp] != null && (props[showProp] || props.sortKey === name);
return {
@ -157,6 +185,10 @@ function MovieIndexOverviewInfo(props: MovieIndexOverviewInfoProps) {
const infoRowProps = getInfoRowProps(row, props, uiSettings);
if (infoRowProps == null) {
return null;
}
return <MovieIndexOverviewInfoRow key={row.name} {...infoRowProps} />;
})}
</div>

@ -1,5 +1,5 @@
import { throttle } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import useMeasure from 'Helpers/Hooks/useMeasure';
@ -33,11 +33,11 @@ interface RowItemData {
interface MovieIndexOverviewsProps {
items: Movie[];
sortKey?: string;
sortKey: string;
sortDirection?: string;
jumpToCharacter?: string;
scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>;
scrollerRef: RefObject<HTMLElement>;
isSelectMode: boolean;
isSmallScreen: boolean;
}
@ -79,7 +79,7 @@ function MovieIndexOverviews(props: MovieIndexOverviewsProps) {
const { size: posterSize, detailedProgressBar } = useSelector(
selectOverviewOptions
);
const listRef: React.MutableRefObject<List> = useRef();
const listRef = useRef<List>(null);
const [measureRef, bounds] = useMeasure();
const [size, setSize] = useState({ width: 0, height: 0 });
@ -136,8 +136,8 @@ function MovieIndexOverviews(props: MovieIndexOverviewsProps) {
}, [isSmallScreen, scrollerRef, bounds]);
useEffect(() => {
const currentScrollListener = isSmallScreen ? window : scrollerRef.current;
const currentScrollerRef = scrollerRef.current;
const currentScrollerRef = scrollerRef.current as HTMLElement;
const currentScrollListener = isSmallScreen ? window : currentScrollerRef;
const handleScroll = throttle(() => {
const { offsetTop = 0 } = currentScrollerRef;
@ -146,7 +146,7 @@ function MovieIndexOverviews(props: MovieIndexOverviewsProps) {
? getWindowScrollTopPosition()
: currentScrollerRef.scrollTop) - offsetTop;
listRef.current.scrollTo(scrollTop);
listRef.current?.scrollTo(scrollTop);
}, 10);
currentScrollListener.addEventListener('scroll', handleScroll);
@ -175,8 +175,8 @@ function MovieIndexOverviews(props: MovieIndexOverviewsProps) {
scrollTop += offset;
}
listRef.current.scrollTo(scrollTop);
scrollerRef.current.scrollTo(0, scrollTop);
listRef.current?.scrollTo(scrollTop);
scrollerRef.current?.scrollTo(0, scrollTop);
}
}
}, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]);

@ -44,7 +44,7 @@ function MovieIndexOverviewOptionsModalContent(
const dispatch = useDispatch();
const onOverviewOptionChange = useCallback(
({ name, value }) => {
({ name, value }: { name: string; value: unknown }) => {
dispatch(setMovieOverviewOption({ [name]: value }));
},
[dispatch]

@ -1,7 +1,8 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
const selectOverviewOptions = createSelector(
(state) => state.movieIndex.overviewOptions,
(state: AppState) => state.movieIndex.overviewOptions,
(overviewOptions) => overviewOptions
);

@ -63,9 +63,8 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
physicalRelease,
digitalRelease,
path,
movieFile,
certification,
queueStatus,
queueState,
} = movie;
const dispatch = useDispatch();
@ -185,14 +184,14 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
</div>
<MovieIndexProgressBar
movieId={movieId}
movieFile={movieFile}
monitored={monitored}
hasFile={hasFile}
isAvailable={isAvailable}
status={status}
posterWidth={posterWidth}
width={posterWidth}
detailedProgressBar={detailedProgressBar}
queueStatus={queueStatus}
queueState={queueState}
bottomRadius={false}
/>

@ -1,8 +1,9 @@
import { throttle } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { FixedSizeGrid as Grid, GridChildComponentProps } from 'react-window';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import useMeasure from 'Helpers/Hooks/useMeasure';
import SortDirection from 'Helpers/Props/SortDirection';
import MovieIndexPoster from 'Movie/Index/Posters/MovieIndexPoster';
@ -21,7 +22,7 @@ const columnPaddingSmallScreen = parseInt(
const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
const ADDITIONAL_COLUMN_COUNT = {
const ADDITIONAL_COLUMN_COUNT: Record<string, number> = {
small: 3,
medium: 2,
large: 1,
@ -41,17 +42,17 @@ interface CellItemData {
interface MovieIndexPostersProps {
items: Movie[];
sortKey?: string;
sortKey: string;
sortDirection?: SortDirection;
jumpToCharacter?: string;
scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>;
scrollerRef: RefObject<HTMLElement>;
isSelectMode: boolean;
isSmallScreen: boolean;
}
const movieIndexSelector = createSelector(
(state) => state.movieIndex.posterOptions,
(state: AppState) => state.movieIndex.posterOptions,
(posterOptions) => {
return {
posterOptions,
@ -110,7 +111,7 @@ export default function MovieIndexPosters(props: MovieIndexPostersProps) {
} = props;
const { posterOptions } = useSelector(movieIndexSelector);
const ref: React.MutableRefObject<Grid> = useRef();
const ref = useRef<Grid>(null);
const [measureRef, bounds] = useMeasure();
const [size, setSize] = useState({ width: 0, height: 0 });
@ -215,8 +216,8 @@ export default function MovieIndexPosters(props: MovieIndexPostersProps) {
}, [isSmallScreen, scrollerRef, bounds]);
useEffect(() => {
const currentScrollListener = isSmallScreen ? window : scrollerRef.current;
const currentScrollerRef = scrollerRef.current;
const currentScrollerRef = scrollerRef.current as HTMLElement;
const currentScrollListener = isSmallScreen ? window : currentScrollerRef;
const handleScroll = throttle(() => {
const { offsetTop = 0 } = currentScrollerRef;
@ -225,7 +226,7 @@ export default function MovieIndexPosters(props: MovieIndexPostersProps) {
? getWindowScrollTopPosition()
: currentScrollerRef.scrollTop) - offsetTop;
ref.current.scrollTo({ scrollLeft: 0, scrollTop });
ref.current?.scrollTo({ scrollLeft: 0, scrollTop });
}, 10);
currentScrollListener.addEventListener('scroll', handleScroll);
@ -248,8 +249,8 @@ export default function MovieIndexPosters(props: MovieIndexPostersProps) {
const scrollTop = rowIndex * rowHeight + padding;
ref.current.scrollTo({ scrollLeft: 0, scrollTop });
scrollerRef.current.scrollTo(0, scrollTop);
ref.current?.scrollTo({ scrollLeft: 0, scrollTop });
scrollerRef.current?.scrollTo(0, scrollTop);
}
}
}, [

@ -45,7 +45,7 @@ function MovieIndexPosterOptionsModalContent(
const dispatch = useDispatch();
const onPosterOptionChange = useCallback(
({ name, value }) => {
({ name, value }: { name: string; value: unknown }) => {
dispatch(setMoviePosterOption({ [name]: value }));
},
[dispatch]

@ -1,7 +1,8 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
const selectPosterOptions = createSelector(
(state) => state.movieIndex.posterOptions,
(state: AppState) => state.movieIndex.posterOptions,
(posterOptions) => posterOptions
);

@ -4,7 +4,6 @@
border-radius: 0;
background-color: #5b5b5b;
color: var(--white);
transition: width 200ms ease;
}
.progressRadius {

@ -1,60 +1,77 @@
import React from 'react';
import { useSelector } from 'react-redux';
import ProgressBar from 'Components/ProgressBar';
import { sizes } from 'Helpers/Props';
import getQueueStatusText from 'Utilities/Movie/getQueueStatusText';
import createMovieQueueItemsDetailsSelector, {
MovieQueueDetails,
} from 'Movie/Index/createMovieQueueDetailsSelector';
import { MovieFile } from 'MovieFile/MovieFile';
import getStatusStyle from 'Utilities/Movie/getStatusStyle';
import translate from 'Utilities/String/translate';
import styles from './MovieIndexProgressBar.css';
interface MovieIndexProgressBarProps {
movieId: number;
movieFile: MovieFile;
monitored: boolean;
status: string;
hasFile: boolean;
isAvailable: boolean;
posterWidth: number;
width: number;
detailedProgressBar: boolean;
bottomRadius: boolean;
queueStatus: string;
queueState: string;
bottomRadius?: boolean;
isStandAlone?: boolean;
}
function MovieIndexProgressBar(props: MovieIndexProgressBarProps) {
const {
movieId,
movieFile,
monitored,
status,
hasFile,
isAvailable,
posterWidth,
width,
detailedProgressBar,
bottomRadius,
queueStatus,
queueState,
isStandAlone,
} = props;
const queueDetails: MovieQueueDetails = useSelector(
createMovieQueueItemsDetailsSelector(movieId)
);
const progress = 100;
const queueStatusText = getQueueStatusText(queueStatus, queueState);
const queueStatusText = queueDetails.count > 0 ? 'Downloading' : null;
let movieStatus = status === 'released' && hasFile ? 'downloaded' : status;
if (movieStatus === 'deleted') {
movieStatus = 'Missing';
if (hasFile) {
movieStatus = 'Downloaded';
const quality = movieFile.quality;
movieStatus = quality.quality.name;
}
} else if (hasFile) {
movieStatus = 'Downloaded';
const quality = movieFile.quality;
movieStatus = quality.quality.name;
} else if (isAvailable && !hasFile) {
movieStatus = 'Missing';
} else {
movieStatus = 'NotAvailable';
}
const attachedClassName = bottomRadius
? styles.progressRadius
: styles.progress;
const containerClassName = isStandAlone ? undefined : attachedClassName;
return (
<ProgressBar
className={styles.progressBar}
containerClassName={
bottomRadius ? styles.progressRadius : styles.progress
}
containerClassName={containerClassName}
progress={progress}
kind={getStatusStyle(
status,
@ -62,11 +79,11 @@ function MovieIndexProgressBar(props: MovieIndexProgressBarProps) {
hasFile,
isAvailable,
'kinds',
queueStatusText
queueDetails.count > 0
)}
size={detailedProgressBar ? sizes.MEDIUM : sizes.SMALL}
showText={detailedProgressBar}
width={posterWidth}
width={width}
text={queueStatusText ? queueStatusText : translate(movieStatus)}
/>
);

@ -2,6 +2,7 @@ import { orderBy } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
@ -11,8 +12,10 @@ 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 Movie from 'Movie/Movie';
import { bulkDeleteMovie, setDeleteOption } from 'Store/Actions/movieActions';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import { CheckInputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './DeleteMovieModalContent.css';
@ -22,7 +25,7 @@ interface DeleteMovieModalContentProps {
}
const selectDeleteOptions = createSelector(
(state) => state.movies.deleteOptions,
(state: AppState) => state.movies.deleteOptions,
(deleteOptions) => deleteOptions
);
@ -30,28 +33,28 @@ function DeleteMovieModalContent(props: DeleteMovieModalContentProps) {
const { movieIds, onModalClose } = props;
const { addImportExclusion } = useSelector(selectDeleteOptions);
const allMovies = useSelector(createAllMoviesSelector());
const allMovies: Movie[] = useSelector(createAllMoviesSelector());
const dispatch = useDispatch();
const [deleteFiles, setDeleteFiles] = useState(false);
const movies = useMemo(() => {
const movies = useMemo((): Movie[] => {
const movies = movieIds.map((id) => {
return allMovies.find((s) => s.id === id);
});
}) as Movie[];
return orderBy(movies, ['sortTitle']);
}, [movieIds, allMovies]);
const onDeleteFilesChange = useCallback(
({ value }) => {
({ value }: CheckInputChanged) => {
setDeleteFiles(value);
},
[setDeleteFiles]
);
const onDeleteOptionChange = useCallback(
({ name, value }) => {
({ name, value }: { name: string; value: boolean }) => {
dispatch(
setDeleteOption({
[name]: value,

@ -44,7 +44,7 @@ function EditMoviesModalContent(props: EditMoviesModalContentProps) {
const [isConfirmMoveModalOpen, setIsConfirmMoveModalOpen] = useState(false);
const save = useCallback(
(moveFiles) => {
(moveFiles: boolean) => {
let hasChanges = false;
const payload: SavePayload = {};
@ -74,7 +74,7 @@ function EditMoviesModalContent(props: EditMoviesModalContentProps) {
);
const onInputChange = useCallback(
({ name, value }) => {
({ name, value }: { name: string; value: string }) => {
switch (name) {
case 'monitored':
setMonitored(value);

@ -6,7 +6,7 @@ import { icons } from 'Helpers/Props';
interface MovieIndexSelectAllButtonProps {
label: string;
isSelectMode: boolean;
overflowComponent: React.FunctionComponent;
overflowComponent: React.FunctionComponent<never>;
}
function MovieIndexSelectAllButton(props: MovieIndexSelectAllButtonProps) {

@ -2,9 +2,11 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import { RENAME_MOVIE } from 'Commands/commandNames';
import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props';
import { saveMovieEditor } from 'Store/Actions/movieActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
@ -17,8 +19,15 @@ import OrganizeMoviesModal from './Organize/OrganizeMoviesModal';
import TagsModal from './Tags/TagsModal';
import styles from './MovieIndexSelectFooter.css';
interface SavePayload {
monitored?: boolean;
qualityProfileId?: number;
rootFolderPath?: string;
moveFiles?: boolean;
}
const movieEditorSelector = createSelector(
(state) => state.movies,
(state: AppState) => state.movies,
(movies) => {
const { isSaving, isDeleting, deleteError } = movies;
@ -46,6 +55,7 @@ function MovieIndexSelectFooter() {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isSavingMovies, setIsSavingMovies] = useState(false);
const [isSavingTags, setIsSavingTags] = useState(false);
const previousIsDeleting = usePrevious(isDeleting);
const [selectState, selectDispatch] = useSelect();
const { selectedState } = selectState;
@ -65,7 +75,7 @@ function MovieIndexSelectFooter() {
}, [setIsEditModalOpen]);
const onSavePress = useCallback(
(payload) => {
(payload: SavePayload) => {
setIsSavingMovies(true);
setIsEditModalOpen(false);
@ -96,7 +106,7 @@ function MovieIndexSelectFooter() {
}, [setIsTagsModalOpen]);
const onApplyTagsPress = useCallback(
(tags, applyTags) => {
(tags: number[], applyTags: string) => {
setIsSavingTags(true);
setIsTagsModalOpen(false);
@ -127,10 +137,10 @@ function MovieIndexSelectFooter() {
}, [isSaving]);
useEffect(() => {
if (!isDeleting && !deleteError) {
if (previousIsDeleting && !isDeleting && !deleteError) {
selectDispatch({ type: 'unselectAll' });
}
}, [isDeleting, deleteError, selectDispatch]);
}, [previousIsDeleting, isDeleting, deleteError, selectDispatch]);
useEffect(() => {
dispatch(fetchRootFolders());

@ -7,7 +7,7 @@ interface MovieIndexSelectModeButtonProps {
label: string;
iconName: IconDefinition;
isSelectMode: boolean;
overflowComponent: React.FunctionComponent;
overflowComponent: React.FunctionComponent<never>;
onPress: () => void;
}

@ -10,6 +10,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, kinds } from 'Helpers/Props';
import Movie from 'Movie/Movie';
import { executeCommand } from 'Store/Actions/commandActions';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import translate from 'Utilities/String/translate';
@ -23,15 +24,21 @@ interface OrganizeMoviesModalContentProps {
function OrganizeMoviesModalContent(props: OrganizeMoviesModalContentProps) {
const { movieIds, onModalClose } = props;
const allMovies = useSelector(createAllMoviesSelector());
const allMovies: Movie[] = useSelector(createAllMoviesSelector());
const dispatch = useDispatch();
const movieTitles = useMemo(() => {
const movies = movieIds.map((id) => {
return allMovies.find((s) => s.id === id);
});
const movie = movieIds.reduce((acc: Movie[], id) => {
const s = allMovies.find((s) => s.id === id);
const sorted = orderBy(movies, ['sortTitle']);
if (s) {
acc.push(s);
}
return acc;
}, []);
const sorted = orderBy(movie, ['sortTitle']);
return sorted.map((s) => s.title);
}, [movieIds, allMovies]);

@ -1,6 +1,7 @@
import { concat, uniq } from 'lodash';
import { uniq } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { Tag } from 'App/State/TagsAppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
@ -12,6 +13,7 @@ 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 Movie from 'Movie/Movie';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import translate from 'Utilities/String/translate';
@ -26,29 +28,35 @@ interface TagsModalContentProps {
function TagsModalContent(props: TagsModalContentProps) {
const { movieIds, onModalClose, onApplyTagsPress } = props;
const allMovies = useSelector(createAllMoviesSelector());
const tagList = useSelector(createTagsSelector());
const allMovies: Movie[] = useSelector(createAllMoviesSelector());
const tagList: Tag[] = useSelector(createTagsSelector());
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');
const movieTags = useMemo(() => {
const movies = movieIds.map((id) => {
return allMovies.find((s) => s.id === id);
});
const tags = movieIds.reduce((acc: number[], id) => {
const s = allMovies.find((s) => s.id === id);
return uniq(concat(...movies.map((s) => s.tags)));
if (s) {
acc.push(...s.tags);
}
return acc;
}, []);
return uniq(tags);
}, [movieIds, allMovies]);
const onTagsChange = useCallback(
({ value }) => {
({ value }: { value: number[] }) => {
setTags(value);
},
[setTags]
);
const onApplyTagsChange = useCallback(
({ value }) => {
({ value }: { value: string }) => {
setApplyTags(value);
},
[setApplyTags]

@ -57,7 +57,10 @@
.movieStatus {
composes: cell;
flex: 0 0 110px;
display: flex;
justify-content: center;
flex: 0 0 150px;
flex-direction: column;
}
.certification {

@ -19,13 +19,15 @@ import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks';
import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
import createMovieIndexItemSelector from 'Movie/Index/createMovieIndexItemSelector';
import MovieFileStatusConnector from 'Movie/MovieFileStatusConnector';
import MovieTitleLink from 'Movie/MovieTitleLink';
import { executeCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { SelectStateInputProps } from 'typings/props';
import formatRuntime from 'Utilities/Date/formatRuntime';
import formatBytes from 'Utilities/Number/formatBytes';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import MovieIndexProgressBar from '../ProgressBar/MovieIndexProgressBar';
import MovieStatusCell from './MovieStatusCell';
import selectTableOptions from './selectTableOptions';
import styles from './MovieIndexRow.css';
@ -45,6 +47,8 @@ function MovieIndexRow(props: MovieIndexRowProps) {
const { showSearchAction } = useSelector(selectTableOptions);
const { movieRuntimeFormat } = useSelector(createUISettingsSelector());
const {
monitored,
titleSlug,
@ -64,19 +68,16 @@ function MovieIndexRow(props: MovieIndexRowProps) {
path,
sizeOnDisk,
genres = [],
queueStatus,
queueState,
ratings,
certification,
tags = [],
tmdbId,
imdbId,
isAvailable,
grabbed,
hasFile,
movieFile,
youTubeTrailerId,
isSaving = false,
movieRuntimeFormat,
} = movie;
const dispatch = useDispatch();
@ -120,7 +121,7 @@ function MovieIndexRow(props: MovieIndexRowProps) {
}, [setIsDeleteMovieModalOpen]);
const onSelectedChange = useCallback(
({ id, value, shiftKey }) => {
({ id, value, shiftKey }: SelectStateInputProps) => {
selectDispatch({
type: 'toggleSelected',
id,
@ -214,6 +215,8 @@ function MovieIndexRow(props: MovieIndexRowProps) {
if (name === 'added') {
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts(2739)
<RelativeDateCellConnector
key={name}
className={styles[name]}
@ -233,6 +236,8 @@ function MovieIndexRow(props: MovieIndexRowProps) {
if (name === 'inCinemas') {
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts(2739)
<RelativeDateCellConnector
key={name}
className={styles[name]}
@ -244,6 +249,8 @@ function MovieIndexRow(props: MovieIndexRowProps) {
if (name === 'digitalRelease') {
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts(2739)
<RelativeDateCellConnector
key={name}
className={styles[name]}
@ -255,6 +262,8 @@ function MovieIndexRow(props: MovieIndexRowProps) {
if (name === 'physicalRelease') {
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts(2739)
<RelativeDateCellConnector
key={name}
className={styles[name]}
@ -313,13 +322,17 @@ function MovieIndexRow(props: MovieIndexRowProps) {
if (name === 'movieStatus') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<MovieFileStatusConnector
isAvailable={isAvailable}
monitored={monitored}
grabbed={grabbed}
<MovieIndexProgressBar
movieId={movieId}
movieFile={movieFile}
queueStatus={queueStatus}
queueState={queueState}
monitored={monitored}
hasFile={hasFile}
isAvailable={isAvailable}
status={status}
width={125}
detailedProgressBar={true}
bottomRadius={false}
isStandAlone={true}
/>
</VirtualTableRowCell>
);

@ -1,8 +1,9 @@
import { throttle } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Scroller from 'Components/Scroller/Scroller';
import Column from 'Components/Table/Column';
import useMeasure from 'Helpers/Hooks/useMeasure';
@ -13,7 +14,6 @@ import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import MovieIndexRow from './MovieIndexRow';
import MovieIndexTableHeader from './MovieIndexTableHeader';
import selectTableOptions from './selectTableOptions';
import styles from './MovieIndexTable.css';
const bodyPadding = parseInt(dimensions.pageContentBodyPadding);
@ -30,17 +30,17 @@ interface RowItemData {
interface MovieIndexTableProps {
items: Movie[];
sortKey?: string;
sortKey: string;
sortDirection?: SortDirection;
jumpToCharacter?: string;
scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>;
scrollerRef: RefObject<HTMLElement>;
isSelectMode: boolean;
isSmallScreen: boolean;
}
const columnsSelector = createSelector(
(state) => state.movieIndex.columns,
(state: AppState) => state.movieIndex.columns,
(columns) => columns
);
@ -91,19 +91,18 @@ function MovieIndexTable(props: MovieIndexTableProps) {
} = props;
const columns = useSelector(columnsSelector);
const { showBanners } = useSelector(selectTableOptions);
const listRef: React.MutableRefObject<List> = useRef();
const listRef = useRef<List<RowItemData>>(null);
const [measureRef, bounds] = useMeasure();
const [size, setSize] = useState({ width: 0, height: 0 });
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const rowHeight = useMemo(() => {
return showBanners ? 70 : 38;
}, [showBanners]);
return 38;
}, []);
useEffect(() => {
const current = scrollerRef.current as HTMLElement;
const current = scrollerRef?.current as HTMLElement;
if (isSmallScreen) {
setSize({
@ -127,8 +126,8 @@ function MovieIndexTable(props: MovieIndexTableProps) {
}, [isSmallScreen, windowWidth, windowHeight, scrollerRef, bounds]);
useEffect(() => {
const currentScrollListener = isSmallScreen ? window : scrollerRef.current;
const currentScrollerRef = scrollerRef.current;
const currentScrollerRef = scrollerRef.current as HTMLElement;
const currentScrollListener = isSmallScreen ? window : currentScrollerRef;
const handleScroll = throttle(() => {
const { offsetTop = 0 } = currentScrollerRef;
@ -137,7 +136,7 @@ function MovieIndexTable(props: MovieIndexTableProps) {
? getWindowScrollTopPosition()
: currentScrollerRef.scrollTop) - offsetTop;
listRef.current.scrollTo(scrollTop);
listRef.current?.scrollTo(scrollTop);
}, 10);
currentScrollListener.addEventListener('scroll', handleScroll);
@ -166,8 +165,8 @@ function MovieIndexTable(props: MovieIndexTableProps) {
scrollTop += offset;
}
listRef.current.scrollTo(scrollTop);
scrollerRef.current.scrollTo(0, scrollTop);
listRef.current?.scrollTo(scrollTop);
scrollerRef?.current?.scrollTo(0, scrollTop);
}
}
}, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]);

@ -50,7 +50,7 @@
.movieStatus {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 110px;
flex: 0 0 150px;
}
.certification {

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

Loading…
Cancel
Save