mirror of
https://github.com/Radarr/Radarr
synced 2026-01-26 01:12:11 +01:00
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
This commit is contained in:
parent
dc29526961
commit
d99a7e9b8a
70 changed files with 1280 additions and 1618 deletions
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
29
frontend/src/App/State/PathsAppState.ts
Normal file
29
frontend/src/App/State/PathsAppState.ts
Normal file
|
|
@ -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;
|
||||
18
frontend/src/Components/Alert.tsx
Normal file
18
frontend/src/Components/Alert.tsx
Normal file
|
|
@ -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;
|
||||
39
frontend/src/Components/Card.tsx
Normal file
39
frontend/src/Components/Card.tsx
Normal file
|
|
@ -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;
|
||||
15
frontend/src/Components/DescriptionList/DescriptionList.tsx
Normal file
15
frontend/src/Components/DescriptionList/DescriptionList.tsx
Normal file
|
|
@ -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;
|
||||
21
frontend/src/Components/DragPreviewLayer.tsx
Normal file
21
frontend/src/Components/DragPreviewLayer.tsx
Normal file
|
|
@ -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;
|
||||
46
frontend/src/Components/Error/ErrorBoundary.tsx
Normal file
46
frontend/src/Components/Error/ErrorBoundary.tsx
Normal file
|
|
@ -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;
|
||||
29
frontend/src/Components/FieldSet.tsx
Normal file
29
frontend/src/Components/FieldSet.tsx
Normal file
|
|
@ -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;
|
||||
23
frontend/src/Components/FileBrowser/FileBrowserModal.tsx
Normal file
23
frontend/src/Components/FileBrowser/FileBrowserModal.tsx
Normal file
|
|
@ -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;
|
||||
237
frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx
Normal file
237
frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx
Normal file
|
|
@ -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;
|
||||
49
frontend/src/Components/FileBrowser/FileBrowserRow.tsx
Normal file
49
frontend/src/Components/FileBrowser/FileBrowserRow.tsx
Normal file
|
|
@ -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;
|
||||
36
frontend/src/Components/FileBrowser/createPathsSelector.ts
Normal file
36
frontend/src/Components/FileBrowser/createPathsSelector.ts
Normal file
|
|
@ -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;
|
||||
36
frontend/src/Components/Loading/LoadingIndicator.tsx
Normal file
36
frontend/src/Components/Loading/LoadingIndicator.tsx
Normal file
|
|
@ -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;
|
||||
75
frontend/src/Components/Markdown/InlineMarkdown.tsx
Normal file
75
frontend/src/Components/Markdown/InlineMarkdown.tsx
Normal file
|
|
@ -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;
|
||||
65
frontend/src/Components/MonitorToggleButton.tsx
Normal file
65
frontend/src/Components/MonitorToggleButton.tsx
Normal file
|
|
@ -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;
|
||||
20
frontend/src/Components/Portal.tsx
Normal file
20
frontend/src/Components/Portal.tsx
Normal file
|
|
@ -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;
|
||||
38
frontend/src/Components/Router/Switch.tsx
Normal file
38
frontend/src/Components/Router/Switch.tsx
Normal file
|
|
@ -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;
|
||||
127
frontend/src/Components/Scroller/OverlayScroller.tsx
Normal file
127
frontend/src/Components/Scroller/OverlayScroller.tsx
Normal file
|
|
@ -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;
|
||||
26
frontend/src/Components/Tooltip/Popover.tsx
Normal file
26
frontend/src/Components/Tooltip/Popover.tsx
Normal file
|
|
@ -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;
|
||||
226
frontend/src/Components/Tooltip/Tooltip.tsx
Normal file
226
frontend/src/Components/Tooltip/Tooltip.tsx
Normal file
|
|
@ -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…
Reference in a new issue