Convert Components to TypeScript

pull/7164/head v4.0.9.2300
Mark McDowall 4 months ago committed by Mark McDowall
parent 53d8c9ba8d
commit e1cbc4a782

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

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

@ -6,6 +6,7 @@ import EpisodesAppState from './EpisodesAppState';
import HistoryAppState from './HistoryAppState'; import HistoryAppState from './HistoryAppState';
import InteractiveImportAppState from './InteractiveImportAppState'; import InteractiveImportAppState from './InteractiveImportAppState';
import ParseAppState from './ParseAppState'; import ParseAppState from './ParseAppState';
import PathsAppState from './PathsAppState';
import QueueAppState from './QueueAppState'; import QueueAppState from './QueueAppState';
import RootFolderAppState from './RootFolderAppState'; import RootFolderAppState from './RootFolderAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState'; import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
@ -69,6 +70,7 @@ interface AppState {
history: HistoryAppState; history: HistoryAppState;
interactiveImport: InteractiveImportAppState; interactiveImport: InteractiveImportAppState;
parse: ParseAppState; parse: ParseAppState;
paths: PathsAppState;
queue: QueueAppState; queue: QueueAppState;
rootFolders: RootFolderAppState; rootFolders: RootFolderAppState;
series: SeriesAppState; series: SeriesAppState;

@ -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;

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

@ -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,246 +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 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 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>
{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/sonarr/faq#why-cant-sonarr-see-my-files-on-a-remote-server' })} />
</Alert>
}
<PathInput
className={styles.pathInput}
placeholder={translate('FileBrowserPlaceholderText')}
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={translate('MyComputer')}
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/sonarr/faq#why-cant-sonarr-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,22 +1,22 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Icon from 'Components/Icon'; import Icon, { IconProps } from 'Components/Icon';
import Tooltip from 'Components/Tooltip/Tooltip'; import Tooltip from 'Components/Tooltip/Tooltip';
import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './HeartRating.css'; import styles from './HeartRating.css';
function HeartRating({ rating, votes, iconSize }) { interface HeartRatingProps {
rating: number;
votes?: number;
iconSize?: IconProps['size'];
}
function HeartRating({ rating, votes = 0, iconSize = 14 }: HeartRatingProps) {
return ( return (
<Tooltip <Tooltip
anchor={ anchor={
<span className={styles.rating}> <span className={styles.rating}>
<Icon <Icon className={styles.heart} name={icons.HEART} size={iconSize} />
className={styles.heart}
name={icons.HEART}
size={iconSize}
/>
{rating * 10}% {rating * 10}%
</span> </span>
} }
@ -27,15 +27,4 @@ function HeartRating({ rating, votes, iconSize }) {
); );
} }
HeartRating.propTypes = {
rating: PropTypes.number.isRequired,
votes: PropTypes.number.isRequired,
iconSize: PropTypes.number.isRequired
};
HeartRating.defaultProps = {
votes: 0,
iconSize: 14
};
export default HeartRating; export default HeartRating;

@ -5,6 +5,7 @@ import {
import classNames from 'classnames'; import classNames from 'classnames';
import React, { ComponentProps } from 'react'; import React, { ComponentProps } from 'react';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import styles from './Icon.css'; import styles from './Icon.css';
export interface IconProps export interface IconProps
@ -14,7 +15,7 @@ export interface IconProps
> { > {
containerClassName?: ComponentProps<'span'>['className']; containerClassName?: ComponentProps<'span'>['className'];
name: FontAwesomeIconProps['icon']; name: FontAwesomeIconProps['icon'];
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>; kind?: Extract<Kind, keyof typeof styles>;
size?: number; size?: number;
isSpinning?: FontAwesomeIconProps['spin']; isSpinning?: FontAwesomeIconProps['spin'];
title?: string | (() => string); title?: string | (() => string);

@ -1,11 +1,13 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React, { ComponentProps, ReactNode } from 'react'; import React, { ComponentProps, ReactNode } from 'react';
import { kinds, sizes } from 'Helpers/Props'; import { kinds, sizes } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { Size } from 'Helpers/Props/sizes';
import styles from './Label.css'; import styles from './Label.css';
export interface LabelProps extends ComponentProps<'span'> { export interface LabelProps extends ComponentProps<'span'> {
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>; kind?: Extract<Kind, keyof typeof styles>;
size?: Extract<(typeof sizes.all)[number], keyof typeof styles>; size?: Extract<Size, keyof typeof styles>;
outline?: boolean; outline?: boolean;
children: ReactNode; children: ReactNode;
} }

@ -1,6 +1,8 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React from 'react';
import { align, kinds, sizes } from 'Helpers/Props'; import { align, kinds, sizes } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { Size } from 'Helpers/Props/sizes';
import Link, { LinkProps } from './Link'; import Link, { LinkProps } from './Link';
import styles from './Button.css'; import styles from './Button.css';
@ -9,8 +11,8 @@ export interface ButtonProps extends Omit<LinkProps, 'children' | 'size'> {
(typeof align.all)[number], (typeof align.all)[number],
keyof typeof styles keyof typeof styles
>; >;
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>; kind?: Extract<Kind, keyof typeof styles>;
size?: Extract<(typeof sizes.all)[number], keyof typeof styles>; size?: Extract<Size, keyof typeof styles>;
children: Required<LinkProps['children']>; children: Required<LinkProps['children']>;
} }

@ -1,50 +0,0 @@
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={styles.rippleContainer}
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,32 @@
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={styles.rippleContainer} 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.', 'Bleep Bloop.',
'Locating the required gigapixels to render...', 'Locating the required gigapixels to render...',
'Spinning up the hamster wheel...', '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', 'Hum something loud while others stare',
'Loading humorous message... Please Wait', 'Loading humorous message... Please Wait',
'I could\'ve been faster in Python', "I could've been faster in Python",
'Don\'t forget to rewind your episodes', "Don't forget to rewind your episodes",
'Congratulations! You are the 1000th visitor.', '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...', 'RE-calibrating the internet...',
'I\'ll be here all week', "I'll be here all week",
'Don\'t forget to tip your waitress', "Don't forget to tip your waitress",
'Apply directly to the forehead', 'Apply directly to the forehead',
'Loading Battlestation' 'Loading Battlestation',
]; ];
let message = null; let message: string | null = null;
function LoadingMessage() { function LoadingMessage() {
if (!message) { if (!message) {
@ -30,11 +30,7 @@ function LoadingMessage() {
message = messages[index]; message = messages[index];
} }
return ( return <div className={styles.loadingMessage}>{message}</div>;
<div className={styles.loadingMessage}>
{message}
</div>
);
} }
export default LoadingMessage; 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;

@ -6,10 +6,7 @@ import styles from './MetadataAttribution.css';
export default function MetadataAttribution() { export default function MetadataAttribution() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Link <Link className={styles.attribution} to="/settings/metadatasource">
className={styles.attribution}
to="/settings/metadatasource"
>
{translate('MetadataProvidedBy', { provider: 'TheTVDB' })} {translate('MetadataProvidedBy', { provider: 'TheTVDB' })}
</Link> </Link>
</div> </div>

@ -1,80 +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 translate from 'Utilities/String/translate';
import styles from './MonitorToggleButton.css';
function getTooltip(monitored, isDisabled) {
if (isDisabled) {
return translate('ToggleMonitoredSeriesUnmonitored ');
}
if (monitored) {
return translate('ToggleMonitoredToUnmonitored');
}
return translate('ToggleUnmonitoredToMonitored');
}
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 translate('ToggleMonitoredSeriesUnmonitored');
}
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,18 +1,19 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import PageContent from 'Components/Page/PageContent'; import PageContent from 'Components/Page/PageContent';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './NotFound.css'; import styles from './NotFound.css';
function NotFound(props) { interface NotFoundProps {
message?: string;
}
function NotFound(props: NotFoundProps) {
const { message = translate('DefaultNotFoundMessage') } = props; const { message = translate('DefaultNotFoundMessage') } = props;
return ( return (
<PageContent title="MIA"> <PageContent title="MIA">
<div className={styles.container}> <div className={styles.container}>
<div className={styles.message}> <div className={styles.message}>{message}</div>
{message}
</div>
<img <img
className={styles.image} className={styles.image}
@ -23,8 +24,4 @@ function NotFound(props) {
); );
} }
NotFound.propTypes = {
message: PropTypes.string
};
export default NotFound; export default NotFound;

@ -1,6 +1,5 @@
import React, { ForwardedRef, forwardRef, ReactNode, useCallback } from 'react'; import React, { ForwardedRef, forwardRef, ReactNode, useCallback } from 'react';
import Scroller, { OnScroll } from 'Components/Scroller/Scroller'; import Scroller, { OnScroll } from 'Components/Scroller/Scroller';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import { isLocked } from 'Utilities/scrollLock'; import { isLocked } from 'Utilities/scrollLock';
import styles from './PageContentBody.css'; import styles from './PageContentBody.css';
@ -36,7 +35,7 @@ const PageContentBody = forwardRef(
ref={ref} ref={ref}
{...otherProps} {...otherProps}
className={className} className={className}
scrollDirection={ScrollDirection.Vertical} scrollDirection="vertical"
onScroll={onScrollWrapper} onScroll={onScrollWrapper}
> >
<div className={innerClassName}>{children}</div> <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, useEffect,
useRef, useRef,
} from 'react'; } from 'react';
import ScrollDirection from 'Helpers/Props/ScrollDirection'; import { ScrollDirection } from 'Helpers/Props/scrollDirections';
import styles from './Scroller.css'; import styles from './Scroller.css';
export interface OnScroll { export interface OnScroll {
@ -33,7 +33,7 @@ const Scroller = forwardRef(
className, className,
autoFocus = false, autoFocus = false,
autoScroll = true, autoScroll = true,
scrollDirection = ScrollDirection.Vertical, scrollDirection = 'vertical',
children, children,
scrollTop, scrollTop,
initialScrollTop, initialScrollTop,
@ -59,7 +59,7 @@ const Scroller = forwardRef(
currentRef.current.scrollTop = scrollTop; currentRef.current.scrollTop = scrollTop;
} }
if (autoFocus && scrollDirection !== ScrollDirection.None) { if (autoFocus && scrollDirection !== 'none') {
currentRef.current.focus({ preventScroll: true }); currentRef.current.focus({ preventScroll: true });
} }
}, [autoFocus, currentRef, scrollDirection, scrollTop]); }, [autoFocus, currentRef, scrollDirection, scrollTop]);

@ -10,11 +10,13 @@ export interface SpinnerIconProps extends IconProps {
export default function SpinnerIcon({ export default function SpinnerIcon({
name, name,
spinningName = icons.SPINNER, spinningName = icons.SPINNER,
isSpinning,
...otherProps ...otherProps
}: SpinnerIconProps) { }: SpinnerIconProps) {
return ( return (
<Icon <Icon
name={(otherProps.isSpinning && spinningName) || name} name={(isSpinning && spinningName) || name}
isSpinning={isSpinning}
{...otherProps} {...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,216 @@
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(0);
const updater = useRef<(() => void) | null>(null);
const [isOpen, setIsOpen] = useState(false);
const handleClick = useCallback(() => {
if (!isMobileUtil()) {
return;
}
setIsOpen((isOpen) => {
return !isOpen;
});
}, [setIsOpen]);
const handleMouseEnter = 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) {
window.clearTimeout(closeTimeout.current);
}
setIsOpen(true);
}, [setIsOpen]);
const handleMouseLeave = useCallback(() => {
// Still listen for mouse leave on mobile to allow clicks outside to close the tooltip.
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(() => {
const currentTimeout = closeTimeout.current;
if (updater.current && isOpen) {
updater.current();
}
return () => {
if (currentTimeout) {
window.clearTimeout(currentTimeout);
}
};
});
return (
<Manager>
<Reference>
{({ ref }) => (
<span
ref={ref}
className={className}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
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={handleMouseEnter}
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;

@ -111,7 +111,6 @@ function EpisodeDetailsModalContent(props: EpisodeDetailsModalContentProps) {
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>
<ModalHeader> <ModalHeader>
<MonitorToggleButton <MonitorToggleButton
id={episodeId}
monitored={monitored} monitored={monitored}
size={18} size={18}
isDisabled={!seriesMonitored} isDisabled={!seriesMonitored}

@ -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 RIGHT = 'right';
export const all = [LEFT, CENTER, RIGHT]; export const all = [LEFT, CENTER, RIGHT];
export type Align = 'left' | 'center' | 'right';

@ -21,3 +21,15 @@ export const all = [
SUCCESS, SUCCESS,
WARNING, WARNING,
] as const; ] as const;
export type Kind =
| 'danger'
| 'default'
| 'disabled'
| 'info'
| 'inverse'
| 'pink'
| 'primary'
| 'purple'
| 'success'
| 'warning';

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

@ -13,3 +13,11 @@ export const all = [
EXTRA_LARGE, EXTRA_LARGE,
EXTRA_EXTRA_LARGE, EXTRA_EXTRA_LARGE,
] as const; ] as const;
export type Size =
| 'extraSmall'
| 'small'
| 'medium'
| 'large'
| 'extraLarge'
| 'extraExtraLarge';

@ -2,3 +2,5 @@ export const ASCENDING = 'ascending';
export const DESCENDING = 'descending'; export const DESCENDING = 'descending';
export const all = [ASCENDING, 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 BOTTOM = 'bottom';
export const LEFT = 'left'; export const LEFT = 'left';
export const all = [ export const all = [TOP, RIGHT, BOTTOM, LEFT];
TOP,
RIGHT, export type TooltipPosition = 'top' | 'right' | 'bottom' | 'left';
BOTTOM,
LEFT
];

@ -15,7 +15,7 @@ import TableBody from 'Components/Table/TableBody';
import Episode from 'Episode/Episode'; import Episode from 'Episode/Episode';
import useSelectState from 'Helpers/Hooks/useSelectState'; import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds, scrollDirections } from 'Helpers/Props'; import { kinds, scrollDirections } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection'; import { SortDirection } from 'Helpers/Props/sortDirections';
import { import {
clearEpisodes, clearEpisodes,
fetchEpisodes, fetchEpisodes,

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

@ -5,7 +5,7 @@ import { FixedSizeGrid as Grid, GridChildComponentProps } from 'react-window';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import AppState from 'App/State/AppState'; import AppState from 'App/State/AppState';
import useMeasure from 'Helpers/Hooks/useMeasure'; import useMeasure from 'Helpers/Hooks/useMeasure';
import SortDirection from 'Helpers/Props/SortDirection'; import { SortDirection } from 'Helpers/Props/sortDirections';
import SeriesIndexPoster from 'Series/Index/Posters/SeriesIndexPoster'; import SeriesIndexPoster from 'Series/Index/Posters/SeriesIndexPoster';
import Series from 'Series/Series'; import Series from 'Series/Series';
import dimensions from 'Styles/Variables/dimensions'; import dimensions from 'Styles/Variables/dimensions';

@ -22,7 +22,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import withScrollPosition from 'Components/withScrollPosition'; import withScrollPosition from 'Components/withScrollPosition';
import { align, icons, kinds } from 'Helpers/Props'; import { align, icons, kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection'; import { DESCENDING } from 'Helpers/Props/sortDirections';
import ParseToolbarButton from 'Parse/ParseToolbarButton'; import ParseToolbarButton from 'Parse/ParseToolbarButton';
import NoSeries from 'Series/NoSeries'; import NoSeries from 'Series/NoSeries';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
@ -201,7 +201,7 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
const order = Object.keys(characters).sort(); const order = Object.keys(characters).sort();
// Reverse if sorting descending // Reverse if sorting descending
if (sortDirection === SortDirection.Descending) { if (sortDirection === DESCENDING) {
order.reverse(); order.reverse();
} }

@ -7,8 +7,7 @@ import AppState from 'App/State/AppState';
import Scroller from 'Components/Scroller/Scroller'; import Scroller from 'Components/Scroller/Scroller';
import Column from 'Components/Table/Column'; import Column from 'Components/Table/Column';
import useMeasure from 'Helpers/Hooks/useMeasure'; import useMeasure from 'Helpers/Hooks/useMeasure';
import ScrollDirection from 'Helpers/Props/ScrollDirection'; import { SortDirection } from 'Helpers/Props/sortDirections';
import SortDirection from 'Helpers/Props/SortDirection';
import Series from 'Series/Series'; import Series from 'Series/Series';
import dimensions from 'Styles/Variables/dimensions'; import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
@ -172,10 +171,7 @@ function SeriesIndexTable(props: SeriesIndexTableProps) {
return ( return (
<div ref={measureRef}> <div ref={measureRef}>
<Scroller <Scroller className={styles.tableScroller} scrollDirection="horizontal">
className={styles.tableScroller}
scrollDirection={ScrollDirection.Horizontal}
>
<SeriesIndexTableHeader <SeriesIndexTableHeader
showBanners={showBanners} showBanners={showBanners}
columns={columns} columns={columns}

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

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

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

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

@ -11,6 +11,7 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import { kinds, sizes } from 'Helpers/Props'; import { kinds, sizes } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { fetchDiskSpace } from 'Store/Actions/systemActions'; import { fetchDiskSpace } from 'Store/Actions/systemActions';
import formatBytes from 'Utilities/Number/formatBytes'; import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
@ -67,7 +68,7 @@ function DiskSpace() {
const { freeSpace, totalSpace } = item; const { freeSpace, totalSpace } = item;
const diskUsage = 100 - (freeSpace / totalSpace) * 100; const diskUsage = 100 - (freeSpace / totalSpace) * 100;
let diskUsageKind: (typeof kinds.all)[number] = kinds.PRIMARY; let diskUsageKind: Kind = 'primary';
if (diskUsage > 90) { if (diskUsage > 90) {
diskUsageKind = kinds.DANGER; diskUsageKind = kinds.DANGER;

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

@ -1982,7 +1982,7 @@
"Titles": "Titles", "Titles": "Titles",
"Today": "Today", "Today": "Today",
"TodayAt": "Today at {time}", "TodayAt": "Today at {time}",
"ToggleMonitoredSeriesUnmonitored ": "Cannot toggle monitored state when series is unmonitored", "ToggleMonitoredSeriesUnmonitored": "Cannot toggle monitored state when series is unmonitored",
"ToggleMonitoredToUnmonitored": "Monitored, click to unmonitor", "ToggleMonitoredToUnmonitored": "Monitored, click to unmonitor",
"ToggleUnmonitoredToMonitored": "Unmonitored, click to monitor", "ToggleUnmonitoredToMonitored": "Unmonitored, click to monitor",
"Tomorrow": "Tomorrow", "Tomorrow": "Tomorrow",

@ -1595,7 +1595,7 @@
"TestParsing": "Probar análisis", "TestParsing": "Probar análisis",
"ThemeHelpText": "Cambia el tema de la interfaz de la aplicación, el tema 'Auto' usará el tema de tu sistema para establecer el modo luminoso u oscuro. Inspirado por Theme.Park", "ThemeHelpText": "Cambia el tema de la interfaz de la aplicación, el tema 'Auto' usará el tema de tu sistema para establecer el modo luminoso u oscuro. Inspirado por Theme.Park",
"TimeLeft": "Tiempo restante", "TimeLeft": "Tiempo restante",
"ToggleMonitoredSeriesUnmonitored ": "No se puede conmutar el estado monitorizado cuando la serie no está monitorizada", "ToggleMonitoredSeriesUnmonitored": "No se puede conmutar el estado monitorizado cuando la serie no está monitorizada",
"Tomorrow": "Mañana", "Tomorrow": "Mañana",
"TorrentBlackhole": "Blackhole de torrent", "TorrentBlackhole": "Blackhole de torrent",
"TorrentBlackholeSaveMagnetFiles": "Guardar archivos magnet", "TorrentBlackholeSaveMagnetFiles": "Guardar archivos magnet",

@ -328,7 +328,7 @@
"TagsSettingsSummary": "Täältä näet kaikki tunnisteet käyttökohteineen ja voit poistaa käyttämättömät tunnisteet.", "TagsSettingsSummary": "Täältä näet kaikki tunnisteet käyttökohteineen ja voit poistaa käyttämättömät tunnisteet.",
"Tomorrow": "Huomenna", "Tomorrow": "Huomenna",
"TestParsing": "Testaa jäsennystä", "TestParsing": "Testaa jäsennystä",
"ToggleMonitoredSeriesUnmonitored ": "Valvontatilaa ei ole mahdollista muuttaa, jos sarjaa ei valvota.", "ToggleMonitoredSeriesUnmonitored": "Valvontatilaa ei ole mahdollista muuttaa, jos sarjaa ei valvota.",
"Trace": "Jäljitys", "Trace": "Jäljitys",
"TotalRecords": "Rivien kokonaismäärä: {totalRecords}", "TotalRecords": "Rivien kokonaismäärä: {totalRecords}",
"TotalSpace": "Kokonaistila", "TotalSpace": "Kokonaistila",

@ -779,7 +779,7 @@
"Time": "Heure", "Time": "Heure",
"TimeFormat": "Format de l'heure", "TimeFormat": "Format de l'heure",
"TimeLeft": "Temps restant", "TimeLeft": "Temps restant",
"ToggleMonitoredSeriesUnmonitored ": "Impossible de basculer entre l'état surveillé lorsque la série n'est pas surveillée", "ToggleMonitoredSeriesUnmonitored": "Impossible de basculer entre l'état surveillé lorsque la série n'est pas surveillée",
"ToggleMonitoredToUnmonitored": "Surveillé, cliquez pour annuler la surveillance", "ToggleMonitoredToUnmonitored": "Surveillé, cliquez pour annuler la surveillance",
"TotalFileSize": "Taille totale des fichiers", "TotalFileSize": "Taille totale des fichiers",
"TotalRecords": "Enregistrements totaux: {totalRecords}", "TotalRecords": "Enregistrements totaux: {totalRecords}",

@ -992,7 +992,7 @@
"SourcePath": "Forrás útvonala", "SourcePath": "Forrás útvonala",
"TableOptionsButton": "Táblázat opciók gomb", "TableOptionsButton": "Táblázat opciók gomb",
"TheTvdb": "TheTVDB", "TheTvdb": "TheTVDB",
"ToggleMonitoredSeriesUnmonitored ": "Nem lehet átkapcsolni a figyelt állapotot, ha a sorozat nem figyelhető", "ToggleMonitoredSeriesUnmonitored": "Nem lehet átkapcsolni a figyelt állapotot, ha a sorozat nem figyelhető",
"Ui": "Felület", "Ui": "Felület",
"UiSettingsLoadError": "Nem sikerült betölteni a felhasználói felület beállításait", "UiSettingsLoadError": "Nem sikerült betölteni a felhasználói felület beállításait",
"SelectDropdown": "Válassz...", "SelectDropdown": "Válassz...",

@ -1288,7 +1288,7 @@
"TablePageSizeMaximum": "O tamanho da página não pode exceder {maximumValue}", "TablePageSizeMaximum": "O tamanho da página não pode exceder {maximumValue}",
"Tba": "A ser anunciado", "Tba": "A ser anunciado",
"Titles": "Título", "Titles": "Título",
"ToggleMonitoredSeriesUnmonitored ": "Não é possível alternar o estado monitorado quando a série não é monitorada", "ToggleMonitoredSeriesUnmonitored": "Não é possível alternar o estado monitorado quando a série não é monitorada",
"ToggleMonitoredToUnmonitored": "Monitorado, clique para cancelar o monitoramento", "ToggleMonitoredToUnmonitored": "Monitorado, clique para cancelar o monitoramento",
"ToggleUnmonitoredToMonitored": "Não monitorado, clique para monitorar", "ToggleUnmonitoredToMonitored": "Não monitorado, clique para monitorar",
"TotalRecords": "Total de registros: {totalRecords}", "TotalRecords": "Total de registros: {totalRecords}",

@ -1668,7 +1668,7 @@
"StopSelecting": "Прекратить выбор", "StopSelecting": "Прекратить выбор",
"Status": "Статус", "Status": "Статус",
"SupportedCustomConditions": "{appName} поддерживает настраиваемые условия в соответствии со свойствами релиза, указанными ниже.", "SupportedCustomConditions": "{appName} поддерживает настраиваемые условия в соответствии со свойствами релиза, указанными ниже.",
"ToggleMonitoredSeriesUnmonitored ": "Невозможно переключить отслеживаемое состояние, если сериал не отслеживается", "ToggleMonitoredSeriesUnmonitored": "Невозможно переключить отслеживаемое состояние, если сериал не отслеживается",
"Failed": "Неудачно", "Failed": "Неудачно",
"FilterSeriesPlaceholder": "Фильтр сериалов", "FilterSeriesPlaceholder": "Фильтр сериалов",
"OrganizeSelectedSeriesModalAlert": "Совет: Чтобы просмотреть переименование, выберите «Отмена», затем выберите любой заголовок эпизода и используйте этот значок:", "OrganizeSelectedSeriesModalAlert": "Совет: Чтобы просмотреть переименование, выберите «Отмена», затем выберите любой заголовок эпизода и используйте этот значок:",

@ -1165,7 +1165,7 @@
"Test": "测试", "Test": "测试",
"ThemeHelpText": "改变应用界面主题选择“自动”主题会通过操作系统主题来自适应白天黑夜模式。受Theme.Park启发", "ThemeHelpText": "改变应用界面主题选择“自动”主题会通过操作系统主题来自适应白天黑夜模式。受Theme.Park启发",
"TimeFormat": "时间格式", "TimeFormat": "时间格式",
"ToggleMonitoredSeriesUnmonitored ": "当系列不受监控时,无法切换监控状态", "ToggleMonitoredSeriesUnmonitored": "当系列不受监控时,无法切换监控状态",
"ToggleMonitoredToUnmonitored": "已监视,单击可取消监视", "ToggleMonitoredToUnmonitored": "已监视,单击可取消监视",
"Total": "全部的", "Total": "全部的",
"TorrentsDisabled": "Torrents关闭", "TorrentsDisabled": "Torrents关闭",

Loading…
Cancel
Save