Convert Components to TypeScript

(cherry picked from commit e1cbc4a78249881de96160739a50c0a399ea4313)

Closes #10378

Fixed: Links tooltip closing too quickly

(cherry picked from commit 0b9a212f33381d07ff67e2453753aaab64cc8041)

Closes #10400

Fixed: Movie links not opening on iOS

(cherry picked from commit f20ac9dc348e1f5ded635f12ab925d982b1b8957)

Closes #10425
pull/10568/head
Mark McDowall 9 months ago committed by Bogdan
parent dc29526961
commit d99a7e9b8a

@ -2,7 +2,7 @@ import React from 'react';
import Icon, { IconProps } from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds } from 'Helpers/Props';
import TooltipPosition from 'Helpers/Props/TooltipPosition';
import { TooltipPosition } from 'Helpers/Props/tooltipPositions';
import {
QueueTrackedDownloadState,
QueueTrackedDownloadStatus,

@ -1,5 +1,5 @@
import Column from 'Components/Table/Column';
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
import { FilterBuilderProp, PropertyFilter } from './AppState';
export interface Error {

@ -8,6 +8,7 @@ import MovieCreditAppState from './MovieCreditAppState';
import MovieFilesAppState from './MovieFilesAppState';
import MoviesAppState, { MovieIndexAppState } from './MoviesAppState';
import ParseAppState from './ParseAppState';
import PathsAppState from './PathsAppState';
import QueueAppState from './QueueAppState';
import RootFolderAppState from './RootFolderAppState';
import SettingsAppState from './SettingsAppState';
@ -71,6 +72,7 @@ interface AppState {
movieIndex: MovieIndexAppState;
movies: MoviesAppState;
parse: ParseAppState;
paths: PathsAppState;
queue: QueueAppState;
rootFolders: RootFolderAppState;
settings: SettingsAppState;

@ -3,7 +3,7 @@ import AppSectionState, {
AppSectionSaveState,
} from 'App/State/AppSectionState';
import Column from 'Components/Table/Column';
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
import Movie from 'Movie/Movie';
import { Filter, FilterBuilderProp } from './AppState';

@ -0,0 +1,29 @@
interface BasePath {
name: string;
path: string;
size: number;
lastModified: string;
}
interface File extends BasePath {
type: 'file';
}
interface Folder extends BasePath {
type: 'folder';
}
export type PathType = 'file' | 'folder' | 'drive' | 'computer' | 'parent';
export type Path = File | Folder;
interface PathsAppState {
currentPath: string;
isFetching: boolean;
isPopulated: boolean;
error: Error;
directories: Folder[];
files: File[];
parent: string | null;
}
export default PathsAppState;

@ -1,34 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { kinds } from 'Helpers/Props';
import styles from './Alert.css';
function Alert(props) {
const { className, kind, children, ...otherProps } = props;
return (
<div
className={classNames(
className,
styles[kind]
)}
{...otherProps}
>
{children}
</div>
);
}
Alert.propTypes = {
className: PropTypes.string,
kind: PropTypes.oneOf(kinds.all),
children: PropTypes.node.isRequired
};
Alert.defaultProps = {
className: styles.alert,
kind: kinds.INFO
};
export default Alert;

@ -0,0 +1,18 @@
import classNames from 'classnames';
import React from 'react';
import { Kind } from 'Helpers/Props/kinds';
import styles from './Alert.css';
interface AlertProps {
className?: string;
kind?: Extract<Kind, keyof typeof styles>;
children: React.ReactNode;
}
function Alert(props: AlertProps) {
const { className = styles.alert, kind = 'info', children } = props;
return <div className={classNames(className, styles[kind])}>{children}</div>;
}
export default Alert;

@ -1,60 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Link from 'Components/Link/Link';
import styles from './Card.css';
class Card extends Component {
//
// Render
render() {
const {
className,
overlayClassName,
overlayContent,
children,
onPress
} = this.props;
if (overlayContent) {
return (
<div className={className}>
<Link
className={styles.underlay}
onPress={onPress}
/>
<div className={overlayClassName}>
{children}
</div>
</div>
);
}
return (
<Link
className={className}
onPress={onPress}
>
{children}
</Link>
);
}
}
Card.propTypes = {
className: PropTypes.string.isRequired,
overlayClassName: PropTypes.string.isRequired,
overlayContent: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired,
onPress: PropTypes.func.isRequired
};
Card.defaultProps = {
className: styles.card,
overlayClassName: styles.overlay,
overlayContent: false
};
export default Card;

@ -0,0 +1,39 @@
import React from 'react';
import Link, { LinkProps } from 'Components/Link/Link';
import styles from './Card.css';
interface CardProps extends Pick<LinkProps, 'onPress'> {
// TODO: Consider using different properties for classname depending if it's overlaying content or not
className?: string;
overlayClassName?: string;
overlayContent?: boolean;
children: React.ReactNode;
}
function Card(props: CardProps) {
const {
className = styles.card,
overlayClassName = styles.overlay,
overlayContent = false,
children,
onPress,
} = props;
if (overlayContent) {
return (
<div className={className}>
<Link className={styles.underlay} onPress={onPress} />
<div className={overlayClassName}>{children}</div>
</div>
);
}
return (
<Link className={className} onPress={onPress}>
{children}
</Link>
);
}
export default Card;

@ -1,33 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import styles from './DescriptionList.css';
class DescriptionList extends Component {
//
// Render
render() {
const {
className,
children
} = this.props;
return (
<dl className={className}>
{children}
</dl>
);
}
}
DescriptionList.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.node
};
DescriptionList.defaultProps = {
className: styles.descriptionList
};
export default DescriptionList;

@ -0,0 +1,15 @@
import React from 'react';
import styles from './DescriptionList.css';
interface DescriptionListProps {
className?: string;
children?: React.ReactNode;
}
function DescriptionList(props: DescriptionListProps) {
const { className = styles.descriptionList, children } = props;
return <dl className={className}>{children}</dl>;
}
export default DescriptionList;

@ -1,46 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import DescriptionListItemDescription from './DescriptionListItemDescription';
import DescriptionListItemTitle from './DescriptionListItemTitle';
class DescriptionListItem extends Component {
//
// Render
render() {
const {
className,
titleClassName,
descriptionClassName,
title,
data
} = this.props;
return (
<div className={className}>
<DescriptionListItemTitle
className={titleClassName}
>
{title}
</DescriptionListItemTitle>
<DescriptionListItemDescription
className={descriptionClassName}
>
{data}
</DescriptionListItemDescription>
</div>
);
}
}
DescriptionListItem.propTypes = {
className: PropTypes.string,
titleClassName: PropTypes.string,
descriptionClassName: PropTypes.string,
title: PropTypes.string,
data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
};
export default DescriptionListItem;

@ -0,0 +1,34 @@
import React from 'react';
import DescriptionListItemDescription, {
DescriptionListItemDescriptionProps,
} from './DescriptionListItemDescription';
import DescriptionListItemTitle, {
DescriptionListItemTitleProps,
} from './DescriptionListItemTitle';
interface DescriptionListItemProps {
className?: string;
titleClassName?: DescriptionListItemTitleProps['className'];
descriptionClassName?: DescriptionListItemDescriptionProps['className'];
title?: DescriptionListItemTitleProps['children'];
data?: DescriptionListItemDescriptionProps['children'];
}
function DescriptionListItem(props: DescriptionListItemProps) {
const { className, titleClassName, descriptionClassName, title, data } =
props;
return (
<div className={className}>
<DescriptionListItemTitle className={titleClassName}>
{title}
</DescriptionListItemTitle>
<DescriptionListItemDescription className={descriptionClassName}>
{data}
</DescriptionListItemDescription>
</div>
);
}
export default DescriptionListItem;

@ -1,27 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './DescriptionListItemDescription.css';
function DescriptionListItemDescription(props) {
const {
className,
children
} = props;
return (
<dd className={className}>
{children}
</dd>
);
}
DescriptionListItemDescription.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
};
DescriptionListItemDescription.defaultProps = {
className: styles.description
};
export default DescriptionListItemDescription;

@ -0,0 +1,17 @@
import React, { ReactNode } from 'react';
import styles from './DescriptionListItemDescription.css';
export interface DescriptionListItemDescriptionProps {
className?: string;
children?: ReactNode;
}
function DescriptionListItemDescription(
props: DescriptionListItemDescriptionProps
) {
const { className = styles.description, children } = props;
return <dd className={className}>{children}</dd>;
}
export default DescriptionListItemDescription;

@ -1,27 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './DescriptionListItemTitle.css';
function DescriptionListItemTitle(props) {
const {
className,
children
} = props;
return (
<dt className={className}>
{children}
</dt>
);
}
DescriptionListItemTitle.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.string
};
DescriptionListItemTitle.defaultProps = {
className: styles.title
};
export default DescriptionListItemTitle;

@ -0,0 +1,15 @@
import React, { ReactNode } from 'react';
import styles from './DescriptionListItemTitle.css';
export interface DescriptionListItemTitleProps {
className?: string;
children?: ReactNode;
}
function DescriptionListItemTitle(props: DescriptionListItemTitleProps) {
const { className = styles.title, children } = props;
return <dt className={className}>{children}</dt>;
}
export default DescriptionListItemTitle;

@ -1,22 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './DragPreviewLayer.css';
function DragPreviewLayer({ children, ...otherProps }) {
return (
<div {...otherProps}>
{children}
</div>
);
}
DragPreviewLayer.propTypes = {
children: PropTypes.node,
className: PropTypes.string
};
DragPreviewLayer.defaultProps = {
className: styles.dragLayer
};
export default DragPreviewLayer;

@ -0,0 +1,21 @@
import React from 'react';
import styles from './DragPreviewLayer.css';
interface DragPreviewLayerProps {
className?: string;
children?: React.ReactNode;
}
function DragPreviewLayer({
className = styles.dragLayer,
children,
...otherProps
}: DragPreviewLayerProps) {
return (
<div className={className} {...otherProps}>
{children}
</div>
);
}
export default DragPreviewLayer;

@ -1,62 +0,0 @@
import * as sentry from '@sentry/browser';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
class ErrorBoundary extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
error: null,
info: null
};
}
componentDidCatch(error, info) {
this.setState({
error,
info
});
sentry.captureException(error);
}
//
// Render
render() {
const {
children,
errorComponent: ErrorComponent,
...otherProps
} = this.props;
const {
error,
info
} = this.state;
if (error) {
return (
<ErrorComponent
error={error}
info={info}
{...otherProps}
/>
);
}
return children;
}
}
ErrorBoundary.propTypes = {
children: PropTypes.node.isRequired,
errorComponent: PropTypes.elementType.isRequired
};
export default ErrorBoundary;

@ -0,0 +1,46 @@
import * as sentry from '@sentry/browser';
import React, { Component, ErrorInfo } from 'react';
interface ErrorBoundaryProps {
children: React.ReactNode;
errorComponent: React.ElementType;
}
interface ErrorBoundaryState {
error: Error | null;
info: ErrorInfo | null;
}
// Class component until componentDidCatch is supported in functional components
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
error: null,
info: null,
};
}
componentDidCatch(error: Error, info: ErrorInfo) {
this.setState({
error,
info,
});
sentry.captureException(error);
}
render() {
const { children, errorComponent: ErrorComponent } = this.props;
const { error, info } = this.state;
if (error) {
return <ErrorComponent error={error} info={info} />;
}
return children;
}
}
export default ErrorBoundary;

@ -1,41 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { sizes } from 'Helpers/Props';
import styles from './FieldSet.css';
class FieldSet extends Component {
//
// Render
render() {
const {
size,
legend,
children
} = this.props;
return (
<fieldset className={styles.fieldSet}>
<legend className={classNames(styles.legend, (size === sizes.SMALL) && styles.small)}>
{legend}
</legend>
{children}
</fieldset>
);
}
}
FieldSet.propTypes = {
size: PropTypes.oneOf(sizes.all).isRequired,
legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
children: PropTypes.node
};
FieldSet.defaultProps = {
size: sizes.MEDIUM
};
export default FieldSet;

@ -0,0 +1,29 @@
import classNames from 'classnames';
import React, { ComponentProps } from 'react';
import { sizes } from 'Helpers/Props';
import { Size } from 'Helpers/Props/sizes';
import styles from './FieldSet.css';
interface FieldSetProps {
size?: Size;
legend?: ComponentProps<'legend'>['children'];
children?: React.ReactNode;
}
function FieldSet({ size = sizes.MEDIUM, legend, children }: FieldSetProps) {
return (
<fieldset className={styles.fieldSet}>
<legend
className={classNames(
styles.legend,
size === sizes.SMALL && styles.small
)}
>
{legend}
</legend>
{children}
</fieldset>
);
}
export default FieldSet;

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

@ -0,0 +1,23 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import FileBrowserModalContent, {
FileBrowserModalContentProps,
} from './FileBrowserModalContent';
import styles from './FileBrowserModal.css';
interface FileBrowserModalProps extends FileBrowserModalContentProps {
isOpen: boolean;
onModalClose: () => void;
}
function FileBrowserModal(props: FileBrowserModalProps) {
const { isOpen, onModalClose, ...otherProps } = props;
return (
<Modal className={styles.modal} isOpen={isOpen} onModalClose={onModalClose}>
<FileBrowserModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default FileBrowserModal;

@ -1,250 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import PathInput from 'Components/Form/PathInput';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Scroller from 'Components/Scroller/Scroller';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { kinds, scrollDirections } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import FileBrowserRow from './FileBrowserRow';
import styles from './FileBrowserModalContent.css';
const columns = [
{
name: 'type',
label: () => translate('Type'),
isVisible: true
},
{
name: 'name',
label: () => translate('Name'),
isVisible: true
}
];
class FileBrowserModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._scrollerRef = React.createRef();
this.state = {
isFileBrowserModalOpen: false,
currentPath: props.value
};
}
componentDidUpdate(prevProps, prevState) {
const {
currentPath
} = this.props;
if (
currentPath !== this.state.currentPath &&
currentPath !== prevState.currentPath
) {
this.setState({ currentPath });
this._scrollerRef.current.scrollTop = 0;
}
}
//
// Listeners
onPathInputChange = ({ value }) => {
this.setState({ currentPath: value });
};
onRowPress = (path) => {
this.props.onFetchPaths(path);
};
onOkPress = () => {
this.props.onChange({
name: this.props.name,
value: this.state.currentPath
});
this.props.onClearPaths();
this.props.onModalClose();
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
parent,
directories,
files,
isWindowsService,
onModalClose,
...otherProps
} = this.props;
const emptyParent = parent === '';
return (
<ModalContent
onModalClose={onModalClose}
>
<ModalHeader>
File Browser
</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
{
isWindowsService &&
<Alert
className={styles.mappedDrivesWarning}
kind={kinds.WARNING}
>
<Link to="https://wiki.servarr.com/radarr/faq#why-cant-radarr-see-my-files-on-a-remote-server">
{translate('MappedDrivesRunningAsService')}
</Link> .
</Alert>
}
<PathInput
className={styles.pathInput}
placeholder={translate('StartTypingOrSelectAPathBelow')}
hasFileBrowser={false}
{...otherProps}
value={this.state.currentPath}
onChange={this.onPathInputChange}
/>
<Scroller
ref={this._scrollerRef}
className={styles.scroller}
scrollDirection={scrollDirections.BOTH}
>
{
!!error &&
<div>
{translate('ErrorLoadingContents')}
</div>
}
{
isPopulated && !error &&
<Table
horizontalScroll={false}
columns={columns}
>
<TableBody>
{
emptyParent &&
<FileBrowserRow
type="computer"
name="My Computer"
path={parent}
onPress={this.onRowPress}
/>
}
{
!emptyParent && parent &&
<FileBrowserRow
type="parent"
name="..."
path={parent}
onPress={this.onRowPress}
/>
}
{
directories.map((directory) => {
return (
<FileBrowserRow
key={directory.path}
type={directory.type}
name={directory.name}
path={directory.path}
onPress={this.onRowPress}
/>
);
})
}
{
files.map((file) => {
return (
<FileBrowserRow
key={file.path}
type={file.type}
name={file.name}
path={file.path}
onPress={this.onRowPress}
/>
);
})
}
</TableBody>
</Table>
}
</Scroller>
</ModalBody>
<ModalFooter>
{
isFetching &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<Button
onPress={this.onOkPress}
>
{translate('Ok')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
FileBrowserModalContent.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
parent: PropTypes.string,
currentPath: PropTypes.string.isRequired,
directories: PropTypes.arrayOf(PropTypes.object).isRequired,
files: PropTypes.arrayOf(PropTypes.object).isRequired,
isWindowsService: PropTypes.bool.isRequired,
onFetchPaths: PropTypes.func.isRequired,
onClearPaths: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default FileBrowserModalContent;

@ -0,0 +1,237 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import PathInput from 'Components/Form/PathInput';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Scroller from 'Components/Scroller/Scroller';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds, scrollDirections } from 'Helpers/Props';
import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import createPathsSelector from './createPathsSelector';
import FileBrowserRow from './FileBrowserRow';
import styles from './FileBrowserModalContent.css';
const columns: Column[] = [
{
name: 'type',
label: () => translate('Type'),
isVisible: true,
},
{
name: 'name',
label: () => translate('Name'),
isVisible: true,
},
];
const handleClearPaths = () => {};
export interface FileBrowserModalContentProps {
name: string;
value: string;
includeFiles?: boolean;
onChange: (args: InputChanged<string>) => unknown;
onModalClose: () => void;
}
function FileBrowserModalContent(props: FileBrowserModalContentProps) {
const { name, value, includeFiles = true, onChange, onModalClose } = props;
const dispatch = useDispatch();
const { isWindows, mode } = useSelector(createSystemStatusSelector());
const { isFetching, isPopulated, error, parent, directories, files, paths } =
useSelector(createPathsSelector());
const [currentPath, setCurrentPath] = useState(value);
const scrollerRef = useRef(null);
const previousValue = usePrevious(value);
const emptyParent = parent === '';
const isWindowsService = isWindows && mode === 'service';
const handlePathInputChange = useCallback(
({ value }: InputChanged<string>) => {
setCurrentPath(value);
},
[]
);
const handleRowPress = useCallback(
(path: string) => {
setCurrentPath(path);
dispatch(
fetchPaths({
path,
allowFoldersWithoutTrailingSlashes: true,
includeFiles,
})
);
},
[includeFiles, dispatch, setCurrentPath]
);
const handleOkPress = useCallback(() => {
onChange({
name,
value: currentPath,
});
dispatch(clearPaths());
onModalClose();
}, [name, currentPath, dispatch, onChange, onModalClose]);
const handleFetchPaths = useCallback(
(path: string) => {
dispatch(
fetchPaths({
path,
allowFoldersWithoutTrailingSlashes: true,
includeFiles,
})
);
},
[includeFiles, dispatch]
);
useEffect(() => {
if (value !== previousValue && value !== currentPath) {
setCurrentPath(value);
}
}, [value, previousValue, currentPath, setCurrentPath]);
useEffect(
() => {
dispatch(
fetchPaths({
path: currentPath,
allowFoldersWithoutTrailingSlashes: true,
includeFiles,
})
);
return () => {
dispatch(clearPaths());
};
},
// This should only run once when the component mounts,
// so we don't need to include the other dependencies.
// eslint-disable-next-line react-hooks/exhaustive-deps
[dispatch]
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('FileBrowser')}</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
{isWindowsService ? (
<Alert className={styles.mappedDrivesWarning} kind={kinds.WARNING}>
<InlineMarkdown
data={translate('MappedNetworkDrivesWindowsService', {
url: 'https://wiki.servarr.com/radarr/faq#why-cant-radarr-see-my-files-on-a-remote-server',
})}
/>
</Alert>
) : null}
<PathInput
className={styles.pathInput}
placeholder={translate('FileBrowserPlaceholderText')}
hasFileBrowser={false}
includeFiles={includeFiles}
paths={paths}
name={name}
value={currentPath}
onChange={handlePathInputChange}
onFetchPaths={handleFetchPaths}
onClearPaths={handleClearPaths}
/>
<Scroller
ref={scrollerRef}
className={styles.scroller}
scrollDirection="both"
>
{error ? <div>{translate('ErrorLoadingContents')}</div> : null}
{isPopulated && !error ? (
<Table horizontalScroll={false} columns={columns}>
<TableBody>
{emptyParent ? (
<FileBrowserRow
type="computer"
name={translate('MyComputer')}
path={parent}
onPress={handleRowPress}
/>
) : null}
{!emptyParent && parent ? (
<FileBrowserRow
type="parent"
name="..."
path={parent}
onPress={handleRowPress}
/>
) : null}
{directories.map((directory) => {
return (
<FileBrowserRow
key={directory.path}
type={directory.type}
name={directory.name}
path={directory.path}
onPress={handleRowPress}
/>
);
})}
{files.map((file) => {
return (
<FileBrowserRow
key={file.path}
type={file.type}
name={file.name}
path={file.path}
onPress={handleRowPress}
/>
);
})}
</TableBody>
</Table>
) : null}
</Scroller>
</ModalBody>
<ModalFooter>
{isFetching ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={handleOkPress}>{translate('Ok')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default FileBrowserModalContent;

@ -1,119 +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 { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import FileBrowserModalContent from './FileBrowserModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.paths,
createSystemStatusSelector(),
(paths, systemStatus) => {
const {
isFetching,
isPopulated,
error,
parent,
currentPath,
directories,
files
} = paths;
const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
return path.toLowerCase().startsWith(currentPath.toLowerCase());
});
return {
isFetching,
isPopulated,
error,
parent,
currentPath,
directories,
files,
paths: filteredPaths,
isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service'
};
}
);
}
const mapDispatchToProps = {
dispatchFetchPaths: fetchPaths,
dispatchClearPaths: clearPaths
};
class FileBrowserModalContentConnector extends Component {
// Lifecycle
componentDidMount() {
const {
value,
includeFiles,
dispatchFetchPaths
} = this.props;
dispatchFetchPaths({
path: value,
allowFoldersWithoutTrailingSlashes: true,
includeFiles
});
}
//
// Listeners
onFetchPaths = (path) => {
const {
includeFiles,
dispatchFetchPaths
} = this.props;
dispatchFetchPaths({
path,
allowFoldersWithoutTrailingSlashes: true,
includeFiles
});
};
onClearPaths = () => {
// this.props.dispatchClearPaths();
};
onModalClose = () => {
this.props.dispatchClearPaths();
this.props.onModalClose();
};
//
// Render
render() {
return (
<FileBrowserModalContent
onFetchPaths={this.onFetchPaths}
onClearPaths={this.onClearPaths}
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
FileBrowserModalContentConnector.propTypes = {
value: PropTypes.string,
includeFiles: PropTypes.bool.isRequired,
dispatchFetchPaths: PropTypes.func.isRequired,
dispatchClearPaths: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
FileBrowserModalContentConnector.defaultProps = {
includeFiles: false
};
export default connect(createMapStateToProps, mapDispatchToProps)(FileBrowserModalContentConnector);

@ -1,62 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowButton from 'Components/Table/TableRowButton';
import { icons } from 'Helpers/Props';
import styles from './FileBrowserRow.css';
function getIconName(type) {
switch (type) {
case 'computer':
return icons.COMPUTER;
case 'drive':
return icons.DRIVE;
case 'file':
return icons.FILE;
case 'parent':
return icons.PARENT;
default:
return icons.FOLDER;
}
}
class FileBrowserRow extends Component {
//
// Listeners
onPress = () => {
this.props.onPress(this.props.path);
};
//
// Render
render() {
const {
type,
name
} = this.props;
return (
<TableRowButton onPress={this.onPress}>
<TableRowCell className={styles.type}>
<Icon name={getIconName(type)} />
</TableRowCell>
<TableRowCell>{name}</TableRowCell>
</TableRowButton>
);
}
}
FileBrowserRow.propTypes = {
type: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
onPress: PropTypes.func.isRequired
};
export default FileBrowserRow;

@ -0,0 +1,49 @@
import React, { useCallback } from 'react';
import { PathType } from 'App/State/PathsAppState';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowButton from 'Components/Table/TableRowButton';
import { icons } from 'Helpers/Props';
import styles from './FileBrowserRow.css';
function getIconName(type: PathType) {
switch (type) {
case 'computer':
return icons.COMPUTER;
case 'drive':
return icons.DRIVE;
case 'file':
return icons.FILE;
case 'parent':
return icons.PARENT;
default:
return icons.FOLDER;
}
}
interface FileBrowserRowProps {
type: PathType;
name: string;
path: string;
onPress: (path: string) => void;
}
function FileBrowserRow(props: FileBrowserRowProps) {
const { type, name, path, onPress } = props;
const handlePress = useCallback(() => {
onPress(path);
}, [path, onPress]);
return (
<TableRowButton onPress={handlePress}>
<TableRowCell className={styles.type}>
<Icon name={getIconName(type)} />
</TableRowCell>
<TableRowCell>{name}</TableRowCell>
</TableRowButton>
);
}
export default FileBrowserRow;

@ -0,0 +1,36 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createPathsSelector() {
return createSelector(
(state: AppState) => state.paths,
(paths) => {
const {
isFetching,
isPopulated,
error,
parent,
currentPath,
directories,
files,
} = paths;
const filteredPaths = [...directories, ...files].filter(({ path }) => {
return path.toLowerCase().startsWith(currentPath.toLowerCase());
});
return {
isFetching,
isPopulated,
error,
parent,
currentPath,
directories,
files,
paths: filteredPaths,
};
}
);
}
export default createPathsSelector;

@ -1,51 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import styles from './LoadingIndicator.css';
function LoadingIndicator({ className, rippleClassName, size }) {
const sizeInPx = `${size}px`;
const width = sizeInPx;
const height = sizeInPx;
return (
<div
className={className}
style={{ height }}
>
<div
className={classNames(styles.rippleContainer, 'followingBalls')}
style={{ width, height }}
>
<div
className={rippleClassName}
style={{ width, height }}
/>
<div
className={rippleClassName}
style={{ width, height }}
/>
<div
className={rippleClassName}
style={{ width, height }}
/>
</div>
</div>
);
}
LoadingIndicator.propTypes = {
className: PropTypes.string,
rippleClassName: PropTypes.string,
size: PropTypes.number
};
LoadingIndicator.defaultProps = {
className: styles.loading,
rippleClassName: styles.ripple,
size: 50
};
export default LoadingIndicator;

@ -0,0 +1,36 @@
import classNames from 'classnames';
import React from 'react';
import styles from './LoadingIndicator.css';
interface LoadingIndicatorProps {
className?: string;
rippleClassName?: string;
size?: number;
}
function LoadingIndicator({
className = styles.loading,
rippleClassName = styles.ripple,
size = 50,
}: LoadingIndicatorProps) {
const sizeInPx = `${size}px`;
const width = sizeInPx;
const height = sizeInPx;
return (
<div className={className} style={{ height }}>
<div
className={classNames(styles.rippleContainer, 'followingBalls')}
style={{ width, height }}
>
<div className={rippleClassName} style={{ width, height }} />
<div className={rippleClassName} style={{ width, height }} />
<div className={rippleClassName} style={{ width, height }} />
</div>
</div>
);
}
export default LoadingIndicator;

@ -8,21 +8,21 @@ const messages = [
'Bleep Bloop.',
'Locating the required gigapixels to render...',
'Spinning up the hamster wheel...',
'At least you\'re not on hold',
"At least you're not on hold",
'Hum something loud while others stare',
'Loading humorous message... Please Wait',
'I could\'ve been faster in Python',
'Don\'t forget to rewind your movies',
"I could've been faster in Python",
"Don't forget to rewind your movies",
'Congratulations! You are the 1000th visitor.',
'HELP! I\'m being held hostage and forced to write these stupid lines!',
"HELP! I'm being held hostage and forced to write these stupid lines!",
'RE-calibrating the internet...',
'I\'ll be here all week',
'Don\'t forget to tip your waitress',
"I'll be here all week",
"Don't forget to tip your waitress",
'Apply directly to the forehead',
'Loading Battlestation'
'Loading Battlestation',
];
let message = null;
let message: string | null = null;
function LoadingMessage() {
if (!message) {
@ -30,11 +30,7 @@ function LoadingMessage() {
message = messages[index];
}
return (
<div className={styles.loadingMessage}>
{message}
</div>
);
return <div className={styles.loadingMessage}>{message}</div>;
}
export default LoadingMessage;

@ -1,74 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Link from 'Components/Link/Link';
class InlineMarkdown extends Component {
//
// Render
render() {
const {
className,
data,
blockClassName
} = this.props;
// For now only replace links or code blocks (not both)
const markdownBlocks = [];
if (data) {
const linkRegex = RegExp(/\[(.+?)\]\((.+?)\)/g);
let endIndex = 0;
let match = null;
while ((match = linkRegex.exec(data)) !== null) {
if (match.index > endIndex) {
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
}
markdownBlocks.push(<Link key={match.index} to={match[2]}>{match[1]}</Link>);
endIndex = match.index + match[0].length;
}
if (endIndex !== data.length && markdownBlocks.length > 0) {
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
}
const codeRegex = RegExp(/(?=`)`(?!`)[^`]*(?=`)`(?!`)/g);
endIndex = 0;
match = null;
let matchedCode = false;
while ((match = codeRegex.exec(data)) !== null) {
matchedCode = true;
if (match.index > endIndex) {
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
}
markdownBlocks.push(<code key={`code-${match.index}`} className={blockClassName ?? null}>{match[0].substring(1, match[0].length - 1)}</code>);
endIndex = match.index + match[0].length;
}
if (endIndex !== data.length && markdownBlocks.length > 0 && matchedCode) {
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
}
if (markdownBlocks.length === 0) {
markdownBlocks.push(data);
}
}
return <span className={className}>{markdownBlocks}</span>;
}
}
InlineMarkdown.propTypes = {
className: PropTypes.string,
data: PropTypes.string,
blockClassName: PropTypes.string
};
export default InlineMarkdown;

@ -0,0 +1,75 @@
import React, { ReactElement } from 'react';
import Link from 'Components/Link/Link';
interface InlineMarkdownProps {
className?: string;
data?: string;
blockClassName?: string;
}
function InlineMarkdown(props: InlineMarkdownProps) {
const { className, data, blockClassName } = props;
// For now only replace links or code blocks (not both)
const markdownBlocks: (ReactElement | string)[] = [];
if (data) {
const linkRegex = RegExp(/\[(.+?)\]\((.+?)\)/g);
let endIndex = 0;
let match = null;
while ((match = linkRegex.exec(data)) !== null) {
if (match.index > endIndex) {
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
}
markdownBlocks.push(
<Link key={match.index} to={match[2]}>
{match[1]}
</Link>
);
endIndex = match.index + match[0].length;
}
if (endIndex !== data.length && markdownBlocks.length > 0) {
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
}
const codeRegex = RegExp(/(?=`)`(?!`)[^`]*(?=`)`(?!`)/g);
endIndex = 0;
match = null;
let matchedCode = false;
while ((match = codeRegex.exec(data)) !== null) {
matchedCode = true;
if (match.index > endIndex) {
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
}
markdownBlocks.push(
<code
key={`code-${match.index}`}
className={blockClassName ?? undefined}
>
{match[0].substring(1, match[0].length - 1)}
</code>
);
endIndex = match.index + match[0].length;
}
if (endIndex !== data.length && markdownBlocks.length > 0 && matchedCode) {
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
}
if (markdownBlocks.length === 0) {
markdownBlocks.push(data);
}
}
return <span className={className}>{markdownBlocks}</span>;
}
export default InlineMarkdown;

@ -1,79 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import { icons } from 'Helpers/Props';
import styles from './MonitorToggleButton.css';
function getTooltip(monitored, isDisabled) {
if (isDisabled) {
return 'Cannot toggle monitored state when movie is unmonitored';
}
if (monitored) {
return 'Monitored, click to unmonitor';
}
return 'Unmonitored, click to monitor';
}
class MonitorToggleButton extends Component {
//
// Listeners
onPress = (event) => {
const shiftKey = event.nativeEvent.shiftKey;
this.props.onPress(!this.props.monitored, { shiftKey });
};
//
// Render
render() {
const {
className,
monitored,
isDisabled,
isSaving,
size,
...otherProps
} = this.props;
const iconName = monitored ? icons.MONITORED : icons.UNMONITORED;
return (
<SpinnerIconButton
className={classNames(
className,
isDisabled && styles.isDisabled
)}
name={iconName}
size={size}
title={getTooltip(monitored, isDisabled)}
isDisabled={isDisabled}
isSpinning={isSaving}
{...otherProps}
onPress={this.onPress}
/>
);
}
}
MonitorToggleButton.propTypes = {
className: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
size: PropTypes.number,
isDisabled: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
onPress: PropTypes.func.isRequired
};
MonitorToggleButton.defaultProps = {
className: styles.toggleButton,
isDisabled: false,
isSaving: false
};
export default MonitorToggleButton;

@ -0,0 +1,65 @@
import classNames from 'classnames';
import React, { SyntheticEvent, useCallback, useMemo } from 'react';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './MonitorToggleButton.css';
interface MonitorToggleButtonProps {
className?: string;
monitored: boolean;
size?: number;
isDisabled?: boolean;
isSaving?: boolean;
onPress: (value: boolean, options: { shiftKey: boolean }) => unknown;
}
function MonitorToggleButton(props: MonitorToggleButtonProps) {
const {
className = styles.toggleButton,
monitored,
isDisabled = false,
isSaving = false,
size,
onPress,
...otherProps
} = props;
const iconName = monitored ? icons.MONITORED : icons.UNMONITORED;
const title = useMemo(() => {
if (isDisabled) {
return 'Cannot toggle monitored state when movie is unmonitored';
}
if (monitored) {
return translate('ToggleMonitoredToUnmonitored');
}
return translate('ToggleUnmonitoredToMonitored');
}, [monitored, isDisabled]);
const handlePress = useCallback(
(event: SyntheticEvent<HTMLLinkElement, MouseEvent>) => {
const shiftKey = event.nativeEvent.shiftKey;
onPress(!monitored, { shiftKey });
},
[monitored, onPress]
);
return (
<SpinnerIconButton
className={classNames(className, isDisabled && styles.isDisabled)}
name={iconName}
size={size}
title={title}
isDisabled={isDisabled}
isSpinning={isSaving}
{...otherProps}
onPress={handlePress}
/>
);
}
export default MonitorToggleButton;

@ -1,16 +1,19 @@
import PropTypes from 'prop-types';
import React from 'react';
import PageContent from 'Components/Page/PageContent';
import translate from 'Utilities/String/translate';
import styles from './NotFound.css';
function NotFound({ message }) {
interface NotFoundProps {
message?: string;
}
function NotFound(props: NotFoundProps) {
const { message = translate('DefaultNotFoundMessage') } = props;
return (
<PageContent title={translate('MIA')}>
<div className={styles.container}>
<div className={styles.message}>
{message}
</div>
<div className={styles.message}>{message}</div>
<img
className={styles.image}
@ -21,12 +24,4 @@ function NotFound({ message }) {
);
}
NotFound.propTypes = {
message: PropTypes.string.isRequired
};
NotFound.defaultProps = {
message: 'You must be lost, nothing to see here.'
};
export default NotFound;

@ -1,6 +1,5 @@
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';
@ -36,7 +35,7 @@ const PageContentBody = forwardRef(
ref={ref}
{...otherProps}
className={className}
scrollDirection={ScrollDirection.Vertical}
scrollDirection="vertical"
onScroll={onScrollWrapper}
>
<div className={innerClassName}>{children}</div>

@ -1,18 +0,0 @@
import PropTypes from 'prop-types';
import ReactDOM from 'react-dom';
function Portal(props) {
const { children, target } = props;
return ReactDOM.createPortal(children, target);
}
Portal.propTypes = {
children: PropTypes.node.isRequired,
target: PropTypes.object.isRequired
};
Portal.defaultProps = {
target: document.getElementById('portal-root')
};
export default Portal;

@ -0,0 +1,20 @@
import ReactDOM from 'react-dom';
interface PortalProps {
children: Parameters<typeof ReactDOM.createPortal>[0];
target?: Parameters<typeof ReactDOM.createPortal>[1];
}
const defaultTarget = document.getElementById('portal-root');
function Portal(props: PortalProps) {
const { children, target = defaultTarget } = props;
if (!target) {
return null;
}
return ReactDOM.createPortal(children, target);
}
export default Portal;

@ -1,44 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Switch as RouterSwitch } from 'react-router-dom';
import { map } from 'Helpers/elementChildren';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
class Switch extends Component {
//
// Render
render() {
const {
children
} = this.props;
return (
<RouterSwitch>
{
map(children, (child) => {
const {
path: childPath,
addUrlBase = true
} = child.props;
if (!childPath) {
return child;
}
const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath;
return React.cloneElement(child, { path });
})
}
</RouterSwitch>
);
}
}
Switch.propTypes = {
children: PropTypes.node.isRequired
};
export default Switch;

@ -0,0 +1,38 @@
import React, { Children, ReactElement, ReactNode } from 'react';
import { Switch as RouterSwitch } from 'react-router-dom';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
interface ExtendedRoute {
path: string;
addUrlBase?: boolean;
}
interface SwitchProps {
children: ReactNode;
}
function Switch({ children }: SwitchProps) {
return (
<RouterSwitch>
{Children.map(children, (child) => {
if (!React.isValidElement<ExtendedRoute>(child)) {
return child;
}
const elementChild: ReactElement<ExtendedRoute> = child;
const { path: childPath, addUrlBase = true } = elementChild.props;
if (!childPath) {
return child;
}
const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath;
return React.cloneElement(child, { path });
})}
</RouterSwitch>
);
}
export default Switch;

@ -1,179 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Scrollbars } from 'react-custom-scrollbars-2';
import { scrollDirections } from 'Helpers/Props';
import styles from './OverlayScroller.css';
const SCROLLBAR_SIZE = 10;
class OverlayScroller extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._scroller = null;
this._isScrolling = false;
}
componentDidUpdate(prevProps) {
const {
scrollTop
} = this.props;
if (
!this._isScrolling &&
scrollTop != null &&
scrollTop !== prevProps.scrollTop
) {
this._scroller.scrollTop(scrollTop);
}
}
//
// Control
_setScrollRef = (ref) => {
this._scroller = ref;
if (ref) {
this.props.registerScroller(ref.view);
}
};
_renderThumb = (props) => {
return (
<div
className={this.props.trackClassName}
{...props}
/>
);
};
_renderTrackHorizontal = ({ style, props }) => {
const finalStyle = {
...style,
right: 2,
bottom: 2,
left: 2,
borderRadius: 3,
height: SCROLLBAR_SIZE
};
return (
<div
className={styles.track}
style={finalStyle}
{...props}
/>
);
};
_renderTrackVertical = ({ style, props }) => {
const finalStyle = {
...style,
right: 2,
bottom: 2,
top: 2,
borderRadius: 3,
width: SCROLLBAR_SIZE
};
return (
<div
className={styles.track}
style={finalStyle}
{...props}
/>
);
};
_renderView = (props) => {
return (
<div
className={this.props.className}
{...props}
/>
);
};
//
// Listers
onScrollStart = () => {
this._isScrolling = true;
};
onScrollStop = () => {
this._isScrolling = false;
};
onScroll = (event) => {
const {
scrollTop,
scrollLeft
} = event.currentTarget;
this._isScrolling = true;
const onScroll = this.props.onScroll;
if (onScroll) {
onScroll({ scrollTop, scrollLeft });
}
};
//
// Render
render() {
const {
autoHide,
autoScroll,
children
} = this.props;
return (
<Scrollbars
ref={this._setScrollRef}
autoHide={autoHide}
hideTracksWhenNotNeeded={autoScroll}
renderTrackHorizontal={this._renderTrackHorizontal}
renderTrackVertical={this._renderTrackVertical}
renderThumbHorizontal={this._renderThumb}
renderThumbVertical={this._renderThumb}
renderView={this._renderView}
onScrollStart={this.onScrollStart}
onScrollStop={this.onScrollStop}
onScroll={this.onScroll}
>
{children}
</Scrollbars>
);
}
}
OverlayScroller.propTypes = {
className: PropTypes.string,
trackClassName: PropTypes.string,
scrollTop: PropTypes.number,
scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]).isRequired,
autoHide: PropTypes.bool.isRequired,
autoScroll: PropTypes.bool.isRequired,
children: PropTypes.node,
onScroll: PropTypes.func,
registerScroller: PropTypes.func
};
OverlayScroller.defaultProps = {
className: styles.scroller,
trackClassName: styles.thumb,
scrollDirection: scrollDirections.VERTICAL,
autoHide: false,
autoScroll: true,
registerScroller: () => { /* no-op */ }
};
export default OverlayScroller;

@ -0,0 +1,127 @@
import React, { ComponentPropsWithoutRef, useCallback, useRef } from 'react';
import { Scrollbars } from 'react-custom-scrollbars-2';
import { ScrollDirection } from 'Helpers/Props/scrollDirections';
import { OnScroll } from './Scroller';
import styles from './OverlayScroller.css';
const SCROLLBAR_SIZE = 10;
interface OverlayScrollerProps {
className?: string;
trackClassName?: string;
scrollTop?: number;
scrollDirection: ScrollDirection;
autoHide: boolean;
autoScroll: boolean;
children?: React.ReactNode;
onScroll?: (payload: OnScroll) => void;
}
interface ScrollbarTrackProps {
style: React.CSSProperties;
props: ComponentPropsWithoutRef<'div'>;
}
function OverlayScroller(props: OverlayScrollerProps) {
const {
autoHide = false,
autoScroll = true,
className = styles.scroller,
trackClassName = styles.thumb,
children,
onScroll,
} = props;
const scrollBarRef = useRef<Scrollbars>(null);
const isScrolling = useRef(false);
const handleScrollStart = useCallback(() => {
isScrolling.current = true;
}, []);
const handleScrollStop = useCallback(() => {
isScrolling.current = false;
}, []);
const handleScroll = useCallback(() => {
if (!scrollBarRef.current) {
return;
}
const { scrollTop, scrollLeft } = scrollBarRef.current.getValues();
isScrolling.current = true;
if (onScroll) {
onScroll({ scrollTop, scrollLeft });
}
}, [onScroll]);
const renderThumb = useCallback(
(props: ComponentPropsWithoutRef<'div'>) => {
return <div className={trackClassName} {...props} />;
},
[trackClassName]
);
const renderTrackHorizontal = useCallback(
({ style, props: trackProps }: ScrollbarTrackProps) => {
const finalStyle = {
...style,
right: 2,
bottom: 2,
left: 2,
borderRadius: 3,
height: SCROLLBAR_SIZE,
};
return (
<div className={styles.track} style={finalStyle} {...trackProps} />
);
},
[]
);
const renderTrackVertical = useCallback(
({ style, props: trackProps }: ScrollbarTrackProps) => {
const finalStyle = {
...style,
right: 2,
bottom: 2,
top: 2,
borderRadius: 3,
width: SCROLLBAR_SIZE,
};
return (
<div className={styles.track} style={finalStyle} {...trackProps} />
);
},
[]
);
const renderView = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(props: any) => {
return <div className={className} {...props} />;
},
[className]
);
return (
<Scrollbars
ref={scrollBarRef}
autoHide={autoHide}
hideTracksWhenNotNeeded={autoScroll}
renderTrackHorizontal={renderTrackHorizontal}
renderTrackVertical={renderTrackVertical}
renderThumbHorizontal={renderThumb}
renderThumbVertical={renderThumb}
renderView={renderView}
onScrollStart={handleScrollStart}
onScrollStop={handleScrollStop}
onScroll={handleScroll}
>
{children}
</Scrollbars>
);
}
export default OverlayScroller;

@ -8,7 +8,7 @@ import React, {
useEffect,
useRef,
} from 'react';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import { ScrollDirection } from 'Helpers/Props/scrollDirections';
import styles from './Scroller.css';
export interface OnScroll {
@ -33,7 +33,7 @@ const Scroller = forwardRef(
className,
autoFocus = false,
autoScroll = true,
scrollDirection = ScrollDirection.Vertical,
scrollDirection = 'vertical',
children,
scrollTop,
initialScrollTop,
@ -59,7 +59,7 @@ const Scroller = forwardRef(
currentRef.current.scrollTop = scrollTop;
}
if (autoFocus && scrollDirection !== ScrollDirection.None) {
if (autoFocus && scrollDirection !== 'none') {
currentRef.current.focus({ preventScroll: true });
}
}, [autoFocus, currentRef, scrollDirection, scrollTop]);

@ -10,11 +10,13 @@ export interface SpinnerIconProps extends IconProps {
export default function SpinnerIcon({
name,
spinningName = icons.SPINNER,
isSpinning,
...otherProps
}: SpinnerIconProps) {
return (
<Icon
name={(otherProps.isSpinning && spinningName) || name}
name={(isSpinning && spinningName) || name}
isSpinning={isSpinning}
{...otherProps}
/>
);

@ -1,43 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { tooltipPositions } from 'Helpers/Props';
import Tooltip from './Tooltip';
import styles from './Popover.css';
function Popover(props) {
const {
title,
body,
...otherProps
} = props;
return (
<Tooltip
{...otherProps}
bodyClassName={styles.tooltipBody}
tooltip={
<div>
<div className={styles.title}>
{title}
</div>
<div className={styles.body}>
{body}
</div>
</div>
}
/>
);
}
Popover.propTypes = {
className: PropTypes.string,
bodyClassName: PropTypes.string,
anchor: PropTypes.node.isRequired,
title: PropTypes.string.isRequired,
body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
position: PropTypes.oneOf(tooltipPositions.all),
canFlip: PropTypes.bool
};
export default Popover;

@ -0,0 +1,26 @@
import React from 'react';
import Tooltip, { TooltipProps } from './Tooltip';
import styles from './Popover.css';
interface PopoverProps extends Omit<TooltipProps, 'tooltip' | 'bodyClassName'> {
title: string;
body: React.ReactNode;
}
function Popover({ title, body, ...otherProps }: PopoverProps) {
return (
<Tooltip
{...otherProps}
bodyClassName={styles.tooltipBody}
tooltip={
<div>
<div className={styles.title}>{title}</div>
<div className={styles.body}>{body}</div>
</div>
}
/>
);
}
export default Popover;

@ -1,235 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import { kinds, tooltipPositions } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import { isMobile as isMobileUtil } from 'Utilities/browser';
import styles from './Tooltip.css';
let maxWidth = null;
function getMaxWidth() {
const windowWidth = window.innerWidth;
if (windowWidth >= parseInt(dimensions.breakpointLarge)) {
maxWidth = 800;
} else if (windowWidth >= parseInt(dimensions.breakpointMedium)) {
maxWidth = 650;
} else if (windowWidth >= parseInt(dimensions.breakpointSmall)) {
maxWidth = 500;
} else {
maxWidth = 450;
}
return maxWidth;
}
class Tooltip extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._scheduleUpdate = null;
this._closeTimeout = null;
this._maxWidth = maxWidth || getMaxWidth();
this.state = {
isOpen: false
};
}
componentDidUpdate() {
if (this._scheduleUpdate && this.state.isOpen) {
this._scheduleUpdate();
}
}
componentWillUnmount() {
if (this._closeTimeout) {
this._closeTimeout = clearTimeout(this._closeTimeout);
}
}
//
// Control
computeMaxSize = (data) => {
const {
top,
right,
bottom,
left
} = data.offsets.reference;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
if ((/^top/).test(data.placement)) {
data.styles.maxHeight = top - 20;
} else if ((/^bottom/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom - 20;
} else if ((/^right/).test(data.placement)) {
data.styles.maxWidth = Math.min(this._maxWidth, windowWidth - right - 20);
data.styles.maxHeight = top - 20;
} else {
data.styles.maxWidth = Math.min(this._maxWidth, left - 20);
data.styles.maxHeight = top - 20;
}
return data;
};
//
// Listeners
onMeasure = ({ width }) => {
this.setState({ width });
};
onClick = () => {
if (isMobileUtil()) {
this.setState({ isOpen: !this.state.isOpen });
}
};
onMouseEnter = () => {
if (this._closeTimeout) {
this._closeTimeout = clearTimeout(this._closeTimeout);
}
this.setState({ isOpen: true });
};
onMouseLeave = () => {
this._closeTimeout = setTimeout(() => {
this.setState({ isOpen: false });
}, 100);
};
//
// Render
render() {
const {
className,
bodyClassName,
anchor,
tooltip,
kind,
position,
canFlip
} = this.props;
return (
<Manager>
<Reference>
{({ ref }) => (
<span
ref={ref}
className={className}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
{anchor}
</span>
)}
</Reference>
<Portal>
<Popper
placement={position}
// Disable events to improve performance when many tooltips
// are shown (Quality Definitions for example).
eventsEnabled={false}
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: this.computeMaxSize
},
preventOverflow: {
// Fixes positioning for tooltips in the queue
// and likely others.
escapeWithReference: false
},
flip: {
enabled: canFlip
}
}}
>
{({ ref, style, placement, arrowProps, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
const popperPlacement = placement ? placement.split('-')[0] : position;
const vertical = popperPlacement === 'top' || popperPlacement === 'bottom';
return (
<div
ref={ref}
className={classNames(
styles.tooltipContainer,
vertical ? styles.verticalContainer : styles.horizontalContainer
)}
style={style}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
<div
className={this.state.isOpen ? classNames(
styles.arrow,
styles[kind],
styles[popperPlacement]
) : styles.arrowDisabled}
ref={arrowProps.ref}
style={arrowProps.style}
/>
{
this.state.isOpen ?
<div
className={classNames(
styles.tooltip,
styles[kind]
)}
>
<div
className={bodyClassName}
>
{tooltip}
</div>
</div> :
null
}
</div>
);
}}
</Popper>
</Portal>
</Manager>
);
}
}
Tooltip.propTypes = {
className: PropTypes.string,
bodyClassName: PropTypes.string.isRequired,
anchor: PropTypes.node.isRequired,
tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]),
position: PropTypes.oneOf(tooltipPositions.all),
canFlip: PropTypes.bool.isRequired
};
Tooltip.defaultProps = {
bodyClassName: styles.body,
kind: kinds.DEFAULT,
position: tooltipPositions.TOP,
canFlip: false
};
export default Tooltip;

@ -0,0 +1,226 @@
import classNames from 'classnames';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import { kinds, tooltipPositions } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import dimensions from 'Styles/Variables/dimensions';
import { isMobile as isMobileUtil } from 'Utilities/browser';
import styles from './Tooltip.css';
export interface TooltipProps {
className?: string;
bodyClassName?: string;
anchor: React.ReactNode;
tooltip: string | React.ReactNode;
kind?: Extract<Kind, keyof typeof styles>;
position?: (typeof tooltipPositions.all)[number];
canFlip?: boolean;
}
function Tooltip(props: TooltipProps) {
const {
className,
bodyClassName = styles.body,
anchor,
tooltip,
kind = kinds.DEFAULT,
position = tooltipPositions.TOP,
canFlip = false,
} = props;
const closeTimeout = useRef<ReturnType<typeof setTimeout>>();
const updater = useRef<(() => void) | null>(null);
const [isOpen, setIsOpen] = useState(false);
const handleClick = useCallback(() => {
if (!isMobileUtil()) {
return;
}
setIsOpen((isOpen) => {
return !isOpen;
});
}, [setIsOpen]);
const handleMouseEnterAnchor = useCallback(() => {
// Mobile will fire mouse enter and click events rapidly,
// this causes the tooltip not to open on the first press.
// Ignore the mouse enter event on mobile.
if (isMobileUtil()) {
return;
}
if (closeTimeout.current) {
clearTimeout(closeTimeout.current);
}
setIsOpen(true);
}, [setIsOpen]);
const handleMouseEnterTooltip = useCallback(() => {
if (closeTimeout.current) {
clearTimeout(closeTimeout.current);
}
setIsOpen(true);
}, [setIsOpen]);
const handleMouseLeave = useCallback(() => {
// Still listen for mouse leave on mobile to allow clicks outside to close the tooltip.
clearTimeout(closeTimeout.current);
closeTimeout.current = setTimeout(() => {
setIsOpen(false);
}, 100);
}, [setIsOpen]);
const maxWidth = useMemo(() => {
const windowWidth = window.innerWidth;
if (windowWidth >= parseInt(dimensions.breakpointLarge)) {
return 800;
} else if (windowWidth >= parseInt(dimensions.breakpointMedium)) {
return 650;
} else if (windowWidth >= parseInt(dimensions.breakpointSmall)) {
return 500;
}
return 450;
}, []);
const computeMaxSize = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(data: any) => {
const { top, right, bottom, left } = data.offsets.reference;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
if (/^top/.test(data.placement)) {
data.styles.maxHeight = top - 20;
} else if (/^bottom/.test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom - 20;
} else if (/^right/.test(data.placement)) {
data.styles.maxWidth = Math.min(maxWidth, windowWidth - right - 20);
data.styles.maxHeight = top - 20;
} else {
data.styles.maxWidth = Math.min(maxWidth, left - 20);
data.styles.maxHeight = top - 20;
}
return data;
},
[maxWidth]
);
useEffect(() => {
if (updater.current && isOpen) {
updater.current();
}
});
useEffect(() => {
return () => {
if (closeTimeout.current) {
clearTimeout(closeTimeout.current);
}
};
}, []);
return (
<Manager>
<Reference>
{({ ref }) => (
<span
ref={ref}
className={className}
onClick={handleClick}
onMouseEnter={handleMouseEnterAnchor}
onMouseLeave={handleMouseLeave}
>
{anchor}
</span>
)}
</Reference>
<Portal>
<Popper
// @ts-expect-error - PopperJS types are not in sync with our position types.
placement={position}
// Disable events to improve performance when many tooltips
// are shown (Quality Definitions for example).
eventsEnabled={false}
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: computeMaxSize,
},
preventOverflow: {
// Fixes positioning for tooltips in the queue
// and likely others.
escapeWithReference: false,
},
flip: {
enabled: canFlip,
},
}}
>
{({ ref, style, placement, arrowProps, scheduleUpdate }) => {
updater.current = scheduleUpdate;
const popperPlacement = placement
? placement.split('-')[0]
: position;
const vertical =
popperPlacement === 'top' || popperPlacement === 'bottom';
return (
<div
ref={ref}
className={classNames(
styles.tooltipContainer,
vertical
? styles.verticalContainer
: styles.horizontalContainer
)}
style={style}
onMouseEnter={handleMouseEnterTooltip}
onMouseLeave={handleMouseLeave}
>
<div
ref={arrowProps.ref}
className={
isOpen
? classNames(
styles.arrow,
styles[kind],
// @ts-expect-error - is a string that may not exist in styles
styles[popperPlacement]
)
: styles.arrowDisabled
}
style={arrowProps.style}
/>
{isOpen ? (
<div className={classNames(styles.tooltip, styles[kind])}>
<div className={bodyClassName}>{tooltip}</div>
</div>
) : null}
</div>
);
}}
</Popper>
</Portal>
</Manager>
);
}
export default Tooltip;

@ -1,8 +0,0 @@
enum ScrollDirection {
Horizontal = 'horizontal',
Vertical = 'vertical',
None = 'none',
Both = 'both',
}
export default ScrollDirection;

@ -1,6 +0,0 @@
enum SortDirection {
Ascending = 'ascending',
Descending = 'descending',
}
export default SortDirection;

@ -1,3 +0,0 @@
type TooltipPosition = 'top' | 'right' | 'bottom' | 'left';
export default TooltipPosition;

@ -3,3 +3,5 @@ export const CENTER = 'center';
export const RIGHT = 'right';
export const all = [LEFT, CENTER, RIGHT];
export type Align = 'left' | 'center' | 'right';

@ -4,3 +4,5 @@ export const HORIZONTAL = 'horizontal';
export const VERTICAL = 'vertical';
export const all = [NONE, HORIZONTAL, VERTICAL, BOTH];
export type ScrollDirection = 'none' | 'both' | 'horizontal' | 'vertical';

@ -2,3 +2,5 @@ export const ASCENDING = 'ascending';
export const DESCENDING = 'descending';
export const all = [ASCENDING, DESCENDING];
export type SortDirection = 'ascending' | 'descending';

@ -3,9 +3,6 @@ export const RIGHT = 'right';
export const BOTTOM = 'bottom';
export const LEFT = 'left';
export const all = [
TOP,
RIGHT,
BOTTOM,
LEFT
];
export const all = [TOP, RIGHT, BOTTOM, LEFT];
export type TooltipPosition = 'top' | 'right' | 'bottom' | 'left';

@ -13,3 +13,10 @@
cursor: pointer;
}
@media only screen and (max-width: $breakpointExtraSmall) {
.links {
display: flex;
flex-flow: column wrap;
}
}

@ -3,7 +3,7 @@ import MenuContent from 'Components/Menu/MenuContent';
import SortMenu from 'Components/Menu/SortMenu';
import SortMenuItem from 'Components/Menu/SortMenuItem';
import { align } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
import translate from 'Utilities/String/translate';
interface MovieIndexSortMenuProps {

@ -22,7 +22,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import withScrollPosition from 'Components/withScrollPosition';
import { align, icons, kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import { DESCENDING } from 'Helpers/Props/sortDirections';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import NoMovie from 'Movie/NoMovie';
import { executeCommand } from 'Store/Actions/commandActions';
@ -211,7 +211,7 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
const order = Object.keys(characters).sort();
// Reverse if sorting descending
if (sortDirection === SortDirection.Descending) {
if (sortDirection === DESCENDING) {
order.reverse();
}

@ -5,7 +5,7 @@ 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 { SortDirection } from 'Helpers/Props/sortDirections';
import MovieIndexPoster from 'Movie/Index/Posters/MovieIndexPoster';
import Movie from 'Movie/Movie';
import dimensions from 'Styles/Variables/dimensions';

@ -7,8 +7,7 @@ import AppState from 'App/State/AppState';
import Scroller from 'Components/Scroller/Scroller';
import Column from 'Components/Table/Column';
import useMeasure from 'Helpers/Hooks/useMeasure';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
import Movie from 'Movie/Movie';
import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
@ -170,10 +169,7 @@ function MovieIndexTable(props: MovieIndexTableProps) {
return (
<div ref={measureRef}>
<Scroller
className={styles.tableScroller}
scrollDirection={ScrollDirection.Horizontal}
>
<Scroller className={styles.tableScroller} scrollDirection="horizontal">
<MovieIndexTableHeader
columns={columns}
sortKey={sortKey}

@ -9,7 +9,7 @@ import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
import { icons } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
import {
setMovieSort,
setMovieTableOption,

@ -14,7 +14,7 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
import {
bulkDeleteCustomFormats,
bulkEditCustomFormats,

@ -14,7 +14,7 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
import {
bulkDeleteDownloadClients,
bulkEditDownloadClients,

@ -14,7 +14,7 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
import {
bulkDeleteIndexers,
bulkEditIndexers,

@ -1,4 +1,4 @@
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
export type SortCallback = (
sortKey: string,

@ -290,6 +290,7 @@
"DefaultDelayProfileMovie": "This is the default profile. It applies to all movies that don't have an explicit profile.",
"DefaultNameCopiedProfile": "{name} - Copy",
"DefaultNameCopiedSpecification": "{name} - Copy",
"DefaultNotFoundMessage": "You must be lost, nothing to see here.",
"DelayMinutes": "{delay} Minutes",
"DelayProfile": "Delay Profile",
"DelayProfileMovieTagsHelpText": "Applies to movies with at least one matching tag",
@ -1722,6 +1723,8 @@
"TmdbVotes": "TMDb Votes",
"Today": "Today",
"TodayAt": "Today at {time}",
"ToggleMonitoredToUnmonitored": "Monitored, click to unmonitor",
"ToggleUnmonitoredToMonitored": "Unmonitored, click to monitor",
"Tomorrow": "Tomorrow",
"TomorrowAt": "Tomorrow at {time}",
"TorrentBlackhole": "Torrent Blackhole",

Loading…
Cancel
Save