mirror of
https://github.com/zoriya/flood.git
synced 2025-12-05 23:06:20 +00:00
client: fully migrate to TypeScript
This commit is contained in:
@@ -5,6 +5,7 @@ module.exports = {
|
||||
'plugin:import/warnings',
|
||||
'plugin:import/typescript',
|
||||
'prettier',
|
||||
'prettier/react',
|
||||
'prettier/@typescript-eslint',
|
||||
],
|
||||
parser: 'babel-eslint',
|
||||
@@ -63,6 +64,7 @@ module.exports = {
|
||||
'airbnb-typescript',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier',
|
||||
'prettier/react',
|
||||
'prettier/@typescript-eslint',
|
||||
],
|
||||
parserOptions: {
|
||||
|
||||
@@ -25,7 +25,6 @@ module.exports = {
|
||||
patterns: ['**/server/**/*'],
|
||||
},
|
||||
],
|
||||
camelcase: 0,
|
||||
// TODO: Enable a11y features
|
||||
'jsx-a11y/click-events-have-key-events': 0,
|
||||
'jsx-a11y/control-has-associated-label': 0,
|
||||
@@ -35,30 +34,9 @@ module.exports = {
|
||||
'jsx-a11y/no-noninteractive-element-interactions': 0,
|
||||
'jsx-a11y/no-static-element-interactions': 0,
|
||||
'no-console': [2, {allow: ['warn', 'error']}],
|
||||
'react/button-has-type': 0,
|
||||
'react/default-props-match-prop-types': 0,
|
||||
'react/destructuring-assignment': 0,
|
||||
'react/forbid-prop-types': 0,
|
||||
'react/jsx-closing-bracket-location': 0,
|
||||
'react/jsx-filename-extension': [1, {extensions: ['.js', '.tsx']}],
|
||||
'react/jsx-one-expression-per-line': 0,
|
||||
'react/jsx-wrap-multilines': 0,
|
||||
'react/jsx-props-no-spreading': 0,
|
||||
'react/no-unescaped-entities': ['error', {forbid: ['>', '}']}],
|
||||
'react/no-unused-prop-types': 0,
|
||||
'react/prefer-stateless-function': 0,
|
||||
'react/prop-types': 0,
|
||||
'react/require-default-props': 0,
|
||||
'react/sort-comp': [
|
||||
2,
|
||||
{
|
||||
order: ['static-methods', 'instance-variables', 'lifecycle', 'everything-else', 'rendering'],
|
||||
groups: {
|
||||
rendering: ['/^render.+$/', 'render'],
|
||||
},
|
||||
},
|
||||
],
|
||||
'react/static-property-placement': [1, 'static public field'],
|
||||
'react/static-property-placement': [2, 'static public field'],
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
|
||||
@@ -10,12 +10,12 @@ import type {ContextMenu, Modal} from '../stores/UIStore';
|
||||
|
||||
export interface UIClickTorrentAction {
|
||||
type: 'UI_CLICK_TORRENT';
|
||||
data: {event: React.MouseEvent<HTMLLIElement>; hash: string};
|
||||
data: {event: React.MouseEvent; hash: string};
|
||||
}
|
||||
|
||||
export interface UIClickTorrentDetailsAction {
|
||||
type: 'UI_CLICK_TORRENT_DETAILS';
|
||||
data: {event: React.MouseEvent<HTMLLIElement>; hash: string};
|
||||
data: {event: React.MouseEvent; hash: string};
|
||||
}
|
||||
|
||||
export interface UIDismissContextMenuAction {
|
||||
|
||||
@@ -9,11 +9,12 @@ import connectStores from './util/connectStores';
|
||||
import AppWrapper from './components/AppWrapper';
|
||||
import AuthActions from './actions/AuthActions';
|
||||
import history from './util/history';
|
||||
import Languages from './constants/Languages';
|
||||
import LoadingIndicator from './components/general/LoadingIndicator';
|
||||
import SettingsStore from './stores/SettingsStore';
|
||||
import UIStore from './stores/UIStore';
|
||||
|
||||
import type {Language} from './constants/Languages';
|
||||
|
||||
import '../sass/style.scss';
|
||||
|
||||
const Login = React.lazy(() => import('./components/views/Login'));
|
||||
@@ -21,7 +22,7 @@ const Register = React.lazy(() => import('./components/views/Register'));
|
||||
const TorrentClientOverview = React.lazy(() => import('./components/views/TorrentClientOverview'));
|
||||
|
||||
interface FloodAppProps {
|
||||
locale?: keyof typeof Languages;
|
||||
locale?: Language;
|
||||
}
|
||||
|
||||
const loadingOverlay = (
|
||||
@@ -95,9 +96,11 @@ class FloodApp extends React.Component<FloodAppProps> {
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const {locale} = this.props;
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={loadingOverlay}>
|
||||
<AsyncIntlProvider locale={this.props.locale}>{appRoutes}</AsyncIntlProvider>
|
||||
<AsyncIntlProvider locale={locale}>{appRoutes}</AsyncIntlProvider>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,11 +101,13 @@ class AuthEnforcer extends React.Component<AuthEnforcerProps> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {children} = this.props;
|
||||
|
||||
return (
|
||||
<div className="application">
|
||||
<WindowTitle />
|
||||
<TransitionGroup>{this.renderOverlay()}</TransitionGroup>
|
||||
{this.props.children}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,40 +7,42 @@ import CircleExclamationIcon from '../icons/CircleExclamationIcon';
|
||||
|
||||
interface AlertProps {
|
||||
id: string;
|
||||
count: number;
|
||||
type: 'success' | 'error';
|
||||
count?: number;
|
||||
type?: 'success' | 'error';
|
||||
}
|
||||
|
||||
export default class Alert extends React.Component<AlertProps> {
|
||||
static defaultProps = {
|
||||
count: 0,
|
||||
type: 'success',
|
||||
};
|
||||
const Alert: React.FC<AlertProps> = (props: AlertProps) => {
|
||||
const {id, count, type} = props;
|
||||
|
||||
render() {
|
||||
let icon = <CircleCheckmarkIcon />;
|
||||
const alertClasses = classnames('alert', {
|
||||
'is-success': this.props.type === 'success',
|
||||
'is-error': this.props.type === 'error',
|
||||
});
|
||||
const alertClasses = classnames('alert', {
|
||||
'is-success': type === 'success',
|
||||
'is-error': type === 'error',
|
||||
});
|
||||
|
||||
if (this.props.type === 'error') {
|
||||
icon = <CircleExclamationIcon />;
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={alertClasses}>
|
||||
{icon}
|
||||
<span className="alert__content">
|
||||
<FormattedMessage
|
||||
id={this.props.id}
|
||||
values={{
|
||||
count: this.props.count,
|
||||
countElement: <span className="alert__count">{this.props.count}</span>,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
let icon = <CircleCheckmarkIcon />;
|
||||
if (type === 'error') {
|
||||
icon = <CircleExclamationIcon />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={alertClasses}>
|
||||
{icon}
|
||||
<span className="alert__content">
|
||||
<FormattedMessage
|
||||
id={id}
|
||||
values={{
|
||||
count,
|
||||
countElement: <span className="alert__count">{count}</span>,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
Alert.defaultProps = {
|
||||
count: 0,
|
||||
type: 'success',
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
|
||||
@@ -38,25 +38,29 @@ class AuthForm extends React.Component<AuthFormProps, AuthFormStates> {
|
||||
}
|
||||
|
||||
getHeaderText() {
|
||||
if (this.props.mode === 'login') {
|
||||
return this.props.intl.formatMessage({
|
||||
const {mode, intl} = this.props;
|
||||
|
||||
if (mode === 'login') {
|
||||
return intl.formatMessage({
|
||||
id: 'auth.login',
|
||||
});
|
||||
}
|
||||
|
||||
return this.props.intl.formatMessage({
|
||||
return intl.formatMessage({
|
||||
id: 'auth.create.an.account',
|
||||
});
|
||||
}
|
||||
|
||||
getIntroText() {
|
||||
if (this.props.mode === 'login') {
|
||||
return this.props.intl.formatMessage({
|
||||
const {mode, intl} = this.props;
|
||||
|
||||
if (mode === 'login') {
|
||||
return intl.formatMessage({
|
||||
id: 'auth.login.intro',
|
||||
});
|
||||
}
|
||||
|
||||
return this.props.intl.formatMessage({
|
||||
return intl.formatMessage({
|
||||
id: 'auth.create.an.account.intro',
|
||||
});
|
||||
}
|
||||
@@ -176,7 +180,7 @@ class AuthForm extends React.Component<AuthFormProps, AuthFormStates> {
|
||||
}}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button isLoading={this.state.isSubmitting} type="submit">
|
||||
<Button isLoading={isSubmitting} type="submit">
|
||||
{isLoginMode
|
||||
? intl.formatMessage({
|
||||
id: 'auth.log.in',
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
export default class Badge extends React.Component {
|
||||
render() {
|
||||
return <div className="badge">{this.props.children}</div>;
|
||||
}
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Badge: React.FC<BadgeProps> = ({children}: BadgeProps) => {
|
||||
return <div className="badge">{children}</div>;
|
||||
};
|
||||
|
||||
export default Badge;
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import React from 'react';
|
||||
import {Scrollbars} from 'react-custom-scrollbars';
|
||||
|
||||
const METHODS_TO_BIND = ['getHorizontalThumb', 'getVerticalThumb'];
|
||||
|
||||
export default class CustomScrollbar extends React.Component {
|
||||
scrollbarRef = null;
|
||||
|
||||
static defaultProps = {
|
||||
className: '',
|
||||
inverted: false,
|
||||
nativeScrollHandler: null,
|
||||
scrollHandler: null,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
METHODS_TO_BIND.forEach((method) => {
|
||||
this[method] = this[method].bind(this);
|
||||
});
|
||||
}
|
||||
|
||||
getHorizontalThumb(props) {
|
||||
if (this.props.getHorizontalThumb) {
|
||||
return this.props.getHorizontalThumb(props, this.props.onThumbMouseUp);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className="scrollbars__thumb scrollbars__thumb--horizontal"
|
||||
onMouseUp={this.props.onThumbMouseUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
getVerticalThumb(props) {
|
||||
if (this.props.getVerticalThumb) {
|
||||
return this.props.getVerticalThumb(props, this.props.onThumbMouseUp);
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...props} className="scrollbars__thumb scrollbars__thumb--vertical" onMouseUp={this.props.onThumbMouseUp} />
|
||||
);
|
||||
}
|
||||
|
||||
renderView(props) {
|
||||
return (
|
||||
<div {...props} className="scrollbars__view">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
inverted,
|
||||
nativeScrollHandler,
|
||||
scrollHandler,
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
getHorizontalThumb: _getHorizontalThumb,
|
||||
getVerticalThumb: _getVerticalThumb,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
...otherProps
|
||||
} = this.props;
|
||||
const classes = classnames('scrollbars', className, {
|
||||
'is-inverted': inverted,
|
||||
});
|
||||
|
||||
return (
|
||||
<Scrollbars
|
||||
className={classes}
|
||||
ref={(ref) => {
|
||||
this.scrollbarRef = ref;
|
||||
}}
|
||||
renderView={this.renderView}
|
||||
renderThumbHorizontal={this.getHorizontalThumb}
|
||||
renderThumbVertical={this.getVerticalThumb}
|
||||
onScroll={nativeScrollHandler}
|
||||
onScrollFrame={scrollHandler}
|
||||
{...otherProps}>
|
||||
{children}
|
||||
</Scrollbars>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import classnames from 'classnames';
|
||||
import React from 'react';
|
||||
import {positionValues, Scrollbars} from 'react-custom-scrollbars';
|
||||
|
||||
const horizontalThumb: React.StatelessComponent = (props) => {
|
||||
return <div {...props} className="scrollbars__thumb scrollbars__thumb--horizontal" />;
|
||||
};
|
||||
|
||||
const verticalThumb: React.StatelessComponent = (props) => {
|
||||
return <div {...props} className="scrollbars__thumb scrollbars__thumb--vertical" />;
|
||||
};
|
||||
|
||||
const renderView: React.StatelessComponent = (props) => {
|
||||
return (
|
||||
<div {...props} className="scrollbars__view">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomScrollbarProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
autoHeight?: boolean;
|
||||
autoHeightMin?: number | string;
|
||||
autoHeightMax?: number | string;
|
||||
inverted?: boolean;
|
||||
getHorizontalThumb?: React.StatelessComponent;
|
||||
getVerticalThumb?: React.StatelessComponent;
|
||||
nativeScrollHandler?: (event: React.UIEvent) => void;
|
||||
scrollHandler?: (values: positionValues) => void;
|
||||
onScrollStart?: () => void;
|
||||
onScrollStop?: () => void;
|
||||
}
|
||||
|
||||
const CustomScrollbar = React.forwardRef<Scrollbars, CustomScrollbarProps>((props: CustomScrollbarProps, ref) => {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
autoHeight,
|
||||
autoHeightMin,
|
||||
autoHeightMax,
|
||||
inverted,
|
||||
getHorizontalThumb,
|
||||
getVerticalThumb,
|
||||
nativeScrollHandler,
|
||||
scrollHandler,
|
||||
onScrollStart,
|
||||
onScrollStop,
|
||||
} = props;
|
||||
const classes = classnames('scrollbars', className, {
|
||||
'is-inverted': inverted,
|
||||
});
|
||||
|
||||
return (
|
||||
<Scrollbars
|
||||
className={classes}
|
||||
style={style}
|
||||
autoHeight={autoHeight}
|
||||
autoHeightMin={autoHeightMin}
|
||||
autoHeightMax={autoHeightMax}
|
||||
ref={ref}
|
||||
renderView={renderView}
|
||||
renderThumbHorizontal={getHorizontalThumb}
|
||||
renderThumbVertical={getVerticalThumb}
|
||||
onScroll={nativeScrollHandler}
|
||||
onScrollFrame={scrollHandler}
|
||||
onScrollStart={onScrollStart}
|
||||
onScrollStop={onScrollStop}>
|
||||
{children}
|
||||
</Scrollbars>
|
||||
);
|
||||
});
|
||||
|
||||
CustomScrollbar.defaultProps = {
|
||||
className: '',
|
||||
style: undefined,
|
||||
autoHeight: undefined,
|
||||
autoHeightMin: undefined,
|
||||
autoHeightMax: undefined,
|
||||
inverted: undefined,
|
||||
getHorizontalThumb: horizontalThumb,
|
||||
getVerticalThumb: verticalThumb,
|
||||
nativeScrollHandler: undefined,
|
||||
scrollHandler: undefined,
|
||||
onScrollStart: undefined,
|
||||
onScrollStop: undefined,
|
||||
};
|
||||
|
||||
export default CustomScrollbar;
|
||||
@@ -8,114 +8,118 @@ interface DurationProps {
|
||||
value: -1 | DurationType;
|
||||
}
|
||||
|
||||
export default class Duration extends React.Component<DurationProps> {
|
||||
render() {
|
||||
let {suffix = null} = this.props;
|
||||
const {value: duration} = this.props;
|
||||
const Duration: React.FC<DurationProps> = (props: DurationProps) => {
|
||||
const {suffix, value: duration} = props;
|
||||
|
||||
if (duration == null) {
|
||||
return null;
|
||||
}
|
||||
if (duration == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let content = null;
|
||||
let content = null;
|
||||
let suffixElement = null;
|
||||
|
||||
if (suffix) {
|
||||
suffix = <span className="duration--segment">{suffix}</span>;
|
||||
}
|
||||
if (suffix) {
|
||||
suffixElement = <span className="duration--segment">{suffix}</span>;
|
||||
}
|
||||
|
||||
if (duration === -1) {
|
||||
content = <FormattedMessage id="unit.time.infinity" />;
|
||||
} else if (duration.years != null && duration.years > 0) {
|
||||
content = [
|
||||
<span className="duration--segment" key="years">
|
||||
{duration.years}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.year" />
|
||||
</em>
|
||||
</span>,
|
||||
<span className="duration--segment" key="weeks">
|
||||
{duration.weeks}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.week" />
|
||||
</em>
|
||||
</span>,
|
||||
];
|
||||
} else if (duration.weeks != null && duration.weeks > 0) {
|
||||
content = [
|
||||
<span className="duration--segment" key="weeks">
|
||||
{duration.weeks}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.week" />
|
||||
</em>
|
||||
</span>,
|
||||
<span className="duration--segment" key="days">
|
||||
{duration.days}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.day" />
|
||||
</em>
|
||||
</span>,
|
||||
];
|
||||
} else if (duration.days != null && duration.days > 0) {
|
||||
content = [
|
||||
<span className="duration--segment" key="days">
|
||||
{duration.days}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.day" />
|
||||
</em>
|
||||
</span>,
|
||||
<span className="duration--segment" key="hours">
|
||||
{duration.hours}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.hour" />
|
||||
</em>
|
||||
</span>,
|
||||
];
|
||||
} else if (duration.hours != null && duration.hours > 0) {
|
||||
content = [
|
||||
<span className="duration--segment" key="hours">
|
||||
{duration.hours}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.hour" />
|
||||
</em>
|
||||
</span>,
|
||||
<span className="duration--segment" key="minutes">
|
||||
{duration.minutes}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.minute" />
|
||||
</em>
|
||||
</span>,
|
||||
];
|
||||
} else if (duration.minutes != null && duration.minutes > 0) {
|
||||
content = [
|
||||
<span className="duration--segment" key="minutes">
|
||||
{duration.minutes}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.minute" />
|
||||
</em>
|
||||
</span>,
|
||||
<span className="duration--segment" key="seconds">
|
||||
{duration.seconds}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.second" />
|
||||
</em>
|
||||
</span>,
|
||||
];
|
||||
} else {
|
||||
content = (
|
||||
<span className="duration--segment">
|
||||
{duration.seconds}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.second" />
|
||||
</em>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="duration">
|
||||
{content}
|
||||
{suffix}
|
||||
if (duration === -1) {
|
||||
content = <FormattedMessage id="unit.time.infinity" />;
|
||||
} else if (duration.years != null && duration.years > 0) {
|
||||
content = [
|
||||
<span className="duration--segment" key="years">
|
||||
{duration.years}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.year" />
|
||||
</em>
|
||||
</span>,
|
||||
<span className="duration--segment" key="weeks">
|
||||
{duration.weeks}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.week" />
|
||||
</em>
|
||||
</span>,
|
||||
];
|
||||
} else if (duration.weeks != null && duration.weeks > 0) {
|
||||
content = [
|
||||
<span className="duration--segment" key="weeks">
|
||||
{duration.weeks}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.week" />
|
||||
</em>
|
||||
</span>,
|
||||
<span className="duration--segment" key="days">
|
||||
{duration.days}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.day" />
|
||||
</em>
|
||||
</span>,
|
||||
];
|
||||
} else if (duration.days != null && duration.days > 0) {
|
||||
content = [
|
||||
<span className="duration--segment" key="days">
|
||||
{duration.days}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.day" />
|
||||
</em>
|
||||
</span>,
|
||||
<span className="duration--segment" key="hours">
|
||||
{duration.hours}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.hour" />
|
||||
</em>
|
||||
</span>,
|
||||
];
|
||||
} else if (duration.hours != null && duration.hours > 0) {
|
||||
content = [
|
||||
<span className="duration--segment" key="hours">
|
||||
{duration.hours}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.hour" />
|
||||
</em>
|
||||
</span>,
|
||||
<span className="duration--segment" key="minutes">
|
||||
{duration.minutes}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.minute" />
|
||||
</em>
|
||||
</span>,
|
||||
];
|
||||
} else if (duration.minutes != null && duration.minutes > 0) {
|
||||
content = [
|
||||
<span className="duration--segment" key="minutes">
|
||||
{duration.minutes}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.minute" />
|
||||
</em>
|
||||
</span>,
|
||||
<span className="duration--segment" key="seconds">
|
||||
{duration.seconds}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.second" />
|
||||
</em>
|
||||
</span>,
|
||||
];
|
||||
} else {
|
||||
content = (
|
||||
<span className="duration--segment">
|
||||
{duration.seconds}
|
||||
<em className="unit">
|
||||
<FormattedMessage id="unit.time.second" />
|
||||
</em>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="duration">
|
||||
{content}
|
||||
{suffixElement}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
Duration.defaultProps = {
|
||||
suffix: undefined,
|
||||
};
|
||||
|
||||
export default Duration;
|
||||
|
||||
@@ -9,8 +9,8 @@ import type {ContextMenu as ContextMenuType, ContextMenuItem} from '../../stores
|
||||
|
||||
interface GlobalContextMenuMountPointProps {
|
||||
id: ContextMenuType['id'];
|
||||
onMenuOpen: () => void;
|
||||
onMenuClose: () => void;
|
||||
onMenuOpen?: () => void;
|
||||
onMenuClose?: () => void;
|
||||
}
|
||||
|
||||
interface GlobalContextMenuMountPointStates {
|
||||
@@ -40,38 +40,39 @@ class GlobalContextMenuMountPoint extends React.Component<
|
||||
}
|
||||
|
||||
shouldComponentUpdate(_nextProps: GlobalContextMenuMountPointProps, nextState: GlobalContextMenuMountPointStates) {
|
||||
if (!this.state.isOpen && !nextState.isOpen) {
|
||||
const {isOpen, clickPosition, items} = this.state;
|
||||
|
||||
if (!isOpen && !nextState.isOpen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.state.isOpen !== nextState.isOpen) {
|
||||
if (isOpen !== nextState.isOpen) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let shouldUpdate = true;
|
||||
|
||||
if (
|
||||
this.state.clickPosition.x === nextState.clickPosition.x &&
|
||||
this.state.clickPosition.y === nextState.clickPosition.y
|
||||
) {
|
||||
if (clickPosition.x === nextState.clickPosition.x && clickPosition.y === nextState.clickPosition.y) {
|
||||
shouldUpdate = false;
|
||||
}
|
||||
|
||||
if (!shouldUpdate) {
|
||||
return this.state.items.some((item, index) => item !== nextState.items[index]);
|
||||
return items.some((item, index) => item !== nextState.items[index]);
|
||||
}
|
||||
|
||||
return shouldUpdate;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: GlobalContextMenuMountPointProps, prevState: GlobalContextMenuMountPointStates) {
|
||||
if (!prevState.isOpen && this.state.isOpen) {
|
||||
const {isOpen} = this.state;
|
||||
|
||||
if (!prevState.isOpen && isOpen) {
|
||||
document.addEventListener('keydown', this.handleKeyPress);
|
||||
|
||||
if (prevProps.onMenuOpen) {
|
||||
prevProps.onMenuOpen();
|
||||
}
|
||||
} else if (prevState.isOpen && !this.state.isOpen) {
|
||||
} else if (prevState.isOpen && !isOpen) {
|
||||
document.removeEventListener('keydown', this.handleKeyPress);
|
||||
|
||||
if (prevProps.onMenuClose) {
|
||||
@@ -85,43 +86,45 @@ class GlobalContextMenuMountPoint extends React.Component<
|
||||
}
|
||||
|
||||
getMenuItems() {
|
||||
return this.state.items.map((item, index) => {
|
||||
let labelAction;
|
||||
let labelSecondary;
|
||||
const {items} = this.state;
|
||||
|
||||
return items.map((item, index) => {
|
||||
let menuItemContent;
|
||||
let menuItemClasses;
|
||||
|
||||
const menuItemClasses = classnames('menu__item', {
|
||||
'is-selectable': item.clickHandler,
|
||||
'menu__item--separator': item.type === 'separator',
|
||||
});
|
||||
const primaryLabelClasses = classnames('menu__item__label--primary', {
|
||||
'has-action': item.labelAction,
|
||||
});
|
||||
|
||||
if (item.labelSecondary) {
|
||||
labelSecondary = <span className="menu__item__label--secondary">{item.labelSecondary}</span>;
|
||||
}
|
||||
|
||||
if (item.labelAction) {
|
||||
labelAction = <span className="menu__item__label__action">{item.labelAction}</span>;
|
||||
}
|
||||
|
||||
if (item.type !== 'separator') {
|
||||
menuItemContent = (
|
||||
<span>
|
||||
<span className={primaryLabelClasses}>
|
||||
<span className="menu__item__label">{item.label}</span>
|
||||
{labelAction}
|
||||
switch (item.type) {
|
||||
case 'action':
|
||||
menuItemClasses = classnames('menu__item', {
|
||||
'is-selectable': item.clickHandler,
|
||||
});
|
||||
menuItemContent = (
|
||||
<span>
|
||||
<span
|
||||
className={classnames('menu__item__label--primary', {
|
||||
'has-action': item.labelAction,
|
||||
})}>
|
||||
<span className="menu__item__label">{item.label}</span>
|
||||
{item.labelAction ? <span className="menu__item__label__action">{item.labelAction}</span> : undefined}
|
||||
</span>
|
||||
{item.labelSecondary ? (
|
||||
<span className="menu__item__label--secondary">{item.labelSecondary}</span>
|
||||
) : undefined}
|
||||
</span>
|
||||
{labelSecondary}
|
||||
</span>
|
||||
);
|
||||
);
|
||||
break;
|
||||
case 'separator':
|
||||
default:
|
||||
menuItemClasses = classnames('menu__item', {
|
||||
'menu__item--separator': item.type === 'separator',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
// TODO: Find a better key for this
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<li className={menuItemClasses} key={index} onClick={this.handleMenuItemClick.bind(this, item)}>
|
||||
<li
|
||||
className={menuItemClasses}
|
||||
key={item.type === 'action' ? item.action : `sep-${index}`}
|
||||
onClick={this.handleMenuItemClick.bind(this, item)}>
|
||||
{menuItemContent}
|
||||
</li>
|
||||
);
|
||||
@@ -150,32 +153,35 @@ class GlobalContextMenuMountPoint extends React.Component<
|
||||
}
|
||||
};
|
||||
|
||||
handleOverlayClick = () => {
|
||||
UIActions.dismissContextMenu(this.props.id);
|
||||
};
|
||||
|
||||
handleMenuItemClick(item: ContextMenuItem, event: React.MouseEvent<HTMLLIElement>) {
|
||||
if (item.dismissMenu === false) {
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
}
|
||||
const {id} = this.props;
|
||||
|
||||
if (item.clickHandler) {
|
||||
item.clickHandler(item.action, event);
|
||||
}
|
||||
if (item.type !== 'separator') {
|
||||
if (item.dismissMenu === false) {
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
if (item.dismissMenu !== false) {
|
||||
UIActions.dismissContextMenu(this.props.id);
|
||||
if (item.clickHandler) {
|
||||
item.clickHandler(item.action, event);
|
||||
}
|
||||
|
||||
if (item.dismissMenu !== false) {
|
||||
UIActions.dismissContextMenu(id);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
handleOverlayClick = () => {
|
||||
UIActions.dismissContextMenu(this.props.id);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {clickPosition, isOpen} = this.state;
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
triggerCoordinates={this.state.clickPosition}
|
||||
onOverlayClick={this.handleOverlayClick}
|
||||
in={this.state.isOpen}>
|
||||
<ContextMenu triggerCoordinates={clickPosition} onOverlayClick={this.handleOverlayClick} isIn={isOpen}>
|
||||
{this.getMenuItems()}
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import throttle from 'lodash/throttle';
|
||||
|
||||
import CustomScrollbars from './CustomScrollbars';
|
||||
|
||||
const methodsToBind = [
|
||||
'getListPadding',
|
||||
'getViewportLimits',
|
||||
'handleScroll',
|
||||
'handleScrollStart',
|
||||
'handleScrollStop',
|
||||
'measureItemHeight',
|
||||
'scrollToTop',
|
||||
'setScrollPosition',
|
||||
'setViewportHeight',
|
||||
];
|
||||
|
||||
class ListViewport extends React.Component {
|
||||
static propTypes = {
|
||||
bottomSpacerClass: PropTypes.string,
|
||||
itemRenderer: PropTypes.func.isRequired,
|
||||
itemScrollOffset: PropTypes.number,
|
||||
listClass: PropTypes.string,
|
||||
listLength: PropTypes.number.isRequired,
|
||||
scrollContainerClass: PropTypes.string,
|
||||
topSpacerClass: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
bottomSpacerClass: 'list__spacer list__spacer--bottom',
|
||||
itemScrollOffset: 10,
|
||||
topSpacerClass: 'list__spacer list__spacer--top',
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.isScrolling = false;
|
||||
this.lastScrollTop = 0;
|
||||
this.nodeRefs = {};
|
||||
this.state = {
|
||||
itemHeight: null,
|
||||
listVerticalPadding: null,
|
||||
scrollTop: 0,
|
||||
viewportHeight: null,
|
||||
};
|
||||
|
||||
methodsToBind.forEach((method) => {
|
||||
this[method] = this[method].bind(this);
|
||||
});
|
||||
|
||||
this.setViewportHeight = debounce(this.setViewportHeight, 250);
|
||||
this.updateAfterScrolling = debounce(this.updateAfterScrolling, 500, {
|
||||
leading: true,
|
||||
trailing: true,
|
||||
});
|
||||
this.setScrollPosition = throttle(this.setScrollPosition, 100);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
global.addEventListener('resize', this.setViewportHeight);
|
||||
this.setViewportHeight();
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
const scrollDelta = Math.abs(this.state.scrollTop - nextState.scrollTop);
|
||||
|
||||
if (this.isScrolling && scrollDelta > 20) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const {nodeRefs, state} = this;
|
||||
|
||||
if (state.itemHeight == null && nodeRefs.topSpacer != null) {
|
||||
// TODO: Decouple this
|
||||
// eslint-disable-next-line react/no-did-update-set-state
|
||||
this.setState({
|
||||
itemHeight: nodeRefs.topSpacer.nextSibling.offsetHeight,
|
||||
});
|
||||
}
|
||||
|
||||
if (state.listVerticalPadding == null && nodeRefs.list != null) {
|
||||
const listStyle = global.getComputedStyle(nodeRefs.list);
|
||||
const paddingBottom = Number(listStyle['padding-bottom'].replace('px', ''));
|
||||
const paddingTop = Number(listStyle['padding-top'].replace('px', ''));
|
||||
|
||||
// TODO: Decouple this
|
||||
// eslint-disable-next-line react/no-did-update-set-state
|
||||
this.setState({
|
||||
listVerticalPadding: paddingBottom + paddingTop,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
global.removeEventListener('resize', this.setViewportHeight);
|
||||
}
|
||||
|
||||
getViewportLimits(scrollDelta) {
|
||||
if (this.state.itemHeight == null || this.state.itemHeight === 0) {
|
||||
return {
|
||||
minItemIndex: 0,
|
||||
maxItemIndex: Math.min(50, this.props.listLength),
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate the number of items that should be rendered based on the height
|
||||
// of the viewport. We offset this to render a few more outside of the
|
||||
// container's dimensions, which looks nicer when the user scrolls.
|
||||
const {itemScrollOffset} = this.props;
|
||||
const offsetBottom = scrollDelta > 0 ? itemScrollOffset * 2 : itemScrollOffset / 2;
|
||||
const offsetTop = scrollDelta < 0 ? itemScrollOffset * 2 : itemScrollOffset / 2;
|
||||
|
||||
const {itemHeight, listVerticalPadding, scrollTop} = this.state;
|
||||
let {viewportHeight} = this.state;
|
||||
|
||||
if (listVerticalPadding) {
|
||||
viewportHeight -= listVerticalPadding;
|
||||
}
|
||||
|
||||
// The number of elements in view is the height of the viewport divided
|
||||
// by the height of the elements.
|
||||
const elementsInView = Math.ceil(viewportHeight / itemHeight);
|
||||
|
||||
// The minimum item index to render is the number of items above the
|
||||
// viewport's current scroll position, minus the offset.
|
||||
const minItemIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - offsetTop);
|
||||
|
||||
// The maximum item index to render is the minimum item rendered, plus the
|
||||
// number of items in view, plus double the offset.
|
||||
const maxItemIndex = Math.min(this.props.listLength, minItemIndex + elementsInView + offsetBottom + offsetTop);
|
||||
|
||||
return {minItemIndex, maxItemIndex};
|
||||
}
|
||||
|
||||
handleScroll(scrollValues) {
|
||||
this.setScrollPosition(scrollValues);
|
||||
}
|
||||
|
||||
handleScrollStart() {
|
||||
this.isScrolling = true;
|
||||
}
|
||||
|
||||
handleScrollStop() {
|
||||
this.isScrolling = false;
|
||||
this.updateAfterScrolling();
|
||||
}
|
||||
|
||||
measureItemHeight() {
|
||||
this.lastScrollTop = 0;
|
||||
|
||||
this.setState(
|
||||
{
|
||||
scrollTop: 0,
|
||||
itemHeight: null,
|
||||
},
|
||||
() => {
|
||||
this.nodeRefs.outerScrollbar.scrollbarRef.scrollTop(0);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
getListPadding(minItemIndex, maxItemIndex, itemCount) {
|
||||
const {itemHeight} = this.state;
|
||||
|
||||
if (itemHeight == null) {
|
||||
return {bottom: 0, top: 0};
|
||||
}
|
||||
|
||||
// Calculate the number of pixels to pad the visible item list.
|
||||
// If the minimum item index is less than 0, then we're already at the top
|
||||
// of the list and don't need to render any padding.
|
||||
if (minItemIndex < 0) {
|
||||
minItemIndex = 0;
|
||||
}
|
||||
|
||||
// If the max item index is larger than the item count, then we're at the
|
||||
// bottom of the list and don't need to render any padding.
|
||||
if (maxItemIndex > itemCount) {
|
||||
maxItemIndex = itemCount;
|
||||
}
|
||||
|
||||
const bottom = (itemCount - maxItemIndex) * itemHeight;
|
||||
const top = minItemIndex * itemHeight;
|
||||
|
||||
return {bottom, top};
|
||||
}
|
||||
|
||||
scrollToTop() {
|
||||
if (this.state.scrollTop !== 0) {
|
||||
if (this.nodeRefs.outerScrollbar != null) {
|
||||
this.nodeRefs.outerScrollbar.scrollbarRef.scrollToTop();
|
||||
}
|
||||
|
||||
this.lastScrollTop = 0;
|
||||
this.setState({scrollTop: 0});
|
||||
}
|
||||
}
|
||||
|
||||
setScrollPosition(scrollValues) {
|
||||
this.lastScrollTop = this.state.scrollTop;
|
||||
this.setState({scrollTop: scrollValues.scrollTop});
|
||||
}
|
||||
|
||||
setViewportHeight() {
|
||||
const {nodeRefs} = this;
|
||||
|
||||
if (nodeRefs.outerScrollbar) {
|
||||
this.setState({
|
||||
viewportHeight: nodeRefs.outerScrollbar.scrollbarRef.getClientHeight(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateAfterScrolling() {
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {lastScrollTop, nodeRefs, props, state} = this;
|
||||
const {minItemIndex, maxItemIndex} = this.getViewportLimits(state.scrollTop - lastScrollTop);
|
||||
const listPadding = this.getListPadding(minItemIndex, maxItemIndex, props.listLength);
|
||||
const list = [];
|
||||
|
||||
// For loops are fast, and performance matters here.
|
||||
for (let index = minItemIndex; index < maxItemIndex; index++) {
|
||||
list.push(props.itemRenderer(index));
|
||||
}
|
||||
|
||||
const listContent = (
|
||||
<ul
|
||||
className={props.listClass}
|
||||
ref={(ref) => {
|
||||
nodeRefs.list = ref;
|
||||
}}>
|
||||
<li
|
||||
className={props.topSpacerClass}
|
||||
ref={(ref) => {
|
||||
nodeRefs.topSpacer = ref;
|
||||
}}
|
||||
style={{height: `${listPadding.top}px`}}
|
||||
/>
|
||||
{list}
|
||||
<li className={props.bottomSpacerClass} style={{height: `${listPadding.bottom}px`}} />
|
||||
</ul>
|
||||
);
|
||||
|
||||
const scrollbarStyle = {};
|
||||
|
||||
if (props.width != null) {
|
||||
scrollbarStyle.width = props.width;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomScrollbars
|
||||
className={props.scrollContainerClass}
|
||||
getVerticalThumb={props.getVerticalThumb}
|
||||
onScrollStart={this.handleScrollStart}
|
||||
onScrollStop={this.handleScrollStop}
|
||||
ref={(ref) => {
|
||||
this.nodeRefs.outerScrollbar = ref;
|
||||
}}
|
||||
scrollHandler={this.handleScroll}
|
||||
style={scrollbarStyle}>
|
||||
{props.children}
|
||||
{listContent}
|
||||
</CustomScrollbars>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ListViewport;
|
||||
282
client/src/javascript/components/general/ListViewport.tsx
Normal file
282
client/src/javascript/components/general/ListViewport.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
import React from 'react';
|
||||
import {positionValues, Scrollbars} from 'react-custom-scrollbars';
|
||||
import throttle from 'lodash/throttle';
|
||||
|
||||
import CustomScrollbars from './CustomScrollbars';
|
||||
|
||||
const METHODS_TO_BIND = [
|
||||
'handleScroll',
|
||||
'handleScrollStart',
|
||||
'handleScrollStop',
|
||||
'measureItemHeight',
|
||||
'scrollToTop',
|
||||
'setScrollPosition',
|
||||
'setViewportHeight',
|
||||
] as const;
|
||||
|
||||
interface ListViewportProps {
|
||||
itemRenderer: (index: number) => React.ReactNode;
|
||||
listClass: string;
|
||||
listLength: number;
|
||||
scrollContainerClass: string;
|
||||
topSpacerClass?: string;
|
||||
bottomSpacerClass?: string;
|
||||
itemScrollOffset?: number;
|
||||
getVerticalThumb?: React.StatelessComponent;
|
||||
}
|
||||
|
||||
interface ListViewportStates {
|
||||
itemHeight: number | null;
|
||||
listVerticalPadding: number | null;
|
||||
scrollTop: number;
|
||||
viewportHeight: number | null;
|
||||
}
|
||||
|
||||
class ListViewport extends React.Component<ListViewportProps, ListViewportStates> {
|
||||
scrollbarRef: Scrollbars | null = null;
|
||||
listRef: HTMLUListElement | null = null;
|
||||
topSpacerRef: HTMLLIElement | null = null;
|
||||
isScrolling = false;
|
||||
lastScrollTop = 0;
|
||||
|
||||
static defaultProps = {
|
||||
bottomSpacerClass: 'list__spacer list__spacer--bottom',
|
||||
itemScrollOffset: 10,
|
||||
topSpacerClass: 'list__spacer list__spacer--top',
|
||||
};
|
||||
|
||||
constructor(props: ListViewportProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
itemHeight: null,
|
||||
listVerticalPadding: null,
|
||||
scrollTop: 0,
|
||||
viewportHeight: null,
|
||||
};
|
||||
|
||||
METHODS_TO_BIND.forEach(<T extends typeof METHODS_TO_BIND[number]>(methodName: T) => {
|
||||
this[methodName] = this[methodName].bind(this);
|
||||
});
|
||||
|
||||
this.setViewportHeight = debounce(this.setViewportHeight, 250);
|
||||
this.updateAfterScrolling = debounce(this.updateAfterScrolling, 500, {
|
||||
leading: true,
|
||||
trailing: true,
|
||||
});
|
||||
this.setScrollPosition = throttle(this.setScrollPosition, 100);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
global.addEventListener('resize', this.setViewportHeight);
|
||||
this.setViewportHeight();
|
||||
}
|
||||
|
||||
shouldComponentUpdate(_nextProps: ListViewportProps, nextState: ListViewportStates) {
|
||||
const {scrollTop} = this.state;
|
||||
const scrollDelta = Math.abs(scrollTop - nextState.scrollTop);
|
||||
|
||||
if (this.isScrolling && scrollDelta > 20) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
global.removeEventListener('resize', this.setViewportHeight);
|
||||
}
|
||||
|
||||
getViewportLimits(scrollDelta: number) {
|
||||
const {itemScrollOffset, listLength} = this.props;
|
||||
const {itemHeight, listVerticalPadding, scrollTop, viewportHeight} = this.state;
|
||||
|
||||
if (
|
||||
itemHeight == null ||
|
||||
itemHeight <= 0 ||
|
||||
itemScrollOffset == null ||
|
||||
viewportHeight == null ||
|
||||
viewportHeight <= 0
|
||||
) {
|
||||
return {
|
||||
minItemIndex: 0,
|
||||
maxItemIndex: Math.min(50, listLength),
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate the number of items that should be rendered based on the height
|
||||
// of the viewport. We offset this to render a few more outside of the
|
||||
// container's dimensions, which looks nicer when the user scrolls.
|
||||
const offsetBottom = scrollDelta > 0 ? itemScrollOffset * 2 : itemScrollOffset / 2;
|
||||
const offsetTop = scrollDelta < 0 ? itemScrollOffset * 2 : itemScrollOffset / 2;
|
||||
|
||||
let viewportHeightPadded = viewportHeight;
|
||||
|
||||
if (listVerticalPadding) {
|
||||
viewportHeightPadded -= listVerticalPadding;
|
||||
}
|
||||
|
||||
// The number of elements in view is the height of the viewport divided
|
||||
// by the height of the elements.
|
||||
const elementsInView = Math.ceil(viewportHeightPadded / itemHeight);
|
||||
|
||||
// The minimum item index to render is the number of items above the
|
||||
// viewport's current scroll position, minus the offset.
|
||||
const minItemIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - offsetTop);
|
||||
|
||||
// The maximum item index to render is the minimum item rendered, plus the
|
||||
// number of items in view, plus double the offset.
|
||||
const maxItemIndex = Math.min(listLength, minItemIndex + elementsInView + offsetBottom + offsetTop);
|
||||
|
||||
return {minItemIndex, maxItemIndex};
|
||||
}
|
||||
|
||||
getListPadding(minItemIndex: number, maxItemIndex: number, itemCount: number) {
|
||||
const {itemHeight} = this.state;
|
||||
|
||||
if (itemHeight == null) {
|
||||
return {bottom: 0, top: 0};
|
||||
}
|
||||
|
||||
const bottom = (itemCount - maxItemIndex) * itemHeight;
|
||||
const top = minItemIndex * itemHeight;
|
||||
|
||||
return {bottom, top};
|
||||
}
|
||||
|
||||
setScrollPosition(scrollValues: positionValues) {
|
||||
const {scrollTop} = this.state;
|
||||
|
||||
this.lastScrollTop = scrollTop;
|
||||
this.setState({scrollTop: scrollValues.scrollTop});
|
||||
}
|
||||
|
||||
setViewportHeight() {
|
||||
if (this.scrollbarRef) {
|
||||
this.setState({
|
||||
viewportHeight: this.scrollbarRef.getClientHeight(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
scrollToTop() {
|
||||
const {scrollTop} = this.state;
|
||||
|
||||
if (scrollTop !== 0) {
|
||||
if (this.scrollbarRef != null) {
|
||||
this.scrollbarRef.scrollToTop();
|
||||
}
|
||||
|
||||
this.lastScrollTop = 0;
|
||||
this.setState({scrollTop: 0});
|
||||
}
|
||||
}
|
||||
|
||||
measureItemHeight() {
|
||||
this.lastScrollTop = 0;
|
||||
|
||||
this.setState(
|
||||
{
|
||||
scrollTop: 0,
|
||||
itemHeight: null,
|
||||
},
|
||||
() => {
|
||||
if (this.scrollbarRef != null) {
|
||||
this.scrollbarRef.scrollTop(0);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
handleScroll(scrollValues: positionValues) {
|
||||
this.setScrollPosition(scrollValues);
|
||||
}
|
||||
|
||||
handleScrollStart() {
|
||||
this.isScrolling = true;
|
||||
}
|
||||
|
||||
handleScrollStop() {
|
||||
this.isScrolling = false;
|
||||
this.updateAfterScrolling();
|
||||
}
|
||||
|
||||
updateAfterScrolling() {
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
children,
|
||||
listClass,
|
||||
topSpacerClass,
|
||||
bottomSpacerClass,
|
||||
scrollContainerClass,
|
||||
listLength,
|
||||
getVerticalThumb,
|
||||
itemRenderer,
|
||||
} = this.props;
|
||||
const {itemHeight, scrollTop, listVerticalPadding} = this.state;
|
||||
|
||||
const {minItemIndex, maxItemIndex} = this.getViewportLimits(scrollTop - this.lastScrollTop);
|
||||
const listPadding = this.getListPadding(minItemIndex, maxItemIndex, listLength);
|
||||
const list = [];
|
||||
|
||||
// For loops are fast, and performance matters here.
|
||||
for (let index = minItemIndex; index < maxItemIndex; index += 1) {
|
||||
list.push(itemRenderer(index));
|
||||
}
|
||||
|
||||
const listContent = (
|
||||
<ul
|
||||
className={listClass}
|
||||
ref={(ref) => {
|
||||
this.listRef = ref;
|
||||
|
||||
if (listVerticalPadding == null && this.listRef != null) {
|
||||
const listStyle = global.getComputedStyle(this.listRef);
|
||||
const paddingBottom = Number(listStyle.getPropertyValue('padding-bottom').replace('px', ''));
|
||||
const paddingTop = Number(listStyle.getPropertyValue('padding-top').replace('px', ''));
|
||||
|
||||
this.setState({
|
||||
listVerticalPadding: paddingBottom + paddingTop,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<li
|
||||
className={topSpacerClass}
|
||||
ref={(ref) => {
|
||||
this.topSpacerRef = ref;
|
||||
|
||||
if (itemHeight == null && this.topSpacerRef != null && this.topSpacerRef.nextSibling != null) {
|
||||
this.setState({
|
||||
itemHeight: (this.topSpacerRef.nextSibling as HTMLLIElement).offsetHeight,
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={{height: `${listPadding.top}px`}}
|
||||
/>
|
||||
{list}
|
||||
<li className={bottomSpacerClass} style={{height: `${listPadding.bottom}px`}} />
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomScrollbars
|
||||
className={scrollContainerClass}
|
||||
getVerticalThumb={getVerticalThumb}
|
||||
onScrollStart={this.handleScrollStart}
|
||||
onScrollStop={this.handleScrollStop}
|
||||
ref={(ref) => {
|
||||
this.scrollbarRef = ref;
|
||||
}}
|
||||
scrollHandler={this.handleScroll}>
|
||||
{children}
|
||||
{listContent}
|
||||
</CustomScrollbars>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ListViewport;
|
||||
@@ -5,18 +5,24 @@ interface LoadingIndicatorProps {
|
||||
inverse?: boolean;
|
||||
}
|
||||
|
||||
export default class LoadingIndicator extends React.Component<LoadingIndicatorProps> {
|
||||
render() {
|
||||
const classes = classnames('loading-indicator', {
|
||||
'is-inverse': this.props.inverse,
|
||||
});
|
||||
const LoadingIndicator: React.FC<LoadingIndicatorProps> = (props: LoadingIndicatorProps) => {
|
||||
const {inverse} = props;
|
||||
|
||||
return (
|
||||
<div className={classes} key="loading-indicator">
|
||||
<div className="loading-indicator__bar loading-indicator__bar--1" />
|
||||
<div className="loading-indicator__bar loading-indicator__bar--2" />
|
||||
<div className="loading-indicator__bar loading-indicator__bar--3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
const classes = classnames('loading-indicator', {
|
||||
'is-inverse': inverse,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classes} key="loading-indicator">
|
||||
<div className="loading-indicator__bar loading-indicator__bar--1" />
|
||||
<div className="loading-indicator__bar loading-indicator__bar--2" />
|
||||
<div className="loading-indicator__bar loading-indicator__bar--3" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LoadingIndicator.defaultProps = {
|
||||
inverse: true,
|
||||
};
|
||||
|
||||
export default LoadingIndicator;
|
||||
|
||||
@@ -7,16 +7,17 @@ interface ProgressBarProps {
|
||||
|
||||
export default class ProgressBar extends React.PureComponent<ProgressBarProps> {
|
||||
render() {
|
||||
const percent = Math.round(this.props.percent);
|
||||
const {percent, icon} = this.props;
|
||||
const roundedPercent = Math.round(percent);
|
||||
const style: React.CSSProperties = {};
|
||||
|
||||
if (percent !== 100) {
|
||||
style.width = `${percent}%`;
|
||||
if (roundedPercent !== 100) {
|
||||
style.width = `${roundedPercent}%`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="progress-bar">
|
||||
<div className="progress-bar__icon">{this.props.icon}</div>
|
||||
<div className="progress-bar__icon">{icon}</div>
|
||||
<div className="progress-bar__fill__wrapper">
|
||||
<div className="progress-bar__fill" style={style} />
|
||||
</div>
|
||||
|
||||
@@ -5,21 +5,21 @@ interface RatioProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export default class Ratio extends React.Component<RatioProps> {
|
||||
render() {
|
||||
let ratio = this.props.value;
|
||||
const Ratio: React.FC<RatioProps> = (props: RatioProps) => {
|
||||
let {value: ratio} = props;
|
||||
|
||||
ratio /= 1000;
|
||||
let precision = 1;
|
||||
ratio /= 1000;
|
||||
let precision = 1;
|
||||
|
||||
if (ratio < 10) {
|
||||
precision = 2;
|
||||
} else if (ratio >= 100) {
|
||||
precision = 0;
|
||||
}
|
||||
|
||||
ratio = Number(ratio.toFixed(precision));
|
||||
|
||||
return <FormattedNumber value={ratio} />;
|
||||
if (ratio < 10) {
|
||||
precision = 2;
|
||||
} else if (ratio >= 100) {
|
||||
precision = 0;
|
||||
}
|
||||
}
|
||||
|
||||
ratio = Number(ratio.toFixed(precision));
|
||||
|
||||
return <FormattedNumber value={ratio} />;
|
||||
};
|
||||
|
||||
export default Ratio;
|
||||
|
||||
@@ -1,53 +1,52 @@
|
||||
import {FormattedNumber, injectIntl, WrappedComponentProps} from 'react-intl';
|
||||
import {FormattedNumber, useIntl} from 'react-intl';
|
||||
import React from 'react';
|
||||
|
||||
import {compute, getTranslationString} from '../../util/size';
|
||||
|
||||
interface SizeProps extends WrappedComponentProps {
|
||||
const renderNumber = (computedNumber: ReturnType<typeof compute>) => {
|
||||
if (Number.isNaN(computedNumber.value)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return <FormattedNumber value={computedNumber.value} />;
|
||||
};
|
||||
|
||||
interface SizeProps {
|
||||
value: number;
|
||||
precision?: number;
|
||||
isSpeed?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
class Size extends React.Component<SizeProps> {
|
||||
static defaultProps = {
|
||||
isSpeed: false,
|
||||
precision: 2,
|
||||
};
|
||||
const Size: React.FC<SizeProps> = ({value, isSpeed, className, precision}: SizeProps) => {
|
||||
const computed = compute(value, precision);
|
||||
const intl = useIntl();
|
||||
|
||||
static renderNumber(computedNumber: ReturnType<typeof compute>) {
|
||||
if (Number.isNaN(computedNumber.value)) {
|
||||
return '—';
|
||||
}
|
||||
let translatedUnit = intl.formatMessage({id: getTranslationString(computed.unit)});
|
||||
|
||||
return <FormattedNumber value={computedNumber.value} />;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {value, isSpeed, className, precision, intl} = this.props;
|
||||
const computed = compute(value, precision);
|
||||
|
||||
let translatedUnit = intl.formatMessage({id: getTranslationString(computed.unit)});
|
||||
|
||||
if (isSpeed) {
|
||||
translatedUnit = intl.formatMessage(
|
||||
{
|
||||
id: 'unit.speed',
|
||||
},
|
||||
{
|
||||
baseUnit: translatedUnit,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{Size.renderNumber(computed)}
|
||||
<em className="unit">{translatedUnit}</em>
|
||||
</span>
|
||||
if (isSpeed) {
|
||||
translatedUnit = intl.formatMessage(
|
||||
{
|
||||
id: 'unit.speed',
|
||||
},
|
||||
{
|
||||
baseUnit: translatedUnit,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(Size);
|
||||
return (
|
||||
<span className={className}>
|
||||
{renderNumber(computed)}
|
||||
<em className="unit">{translatedUnit}</em>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
Size.defaultProps = {
|
||||
isSpeed: false,
|
||||
precision: 2,
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default Size;
|
||||
|
||||
@@ -48,34 +48,6 @@ class SortableList extends React.Component<SortableListProps, SortableListStates
|
||||
return {items: props.items};
|
||||
}
|
||||
|
||||
handleDrop() {
|
||||
if (this.props.onDrop) {
|
||||
this.props.onDrop(this.state.items);
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseDown(event: React.MouseEvent<HTMLUListElement>) {
|
||||
if (this.props.onMouseDown) {
|
||||
this.props.onMouseDown(event);
|
||||
}
|
||||
}
|
||||
|
||||
handleMove(dragIndex: number, hoverIndex: number) {
|
||||
const {items} = this.state;
|
||||
const draggedItem = items[dragIndex];
|
||||
|
||||
// Remove the item being dragged.
|
||||
items.splice(dragIndex, 1);
|
||||
// Add the item being dragged in its new position.
|
||||
items.splice(hoverIndex, 0, draggedItem);
|
||||
|
||||
this.setState({items});
|
||||
|
||||
if (this.props.onMove) {
|
||||
this.props.onMove(items);
|
||||
}
|
||||
}
|
||||
|
||||
getItemList() {
|
||||
const {
|
||||
handleDrop,
|
||||
@@ -104,8 +76,40 @@ class SortableList extends React.Component<SortableListProps, SortableListStates
|
||||
});
|
||||
}
|
||||
|
||||
handleDrop() {
|
||||
const {onDrop} = this.props;
|
||||
if (onDrop) {
|
||||
onDrop(this.state.items);
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseDown(event: React.MouseEvent<HTMLUListElement>) {
|
||||
const {onMouseDown} = this.props;
|
||||
if (onMouseDown) {
|
||||
onMouseDown(event);
|
||||
}
|
||||
}
|
||||
|
||||
handleMove(dragIndex: number, hoverIndex: number) {
|
||||
const {onMove} = this.props;
|
||||
const {items} = this.state;
|
||||
const draggedItem = items[dragIndex];
|
||||
|
||||
// Remove the item being dragged.
|
||||
items.splice(dragIndex, 1);
|
||||
// Add the item being dragged in its new position.
|
||||
items.splice(hoverIndex, 0, draggedItem);
|
||||
|
||||
this.setState({items});
|
||||
|
||||
if (onMove) {
|
||||
onMove(items);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const classes = classnames('sortable-list', this.props.className);
|
||||
const {className} = this.props;
|
||||
const classes = classnames('sortable-list', className);
|
||||
|
||||
return (
|
||||
<DndProvider options={HTML5toTouch}>
|
||||
|
||||
@@ -23,8 +23,9 @@ interface SortableListItemProps {
|
||||
|
||||
class SortableListItem extends React.Component<SortableListItemProps> {
|
||||
componentDidMount() {
|
||||
const {connectDragPreview} = this.props;
|
||||
// Replace the native drag preview with an empty image.
|
||||
this.props.connectDragPreview(getEmptyImage(), {
|
||||
connectDragPreview(getEmptyImage(), {
|
||||
captureDraggingState: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,67 +176,6 @@ class Tooltip extends React.Component<TooltipProps, TooltipStates> {
|
||||
this.removeScrollListener();
|
||||
}
|
||||
|
||||
handleMouseEnter(forceOpen?: boolean): void {
|
||||
const {props} = this;
|
||||
|
||||
if (props.suppress && !forceOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.anchor == null || props.position == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {anchor, position, coordinates} = this.getIdealLocation(props.anchor, props.position);
|
||||
|
||||
this.setState({
|
||||
anchor,
|
||||
isOpen: true,
|
||||
position,
|
||||
coordinates,
|
||||
wasTriggeredClose: false,
|
||||
});
|
||||
this.addScrollListener();
|
||||
|
||||
if (props.onOpen) {
|
||||
props.onOpen();
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseLeave(): void {
|
||||
this.dismissTooltip();
|
||||
|
||||
if (this.props.onMouseLeave) {
|
||||
this.props.onMouseLeave();
|
||||
}
|
||||
}
|
||||
|
||||
handleTooltipMouseEnter(): void {
|
||||
if (this.props.interactive && !this.state.wasTriggeredClose) {
|
||||
this.setState({isOpen: true});
|
||||
this.addScrollListener();
|
||||
}
|
||||
}
|
||||
|
||||
handleTooltipMouseLeave(): void {
|
||||
this.dismissTooltip();
|
||||
}
|
||||
|
||||
addScrollListener(): void {
|
||||
this.container.addEventListener('scroll', (_e) => this.dismissTooltip());
|
||||
}
|
||||
|
||||
dismissTooltip(forceClose?: boolean): void {
|
||||
if ((!this.props.stayOpen || forceClose) && this.state.isOpen) {
|
||||
this.setState({isOpen: false});
|
||||
this.removeScrollListener();
|
||||
|
||||
if (this.props.onClose) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCoordinates(
|
||||
position: Position,
|
||||
clearance: Clearance,
|
||||
@@ -327,8 +266,79 @@ class Tooltip extends React.Component<TooltipProps, TooltipStates> {
|
||||
};
|
||||
}
|
||||
|
||||
dismissTooltip(forceClose?: boolean): void {
|
||||
const {stayOpen, onClose} = this.props;
|
||||
const {isOpen} = this.state;
|
||||
|
||||
if ((!stayOpen || forceClose) && isOpen) {
|
||||
this.setState({isOpen: false});
|
||||
this.removeScrollListener();
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addScrollListener(): void {
|
||||
this.container.addEventListener('scroll', (_e) => this.dismissTooltip());
|
||||
}
|
||||
|
||||
handleTooltipMouseEnter(): void {
|
||||
const {interactive} = this.props;
|
||||
const {wasTriggeredClose} = this.state;
|
||||
|
||||
if (interactive && !wasTriggeredClose) {
|
||||
this.setState({isOpen: true});
|
||||
this.addScrollListener();
|
||||
}
|
||||
}
|
||||
|
||||
handleTooltipMouseLeave(): void {
|
||||
this.dismissTooltip();
|
||||
}
|
||||
|
||||
handleMouseEnter(forceOpen?: boolean): void {
|
||||
const {props} = this;
|
||||
|
||||
if (props.suppress && !forceOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.anchor == null || props.position == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {anchor, position, coordinates} = this.getIdealLocation(props.anchor, props.position);
|
||||
|
||||
this.setState({
|
||||
anchor,
|
||||
isOpen: true,
|
||||
position,
|
||||
coordinates,
|
||||
wasTriggeredClose: false,
|
||||
});
|
||||
this.addScrollListener();
|
||||
|
||||
if (props.onOpen) {
|
||||
props.onOpen();
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseLeave(): void {
|
||||
this.dismissTooltip();
|
||||
|
||||
const {onMouseLeave} = this.props;
|
||||
|
||||
if (onMouseLeave) {
|
||||
onMouseLeave();
|
||||
}
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.state.isOpen;
|
||||
const {isOpen} = this.state;
|
||||
|
||||
return isOpen;
|
||||
}
|
||||
|
||||
removeScrollListener(): void {
|
||||
@@ -347,40 +357,54 @@ class Tooltip extends React.Component<TooltipProps, TooltipStates> {
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
const {props, state} = this;
|
||||
const {
|
||||
anchor: defaultAnchor,
|
||||
position: defaultPosition,
|
||||
children,
|
||||
align,
|
||||
className,
|
||||
interactive,
|
||||
wrapText,
|
||||
width,
|
||||
maxWidth,
|
||||
wrapperClassName,
|
||||
contentClassName,
|
||||
content,
|
||||
onClick,
|
||||
} = this.props;
|
||||
const {anchor: stateAnchor, position: statePosition, coordinates, isOpen} = this.state;
|
||||
let tooltipStyle: React.CSSProperties = {};
|
||||
|
||||
const {align} = props;
|
||||
// Get the anchor and position from state if possible. If not, get it from
|
||||
// the props.
|
||||
const anchor = state.anchor || props.anchor;
|
||||
const position = state.position || props.position;
|
||||
const anchor = stateAnchor || defaultAnchor;
|
||||
const position = statePosition || defaultPosition;
|
||||
|
||||
const tooltipClasses = classnames(
|
||||
props.className,
|
||||
className,
|
||||
`tooltip--anchor--${anchor}`,
|
||||
`tooltip--position--${position}`,
|
||||
`tooltip--align--${align}`,
|
||||
{
|
||||
'is-interactive': props.interactive,
|
||||
'is-open': state.isOpen,
|
||||
'tooltip--no-wrap': !props.wrapText,
|
||||
'is-interactive': interactive,
|
||||
'is-open': isOpen,
|
||||
'tooltip--no-wrap': !wrapText,
|
||||
},
|
||||
);
|
||||
|
||||
if (state.coordinates) {
|
||||
if (coordinates) {
|
||||
tooltipStyle = {
|
||||
left: state.coordinates.left,
|
||||
top: state.coordinates.top,
|
||||
left: coordinates.left,
|
||||
top: coordinates.top,
|
||||
};
|
||||
}
|
||||
|
||||
if (props.width) {
|
||||
tooltipStyle.width = props.width;
|
||||
if (width) {
|
||||
tooltipStyle.width = width;
|
||||
}
|
||||
|
||||
if (props.maxWidth) {
|
||||
tooltipStyle.maxWidth = props.maxWidth;
|
||||
if (maxWidth) {
|
||||
tooltipStyle.maxWidth = maxWidth;
|
||||
}
|
||||
|
||||
const appElement = document.getElementById('app');
|
||||
@@ -391,14 +415,14 @@ class Tooltip extends React.Component<TooltipProps, TooltipStates> {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={props.wrapperClassName}
|
||||
onClick={this.props.onClick}
|
||||
className={wrapperClassName}
|
||||
onClick={onClick}
|
||||
onMouseEnter={(_e) => this.handleMouseEnter()}
|
||||
onMouseLeave={(_e) => this.handleMouseLeave()}
|
||||
ref={(ref) => {
|
||||
this.triggerNode = ref;
|
||||
}}>
|
||||
{props.children}
|
||||
{children}
|
||||
{ReactDOM.createPortal(
|
||||
<div
|
||||
className={tooltipClasses}
|
||||
@@ -408,7 +432,7 @@ class Tooltip extends React.Component<TooltipProps, TooltipStates> {
|
||||
style={tooltipStyle}
|
||||
onMouseEnter={this.handleTooltipMouseEnter}
|
||||
onMouseLeave={this.handleTooltipMouseLeave}>
|
||||
<div className={props.contentClassName}>{props.content}</div>
|
||||
<div className={contentClassName}>{content}</div>
|
||||
</div>,
|
||||
appElement,
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {injectIntl, WrappedComponentProps} from 'react-intl';
|
||||
import {useIntl} from 'react-intl';
|
||||
import React from 'react';
|
||||
|
||||
import type {TransferSummary} from '@shared/types/TransferData';
|
||||
@@ -7,12 +7,13 @@ import connectStores from '../../util/connectStores';
|
||||
import {compute, getTranslationString} from '../../util/size';
|
||||
import TransferDataStore from '../../stores/TransferDataStore';
|
||||
|
||||
interface WindowTitleProps extends WrappedComponentProps {
|
||||
interface WindowTitleProps {
|
||||
summary?: TransferSummary;
|
||||
}
|
||||
|
||||
const WindowTitleFunc = (props: WindowTitleProps) => {
|
||||
const {intl, summary} = props;
|
||||
const WindowTitleFunc: React.FC<WindowTitleProps> = (props: WindowTitleProps) => {
|
||||
const {summary} = props;
|
||||
const intl = useIntl();
|
||||
|
||||
React.useEffect(() => {
|
||||
let title = 'Flood';
|
||||
@@ -59,7 +60,7 @@ const WindowTitleFunc = (props: WindowTitleProps) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const WindowTitle = connectStores(injectIntl(WindowTitleFunc), () => {
|
||||
const WindowTitle = connectStores(WindowTitleFunc, () => {
|
||||
return [
|
||||
{
|
||||
store: TransferDataStore,
|
||||
|
||||
@@ -39,10 +39,13 @@ class ClientConnectionSettingsForm extends React.Component<WrappedComponentProps
|
||||
}
|
||||
|
||||
render() {
|
||||
const {intl} = this.props;
|
||||
const {client} = this.state;
|
||||
|
||||
let settingsForm: React.ReactNode = null;
|
||||
switch (this.state.client) {
|
||||
switch (client) {
|
||||
case 'rTorrent':
|
||||
settingsForm = <RTorrentConnectionSettingsForm intl={this.props.intl} ref={this.settingsRef} />;
|
||||
settingsForm = <RTorrentConnectionSettingsForm intl={intl} ref={this.settingsRef} />;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -53,9 +56,9 @@ class ClientConnectionSettingsForm extends React.Component<WrappedComponentProps
|
||||
<FormRow>
|
||||
<Select
|
||||
id="client"
|
||||
label={this.props.intl.formatMessage({id: 'connection.settings.client.select'})}
|
||||
onSelect={(client) => {
|
||||
this.setState({client: client as ClientConnectionSettings['client']});
|
||||
label={intl.formatMessage({id: 'connection.settings.client.select'})}
|
||||
onSelect={(selectedClient) => {
|
||||
this.setState({client: selectedClient as ClientConnectionSettings['client']});
|
||||
}}>
|
||||
{getClientSelectItems()}
|
||||
</Select>
|
||||
|
||||
@@ -34,29 +34,31 @@ class RTorrentConnectionSettingsForm extends Component<
|
||||
}
|
||||
|
||||
getConnectionSettings(): RTorrentConnectionSettings | null {
|
||||
switch (this.state.type) {
|
||||
const {type, socket, host, port} = this.state;
|
||||
|
||||
switch (type) {
|
||||
case 'socket': {
|
||||
if (this.state.socket == null) {
|
||||
if (socket == null) {
|
||||
return null;
|
||||
}
|
||||
const settings: RTorrentSocketConnectionSettings = {
|
||||
client: 'rTorrent',
|
||||
type: 'socket',
|
||||
version: 1,
|
||||
socket: this.state.socket,
|
||||
socket,
|
||||
};
|
||||
return settings;
|
||||
}
|
||||
case 'tcp': {
|
||||
const portAsNumber = Number(this.state.port);
|
||||
if (this.state.host == null || portAsNumber == null) {
|
||||
const portAsNumber = Number(port);
|
||||
if (host == null || portAsNumber == null) {
|
||||
return null;
|
||||
}
|
||||
const settings: RTorrentTCPConnectionSettings = {
|
||||
client: 'rTorrent',
|
||||
type: 'tcp',
|
||||
version: 1,
|
||||
host: this.state.host,
|
||||
host,
|
||||
port: portAsNumber,
|
||||
};
|
||||
return settings;
|
||||
@@ -89,14 +91,17 @@ class RTorrentConnectionSettingsForm extends Component<
|
||||
}
|
||||
|
||||
renderConnectionOptions() {
|
||||
if (this.state.type === 'tcp') {
|
||||
const {intl} = this.props;
|
||||
const {type} = this.state;
|
||||
|
||||
if (type === 'tcp') {
|
||||
return (
|
||||
<FormRow>
|
||||
<Textbox
|
||||
onChange={(e) => this.handleFormChange(e, 'host')}
|
||||
id="host"
|
||||
label={<FormattedMessage id="connection.settings.rtorrent.host" />}
|
||||
placeholder={this.props.intl.formatMessage({
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'connection.settings.rtorrent.host.input.placeholder',
|
||||
})}
|
||||
/>
|
||||
@@ -104,7 +109,7 @@ class RTorrentConnectionSettingsForm extends Component<
|
||||
onChange={(e) => this.handleFormChange(e, 'port')}
|
||||
id="port"
|
||||
label={<FormattedMessage id="connection.settings.rtorrent.port" />}
|
||||
placeholder={this.props.intl.formatMessage({
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'connection.settings.rtorrent.port.input.placeholder',
|
||||
})}
|
||||
/>
|
||||
@@ -118,7 +123,7 @@ class RTorrentConnectionSettingsForm extends Component<
|
||||
onChange={(e) => this.handleFormChange(e, 'socket')}
|
||||
id="socket"
|
||||
label={<FormattedMessage id="connection.settings.rtorrent.socket" />}
|
||||
placeholder={this.props.intl.formatMessage({
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'connection.settings.rtorrent.socket.input.placeholder',
|
||||
})}
|
||||
/>
|
||||
@@ -127,12 +132,15 @@ class RTorrentConnectionSettingsForm extends Component<
|
||||
}
|
||||
|
||||
render() {
|
||||
const {intl} = this.props;
|
||||
const {type} = this.state;
|
||||
|
||||
return (
|
||||
<FormRow>
|
||||
<FormGroup>
|
||||
<FormRow>
|
||||
<FormGroup
|
||||
label={this.props.intl.formatMessage({
|
||||
label={intl.formatMessage({
|
||||
id: 'connection.settings.rtorrent.type',
|
||||
})}>
|
||||
<FormRow>
|
||||
@@ -141,7 +149,7 @@ class RTorrentConnectionSettingsForm extends Component<
|
||||
groupID="type"
|
||||
id="tcp"
|
||||
grow={false}
|
||||
checked={this.state.type === 'tcp'}>
|
||||
checked={type === 'tcp'}>
|
||||
<FormattedMessage id="connection.settings.rtorrent.type.tcp" />
|
||||
</Radio>
|
||||
<Radio
|
||||
@@ -149,7 +157,7 @@ class RTorrentConnectionSettingsForm extends Component<
|
||||
groupID="type"
|
||||
id="socket"
|
||||
grow={false}
|
||||
checked={this.state.type === 'socket'}>
|
||||
checked={type === 'socket'}>
|
||||
<FormattedMessage id="connection.settings.rtorrent.type.socket" />
|
||||
</Radio>
|
||||
</FormRow>
|
||||
|
||||
@@ -36,7 +36,9 @@ class DirectoryFiles extends React.Component<DirectoryFilesProps> {
|
||||
}
|
||||
|
||||
getCurrentPath(file: TorrentContent) {
|
||||
return [...this.props.path, file.filename];
|
||||
const {path} = this.props;
|
||||
|
||||
return [...path, file.filename];
|
||||
}
|
||||
|
||||
getIcon(file: TorrentContent, isSelected: boolean) {
|
||||
@@ -61,29 +63,34 @@ class DirectoryFiles extends React.Component<DirectoryFilesProps> {
|
||||
}
|
||||
|
||||
handleFileSelect(file: TorrentContent, isSelected: boolean): void {
|
||||
this.props.onItemSelect({
|
||||
const {depth, onItemSelect} = this.props;
|
||||
|
||||
onItemSelect({
|
||||
type: 'file',
|
||||
depth: this.props.depth,
|
||||
depth,
|
||||
path: this.getCurrentPath(file),
|
||||
select: !isSelected,
|
||||
});
|
||||
}
|
||||
|
||||
handlePriorityChange(fileIndex: React.ReactText, priorityLevel: number) {
|
||||
this.props.onPriorityChange();
|
||||
TorrentActions.setFilePriority(this.props.hash, {indices: [Number(fileIndex)], priority: priorityLevel});
|
||||
const {hash, onPriorityChange} = this.props;
|
||||
|
||||
onPriorityChange();
|
||||
TorrentActions.setFilePriority(hash, {indices: [Number(fileIndex)], priority: priorityLevel});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.items == null) {
|
||||
const {items} = this.props;
|
||||
|
||||
if (items == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const files = Object.values(this.props.items)
|
||||
const files = Object.values(items)
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename))
|
||||
.map((file) => {
|
||||
const isSelected =
|
||||
(this.props.items && this.props.items[file.filename] && this.props.items[file.filename].isSelected) || false;
|
||||
const isSelected = (items && items[file.filename] && items[file.filename].isSelected) || false;
|
||||
const classes = classnames(
|
||||
'directory-tree__node file',
|
||||
'directory-tree__node--file directory-tree__node--selectable',
|
||||
|
||||
@@ -10,91 +10,70 @@ import DirectoryTreeNode from './DirectoryTreeNode';
|
||||
|
||||
interface DirectoryTreeProps {
|
||||
depth?: number;
|
||||
path: Array<string>;
|
||||
path?: Array<string>;
|
||||
hash: TorrentProperties['hash'];
|
||||
itemsTree: TorrentContentSelectionTree;
|
||||
onPriorityChange: () => void;
|
||||
onItemSelect: (selection: TorrentContentSelection) => void;
|
||||
}
|
||||
|
||||
const METHODS_TO_BIND = ['getDirectoryTreeDomNodes'] as const;
|
||||
const DirectoryTree: React.FC<DirectoryTreeProps> = (props: DirectoryTreeProps) => {
|
||||
const {depth = 0, itemsTree, hash, path, onItemSelect, onPriorityChange} = props;
|
||||
const {files, directories} = itemsTree;
|
||||
const childDepth = depth + 1;
|
||||
|
||||
class DirectoryTree extends React.Component<DirectoryTreeProps> {
|
||||
static defaultProps = {
|
||||
path: [],
|
||||
itemsTree: {},
|
||||
};
|
||||
const directoryNodes: Array<React.ReactNode> =
|
||||
directories != null
|
||||
? Object.keys(directories)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map(
|
||||
(directoryName, index): React.ReactNode => {
|
||||
const subSelectedItems = itemsTree.directories && itemsTree.directories[directoryName];
|
||||
|
||||
constructor(props: DirectoryTreeProps) {
|
||||
super(props);
|
||||
const id = `${index}${childDepth}${directoryName}`;
|
||||
const isSelected = (subSelectedItems && subSelectedItems.isSelected) || false;
|
||||
|
||||
METHODS_TO_BIND.forEach((method) => {
|
||||
this[method] = this[method].bind(this);
|
||||
});
|
||||
}
|
||||
if (subSelectedItems == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
getDirectoryTreeDomNodes(itemsTree: TorrentContentSelectionTree, depth = 0) {
|
||||
const {hash} = this.props;
|
||||
const {files, directories} = itemsTree;
|
||||
const childDepth = depth + 1;
|
||||
return (
|
||||
<DirectoryTreeNode
|
||||
depth={childDepth}
|
||||
directoryName={directoryName}
|
||||
hash={hash}
|
||||
id={id}
|
||||
isSelected={isSelected}
|
||||
key={id}
|
||||
itemsTree={subSelectedItems}
|
||||
onItemSelect={onItemSelect}
|
||||
onPriorityChange={onPriorityChange}
|
||||
path={path}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)
|
||||
: [];
|
||||
|
||||
const directoryNodes: Array<React.ReactNode> =
|
||||
directories != null
|
||||
? Object.keys(directories)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map(
|
||||
(directoryName, index): React.ReactNode => {
|
||||
const subSelectedItems =
|
||||
this.props.itemsTree.directories && this.props.itemsTree.directories[directoryName];
|
||||
const fileList: React.ReactNode =
|
||||
files != null && Object.keys(files).length > 0 ? (
|
||||
<DirectoryFileList
|
||||
depth={childDepth}
|
||||
hash={hash}
|
||||
key={`files-${childDepth}`}
|
||||
onItemSelect={onItemSelect}
|
||||
onPriorityChange={onPriorityChange}
|
||||
path={path}
|
||||
items={itemsTree.files}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const id = `${index}${childDepth}${directoryName}`;
|
||||
const isSelected = (subSelectedItems && subSelectedItems.isSelected) || false;
|
||||
return <div className="directory-tree__tree">{directoryNodes.concat(fileList)}</div>;
|
||||
};
|
||||
|
||||
if (subSelectedItems == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DirectoryTreeNode
|
||||
depth={childDepth}
|
||||
directoryName={directoryName}
|
||||
hash={hash}
|
||||
id={id}
|
||||
isSelected={isSelected}
|
||||
key={id}
|
||||
itemsTree={subSelectedItems}
|
||||
onItemSelect={this.props.onItemSelect}
|
||||
onPriorityChange={this.props.onPriorityChange}
|
||||
path={this.props.path}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)
|
||||
: [];
|
||||
|
||||
const fileList: React.ReactNode =
|
||||
files != null && Object.keys(files).length > 0 ? (
|
||||
<DirectoryFileList
|
||||
depth={childDepth}
|
||||
hash={hash}
|
||||
key={`files-${childDepth}`}
|
||||
onItemSelect={this.props.onItemSelect}
|
||||
onPriorityChange={this.props.onPriorityChange}
|
||||
path={this.props.path}
|
||||
items={this.props.itemsTree.files}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return directoryNodes.concat(fileList);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="directory-tree__tree">
|
||||
{this.getDirectoryTreeDomNodes(this.props.itemsTree, this.props.depth)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
DirectoryTree.defaultProps = {
|
||||
depth: 0,
|
||||
path: [],
|
||||
};
|
||||
|
||||
export default DirectoryTree;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import type {TorrentContentSelection, TorrentContentSelectionTree} from '@shared/types/TorrentContent';
|
||||
@@ -31,11 +30,6 @@ interface DirectoryTreeNodeStates {
|
||||
const METHODS_TO_BIND = ['handleDirectoryClick', 'handleDirectorySelection'] as const;
|
||||
|
||||
class DirectoryTreeNode extends React.Component<DirectoryTreeNodeProps, DirectoryTreeNodeStates> {
|
||||
static propTypes = {
|
||||
path: PropTypes.array,
|
||||
selectedItems: PropTypes.object,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
path: [],
|
||||
selectedItems: {},
|
||||
@@ -54,13 +48,17 @@ class DirectoryTreeNode extends React.Component<DirectoryTreeNodeProps, Director
|
||||
}
|
||||
|
||||
getCurrentPath() {
|
||||
return [...this.props.path, this.props.directoryName];
|
||||
const {path, directoryName} = this.props;
|
||||
|
||||
return [...path, directoryName];
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
let icon = null;
|
||||
const {id, isSelected} = this.props;
|
||||
const {expanded} = this.state;
|
||||
|
||||
if (this.state.expanded) {
|
||||
let icon = null;
|
||||
if (expanded) {
|
||||
icon = <FolderOpenSolid />;
|
||||
} else {
|
||||
icon = <FolderClosedSolid />;
|
||||
@@ -71,12 +69,7 @@ class DirectoryTreeNode extends React.Component<DirectoryTreeNodeProps, Director
|
||||
<div
|
||||
className="directory-tree__checkbox__item
|
||||
directory-tree__checkbox__item--checkbox">
|
||||
<Checkbox
|
||||
checked={this.props.isSelected}
|
||||
id={this.props.id}
|
||||
onChange={this.handleDirectorySelection}
|
||||
useProps
|
||||
/>
|
||||
<Checkbox checked={isSelected} id={id} onChange={this.handleDirectorySelection} useProps />
|
||||
</div>
|
||||
<div
|
||||
className="directory-tree__checkbox__item
|
||||
@@ -88,17 +81,20 @@ class DirectoryTreeNode extends React.Component<DirectoryTreeNodeProps, Director
|
||||
}
|
||||
|
||||
getSubTree() {
|
||||
if (this.state.expanded) {
|
||||
const {depth, itemsTree, hash, onItemSelect, onPriorityChange} = this.props;
|
||||
const {expanded} = this.state;
|
||||
|
||||
if (expanded) {
|
||||
return (
|
||||
<div className="directory-tree__node directory-tree__node--group">
|
||||
<DirectoryTree
|
||||
depth={this.props.depth}
|
||||
hash={this.props.hash}
|
||||
key={`${this.state.expanded}-${this.props.depth}`}
|
||||
onPriorityChange={this.props.onPriorityChange}
|
||||
onItemSelect={this.props.onItemSelect}
|
||||
depth={depth}
|
||||
hash={hash}
|
||||
key={`${expanded}-${depth}`}
|
||||
onPriorityChange={onPriorityChange}
|
||||
onItemSelect={onItemSelect}
|
||||
path={this.getCurrentPath()}
|
||||
itemsTree={this.props.itemsTree}
|
||||
itemsTree={itemsTree}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -116,32 +112,37 @@ class DirectoryTreeNode extends React.Component<DirectoryTreeNodeProps, Director
|
||||
}
|
||||
|
||||
handleDirectorySelection() {
|
||||
this.props.onItemSelect({
|
||||
const {depth, isSelected, onItemSelect} = this.props;
|
||||
|
||||
onItemSelect({
|
||||
type: 'directory',
|
||||
depth: this.props.depth,
|
||||
depth,
|
||||
path: this.getCurrentPath(),
|
||||
select: !this.props.isSelected,
|
||||
select: !isSelected,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const branchClasses = classnames('directory-tree__branch', `directory-tree__branch--depth-${this.props.depth}`, {
|
||||
'directory-tree__node--selected': this.props.isSelected,
|
||||
const {depth, directoryName, isSelected} = this.props;
|
||||
const {expanded} = this.state;
|
||||
|
||||
const branchClasses = classnames('directory-tree__branch', `directory-tree__branch--depth-${depth}`, {
|
||||
'directory-tree__node--selected': isSelected,
|
||||
});
|
||||
const directoryClasses = classnames(
|
||||
'directory-tree__node',
|
||||
'directory-tree__node--selectable directory-tree__node--directory',
|
||||
{
|
||||
'is-expanded': this.state.expanded,
|
||||
'is-expanded': expanded,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={branchClasses}>
|
||||
<div className={directoryClasses} onClick={this.handleDirectoryClick} title={this.props.directoryName}>
|
||||
<div className={directoryClasses} onClick={this.handleDirectoryClick} title={directoryName}>
|
||||
<div className="file__label">
|
||||
{this.getIcon()}
|
||||
<div className="file__name">{this.props.directoryName}</div>
|
||||
<div className="file__name">{directoryName}</div>
|
||||
</div>
|
||||
</div>
|
||||
{this.getSubTree()}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import {defineMessages} from 'react-intl';
|
||||
import {defineMessages, WrappedComponentProps} from 'react-intl';
|
||||
|
||||
import ArrowIcon from '../../icons/ArrowIcon';
|
||||
import CustomScrollbars from '../CustomScrollbars';
|
||||
@@ -20,10 +20,27 @@ const MESSAGES = defineMessages({
|
||||
fetching: {
|
||||
id: 'filesystem.fetching',
|
||||
},
|
||||
unknownError: {
|
||||
id: 'filesystem.error.unknown',
|
||||
},
|
||||
});
|
||||
|
||||
class FilesystemBrowser extends React.PureComponent {
|
||||
constructor(props) {
|
||||
interface FilesystemBrowserProps extends WrappedComponentProps {
|
||||
selectable?: 'files' | 'directories';
|
||||
directory: string;
|
||||
maxHeight?: number | string | null;
|
||||
onItemSelection?: (newDestination: string, isDirectory?: boolean) => void;
|
||||
}
|
||||
|
||||
interface FilesystemBrowserStates {
|
||||
errorResponse: {data?: NodeJS.ErrnoException} | null;
|
||||
separator: string;
|
||||
directories?: Array<string>;
|
||||
files?: Array<string>;
|
||||
}
|
||||
|
||||
class FilesystemBrowser extends React.PureComponent<FilesystemBrowserProps, FilesystemBrowserStates> {
|
||||
constructor(props: FilesystemBrowserProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
@@ -32,18 +49,33 @@ class FilesystemBrowser extends React.PureComponent {
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
componentDidMount(): void {
|
||||
this.fetchDirectoryListForCurrentDirectory();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.directory !== this.props.directory) {
|
||||
componentDidUpdate(prevProps: FilesystemBrowserProps): void {
|
||||
const {directory} = this.props;
|
||||
|
||||
if (prevProps.directory !== directory) {
|
||||
this.fetchDirectoryListForCurrentDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
fetchDirectoryListForCurrentDirectory = () => {
|
||||
FloodActions.fetchDirectoryList({path: this.props.directory})
|
||||
getNewDestination(nextDirectorySegment: string): string {
|
||||
const {separator} = this.state;
|
||||
const {directory} = this.props;
|
||||
|
||||
if (directory.endsWith(separator)) {
|
||||
return `${directory}${nextDirectorySegment}`;
|
||||
}
|
||||
|
||||
return `${directory}${separator}${nextDirectorySegment}`;
|
||||
}
|
||||
|
||||
fetchDirectoryListForCurrentDirectory = (): void => {
|
||||
const {directory} = this.props;
|
||||
|
||||
FloodActions.fetchDirectoryList({path: directory})
|
||||
.then((response) => {
|
||||
this.setState({
|
||||
...response,
|
||||
@@ -55,18 +87,7 @@ class FilesystemBrowser extends React.PureComponent {
|
||||
});
|
||||
};
|
||||
|
||||
getNewDestination(nextDirectorySegment) {
|
||||
const {separator} = this.state;
|
||||
const {directory} = this.props;
|
||||
|
||||
if (directory.endsWith(separator)) {
|
||||
return `${directory}${nextDirectorySegment}`;
|
||||
}
|
||||
|
||||
return `${directory}${separator}${nextDirectorySegment}`;
|
||||
}
|
||||
|
||||
handleItemClick = (item, isDirectory = true) => {
|
||||
handleItemClick = (item: string, isDirectory = true) => {
|
||||
if (this.props.onItemSelection) {
|
||||
this.props.onItemSelection(this.getNewDestination(item), isDirectory);
|
||||
}
|
||||
@@ -91,28 +112,30 @@ class FilesystemBrowser extends React.PureComponent {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {selectable} = this.props;
|
||||
const {directories, errorResponse, files = []} = this.state;
|
||||
const {intl, selectable, maxHeight} = this.props;
|
||||
const {directories, errorResponse, files} = this.state;
|
||||
let errorMessage = null;
|
||||
let listItems = null;
|
||||
let parentDirectory = null;
|
||||
let shouldShowDirectoryList = true;
|
||||
|
||||
if (directories == null) {
|
||||
if ((directories == null && selectable === 'directories') || (files == null && selectable === 'files')) {
|
||||
shouldShowDirectoryList = false;
|
||||
errorMessage = (
|
||||
<div className="filesystem__directory-list__item filesystem__directory-list__item--message">
|
||||
<em>{this.props.intl.formatMessage(MESSAGES.fetching)}</em>
|
||||
<em>{intl.formatMessage(MESSAGES.fetching)}</em>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorResponse && errorResponse.data && errorResponse.data.code && MESSAGES[errorResponse.data.code]) {
|
||||
if (errorResponse && errorResponse.data && errorResponse.data.code) {
|
||||
shouldShowDirectoryList = false;
|
||||
|
||||
const messageConfig = MESSAGES[errorResponse.data.code as keyof typeof MESSAGES] || MESSAGES.unknownError;
|
||||
|
||||
errorMessage = (
|
||||
<div className="filesystem__directory-list__item filesystem__directory-list__item--message">
|
||||
<em>{this.props.intl.formatMessage(MESSAGES[errorResponse.data.code])}</em>
|
||||
<em>{intl.formatMessage(messageConfig)}</em>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,40 +145,46 @@ class FilesystemBrowser extends React.PureComponent {
|
||||
className="filesystem__directory-list__item filesystem__directory-list__item--parent"
|
||||
onClick={this.handleParentDirectoryClick}>
|
||||
<ArrowIcon />
|
||||
{this.props.intl.formatMessage({
|
||||
{intl.formatMessage({
|
||||
id: 'filesystem.parent.directory',
|
||||
})}
|
||||
</li>
|
||||
);
|
||||
|
||||
if (shouldShowDirectoryList) {
|
||||
const directoryList = directories.map((directory, index) => (
|
||||
<li
|
||||
className={`${'filesystem__directory-list__item filesystem__directory-list__item--directory'.concat(
|
||||
selectable !== 'files' ? ' filesystem__directory-list__item--selectable' : '',
|
||||
)}`}
|
||||
// TODO: Find a better key
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
onClick={selectable !== 'files' ? () => this.handleItemClick(directory) : undefined}>
|
||||
<FolderClosedSolid />
|
||||
{directory}
|
||||
</li>
|
||||
));
|
||||
const directoryList: React.ReactNodeArray =
|
||||
directories != null
|
||||
? directories.map((directory, index) => (
|
||||
<li
|
||||
className={`${'filesystem__directory-list__item filesystem__directory-list__item--directory'.concat(
|
||||
selectable !== 'files' ? ' filesystem__directory-list__item--selectable' : '',
|
||||
)}`}
|
||||
// TODO: Find a better key
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
onClick={selectable !== 'files' ? () => this.handleItemClick(directory) : undefined}>
|
||||
<FolderClosedSolid />
|
||||
{directory}
|
||||
</li>
|
||||
))
|
||||
: [];
|
||||
|
||||
const filesList = files.map((file, index) => (
|
||||
<li
|
||||
className={`${'filesystem__directory-list__item filesystem__directory-list__item--file'.concat(
|
||||
selectable !== 'directories' ? ' filesystem__directory-list__item--selectable' : '',
|
||||
)}`}
|
||||
// TODO: Find a better key
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`file.${index}`}
|
||||
onClick={selectable !== 'directories' ? () => this.handleItemClick(file, false) : undefined}>
|
||||
<File />
|
||||
{file}
|
||||
</li>
|
||||
));
|
||||
const filesList: React.ReactNodeArray =
|
||||
files != null
|
||||
? files.map((file, index) => (
|
||||
<li
|
||||
className={`${'filesystem__directory-list__item filesystem__directory-list__item--file'.concat(
|
||||
selectable !== 'directories' ? ' filesystem__directory-list__item--selectable' : '',
|
||||
)}`}
|
||||
// TODO: Find a better key
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`file.${index}`}
|
||||
onClick={selectable !== 'directories' ? () => this.handleItemClick(file, false) : undefined}>
|
||||
<File />
|
||||
{file}
|
||||
</li>
|
||||
))
|
||||
: [];
|
||||
|
||||
listItems = directoryList.concat(filesList);
|
||||
}
|
||||
@@ -163,13 +192,13 @@ class FilesystemBrowser extends React.PureComponent {
|
||||
if ((!listItems || listItems.length === 0) && !errorMessage) {
|
||||
errorMessage = (
|
||||
<div className="filesystem__directory-list__item filesystem__directory-list__item--message">
|
||||
<em>{this.props.intl.formatMessage(MESSAGES.emptyDirectory)}</em>
|
||||
<em>{intl.formatMessage(MESSAGES.emptyDirectory)}</em>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomScrollbars autoHeight autoHeightMin={0} autoHeightMax={this.props.maxHeight}>
|
||||
<CustomScrollbars autoHeight autoHeightMin={0} autoHeightMax={maxHeight || undefined}>
|
||||
<div className="filesystem__directory-list context-menu__items__padding-surrogate">
|
||||
{parentDirectory}
|
||||
{errorMessage}
|
||||
@@ -13,7 +13,7 @@ interface FilesystemBrowserTextboxProps extends WrappedComponentProps {
|
||||
label?: React.ReactNode;
|
||||
selectable?: 'directories' | 'files';
|
||||
suggested?: string;
|
||||
basePathToggle?: boolean;
|
||||
showBasePathToggle?: boolean;
|
||||
onChange?: (destination: string) => void;
|
||||
}
|
||||
|
||||
@@ -52,9 +52,11 @@ class FilesystemBrowserTextbox extends React.Component<FilesystemBrowserTextboxP
|
||||
}
|
||||
|
||||
componentDidUpdate(_prevProps: FilesystemBrowserTextboxProps, prevState: FilesystemBrowserTextboxStates) {
|
||||
if (!prevState.isDirectoryListOpen && this.state.isDirectoryListOpen) {
|
||||
const {isDirectoryListOpen} = this.state;
|
||||
|
||||
if (!prevState.isDirectoryListOpen && isDirectoryListOpen) {
|
||||
this.addDestinationOpenEventListeners();
|
||||
} else if (prevState.isDirectoryListOpen && !this.state.isDirectoryListOpen) {
|
||||
} else if (prevState.isDirectoryListOpen && !isDirectoryListOpen) {
|
||||
this.removeDestinationOpenEventListeners();
|
||||
}
|
||||
}
|
||||
@@ -64,11 +66,6 @@ class FilesystemBrowserTextbox extends React.Component<FilesystemBrowserTextboxP
|
||||
this.removeDestinationOpenEventListeners();
|
||||
}
|
||||
|
||||
addDestinationOpenEventListeners() {
|
||||
document.addEventListener('click', this.handleDocumentClick);
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
}
|
||||
|
||||
closeDirectoryList = () => {
|
||||
if (this.state.isDirectoryListOpen) {
|
||||
this.setState({isDirectoryListOpen: false});
|
||||
@@ -124,11 +121,6 @@ class FilesystemBrowserTextbox extends React.Component<FilesystemBrowserTextboxP
|
||||
this.closeDirectoryList();
|
||||
};
|
||||
|
||||
removeDestinationOpenEventListeners() {
|
||||
document.removeEventListener('click', this.handleDocumentClick);
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
}
|
||||
|
||||
toggleOpenState = () => {
|
||||
this.setState((state) => {
|
||||
return {
|
||||
@@ -137,10 +129,21 @@ class FilesystemBrowserTextbox extends React.Component<FilesystemBrowserTextboxP
|
||||
});
|
||||
};
|
||||
|
||||
removeDestinationOpenEventListeners() {
|
||||
document.removeEventListener('click', this.handleDocumentClick);
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
}
|
||||
|
||||
addDestinationOpenEventListeners() {
|
||||
document.addEventListener('click', this.handleDocumentClick);
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {intl, id, label, selectable, showBasePathToggle} = this.props;
|
||||
const {destination, isDirectoryListOpen} = this.state;
|
||||
|
||||
const basePathToggle = this.props.basePathToggle ? (
|
||||
const basePathToggle = showBasePathToggle ? (
|
||||
<FormRow>
|
||||
<Checkbox grow={false} id="isBasePath">
|
||||
<FormattedMessage id="torrents.destination.base_path" />
|
||||
@@ -154,11 +157,11 @@ class FilesystemBrowserTextbox extends React.Component<FilesystemBrowserTextboxP
|
||||
<Textbox
|
||||
addonPlacement="after"
|
||||
defaultValue={destination}
|
||||
id={this.props.id}
|
||||
label={this.props.label}
|
||||
id={id}
|
||||
label={label}
|
||||
onChange={this.handleDestinationInputChange}
|
||||
onClick={(event) => event.nativeEvent.stopImmediatePropagation()}
|
||||
placeholder={this.props.intl.formatMessage({
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'torrents.add.destination.placeholder',
|
||||
})}
|
||||
setRef={(ref) => {
|
||||
@@ -169,7 +172,7 @@ class FilesystemBrowserTextbox extends React.Component<FilesystemBrowserTextboxP
|
||||
</FormElementAddon>
|
||||
<Portal>
|
||||
<ContextMenu
|
||||
in={isDirectoryListOpen}
|
||||
isIn={isDirectoryListOpen}
|
||||
onClick={(event) => event.nativeEvent.stopImmediatePropagation()}
|
||||
overlayProps={{isInteractive: false}}
|
||||
padding={false}
|
||||
@@ -183,13 +186,13 @@ class FilesystemBrowserTextbox extends React.Component<FilesystemBrowserTextboxP
|
||||
triggerRef={this.textboxRef}>
|
||||
<FilesystemBrowser
|
||||
directory={destination}
|
||||
intl={this.props.intl}
|
||||
intl={intl}
|
||||
maxHeight={
|
||||
this.contextMenuInstanceRef &&
|
||||
this.contextMenuInstanceRef.dropdownStyle &&
|
||||
this.contextMenuInstanceRef.dropdownStyle.maxHeight
|
||||
}
|
||||
selectable={this.props.selectable}
|
||||
selectable={selectable}
|
||||
onItemSelection={this.handleItemSelection}
|
||||
/>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -103,4 +103,5 @@ class PriorityMeter extends React.Component<PriorityMeterProps, PriorityMeterSta
|
||||
}
|
||||
}
|
||||
|
||||
export type PriorityMeterType = PriorityMeter;
|
||||
export default injectIntl(PriorityMeter, {forwardRef: true});
|
||||
|
||||
@@ -10,7 +10,7 @@ import UIStore from '../../../stores/UIStore';
|
||||
export interface DropdownItem<T = string> {
|
||||
className?: string;
|
||||
displayName: React.ReactNode;
|
||||
selectable: boolean;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
property?: T;
|
||||
value?: number | null;
|
||||
@@ -73,72 +73,26 @@ class Dropdown<T = string> extends React.Component<DropdownProps<T>, DropdownSta
|
||||
this.handleKeyPress = throttle(this.handleKeyPress, 200);
|
||||
}
|
||||
|
||||
closeDropdown() {
|
||||
window.removeEventListener('keydown', this.handleKeyPress);
|
||||
window.removeEventListener('click', this.closeDropdown);
|
||||
UIStore.unlisten('UI_DROPDOWN_MENU_CHANGE', this.handleActiveDropdownChange);
|
||||
|
||||
this.setState({isOpen: false});
|
||||
}
|
||||
|
||||
openDropdown() {
|
||||
window.addEventListener('keydown', this.handleKeyPress);
|
||||
window.addEventListener('click', this.closeDropdown);
|
||||
UIStore.listen('UI_DROPDOWN_MENU_CHANGE', this.handleActiveDropdownChange);
|
||||
|
||||
this.setState({isOpen: true});
|
||||
|
||||
if (this.props.onOpen) {
|
||||
this.props.onOpen();
|
||||
}
|
||||
|
||||
UIActions.displayDropdownMenu(this.id);
|
||||
}
|
||||
|
||||
handleDropdownClick(event: React.MouseEvent<HTMLDivElement>) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.state.isOpen) {
|
||||
this.closeDropdown();
|
||||
} else {
|
||||
this.openDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
handleActiveDropdownChange() {
|
||||
if (this.state.isOpen && UIStore.getActiveDropdownMenu() !== this.id) {
|
||||
this.closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
handleItemSelect(item: DropdownItem<T>) {
|
||||
this.closeDropdown();
|
||||
this.props.handleItemSelect(item);
|
||||
}
|
||||
|
||||
handleKeyPress(event: KeyboardEvent) {
|
||||
if (this.state.isOpen && event.keyCode === 27) {
|
||||
this.closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
private getDropdownButton(options: {header?: boolean; trigger?: boolean} = {}) {
|
||||
let label = this.props.header;
|
||||
const {header, trigger, dropdownButtonClass} = this.props;
|
||||
|
||||
if (options.trigger && !!this.props.trigger) {
|
||||
label = this.props.trigger;
|
||||
let label = header;
|
||||
if (options.trigger && !!trigger) {
|
||||
label = trigger;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={this.props.dropdownButtonClass} onClick={this.handleDropdownClick}>
|
||||
<div className={dropdownButtonClass} onClick={this.handleDropdownClick}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private getDropdownMenu(items: Array<DropdownItems<T>>) {
|
||||
const {direction} = this.props;
|
||||
|
||||
// TODO: Rewrite this function, wtf was I thinking
|
||||
const arrayMethod = this.props.direction === 'up' ? 'unshift' : 'push';
|
||||
const arrayMethod = direction === 'up' ? 'unshift' : 'push';
|
||||
const content = [
|
||||
<div className="dropdown__header" key="dropdown-header">
|
||||
{this.getDropdownButton({header: true, trigger: false})}
|
||||
@@ -187,26 +141,82 @@ class Dropdown<T = string> extends React.Component<DropdownProps<T>, DropdownSta
|
||||
});
|
||||
}
|
||||
|
||||
closeDropdown() {
|
||||
window.removeEventListener('keydown', this.handleKeyPress);
|
||||
window.removeEventListener('click', this.closeDropdown);
|
||||
UIStore.unlisten('UI_DROPDOWN_MENU_CHANGE', this.handleActiveDropdownChange);
|
||||
|
||||
this.setState({isOpen: false});
|
||||
}
|
||||
|
||||
openDropdown() {
|
||||
window.addEventListener('keydown', this.handleKeyPress);
|
||||
window.addEventListener('click', this.closeDropdown);
|
||||
UIStore.listen('UI_DROPDOWN_MENU_CHANGE', this.handleActiveDropdownChange);
|
||||
|
||||
this.setState({isOpen: true});
|
||||
|
||||
const {onOpen} = this.props;
|
||||
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
|
||||
UIActions.displayDropdownMenu(this.id);
|
||||
}
|
||||
|
||||
handleDropdownClick(event: React.MouseEvent<HTMLDivElement>) {
|
||||
event.stopPropagation();
|
||||
|
||||
const {isOpen} = this.state;
|
||||
|
||||
if (isOpen) {
|
||||
this.closeDropdown();
|
||||
} else {
|
||||
this.openDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
handleActiveDropdownChange() {
|
||||
const {isOpen} = this.state;
|
||||
if (isOpen && UIStore.getActiveDropdownMenu() !== this.id) {
|
||||
this.closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
handleItemSelect(item: DropdownItem<T>) {
|
||||
const {handleItemSelect} = this.props;
|
||||
|
||||
this.closeDropdown();
|
||||
handleItemSelect(item);
|
||||
}
|
||||
|
||||
handleKeyPress(event: KeyboardEvent) {
|
||||
const {isOpen} = this.state;
|
||||
if (isOpen && event.keyCode === 27) {
|
||||
this.closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const dropdownWrapperClass = classnames(
|
||||
this.props.dropdownWrapperClass,
|
||||
`${this.props.baseClassName}--direction-${this.props.direction}`,
|
||||
{
|
||||
[`${this.props.baseClassName}--match-button-width`]: this.props.matchButtonWidth,
|
||||
[`${this.props.baseClassName}--width-${this.props.width}`]: this.props.width != null,
|
||||
[`${this.props.baseClassName}--no-wrap`]: this.props.noWrap,
|
||||
'is-expanded': this.state.isOpen,
|
||||
},
|
||||
);
|
||||
const {baseClassName, dropdownWrapperClass, direction, matchButtonWidth, menuItems, noWrap, width} = this.props;
|
||||
const {isOpen} = this.state;
|
||||
|
||||
const dropdownWrapperClassName = classnames(dropdownWrapperClass, `${baseClassName}--direction-${direction}`, {
|
||||
[`${baseClassName}--match-button-width`]: matchButtonWidth,
|
||||
[`${baseClassName}--width-${width}`]: width != null,
|
||||
[`${baseClassName}--no-wrap`]: noWrap,
|
||||
'is-expanded': isOpen,
|
||||
});
|
||||
|
||||
let menu: React.ReactNode = null;
|
||||
|
||||
if (this.state.isOpen) {
|
||||
menu = this.getDropdownMenu(this.props.menuItems);
|
||||
if (isOpen) {
|
||||
menu = this.getDropdownMenu(menuItems);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={dropdownWrapperClass}>
|
||||
<div className={dropdownWrapperClassName}>
|
||||
{this.getDropdownButton({header: false, trigger: true})}
|
||||
<TransitionGroup>{menu}</TransitionGroup>
|
||||
</div>
|
||||
|
||||
@@ -62,13 +62,15 @@ export default class TagSelect extends Component<TagSelectProps, TagSelectStates
|
||||
}
|
||||
|
||||
componentDidUpdate(_prevProps: TagSelectProps, prevState: TagSelectStates) {
|
||||
if (this.state.isOpen && !prevState.isOpen) {
|
||||
const {isOpen} = this.state;
|
||||
|
||||
if (isOpen && !prevState.isOpen) {
|
||||
window.addEventListener('keydown', this.handleKeyDown);
|
||||
window.addEventListener('scroll', this.handleWindowScroll, {
|
||||
capture: true,
|
||||
});
|
||||
document.addEventListener('click', this.toggleOpenState);
|
||||
} else if (!this.state.isOpen && prevState.isOpen) {
|
||||
} else if (!isOpen && prevState.isOpen) {
|
||||
window.addEventListener('keydown', this.handleKeyDown);
|
||||
window.removeEventListener('scroll', this.handleWindowScroll, {
|
||||
capture: true,
|
||||
@@ -85,10 +87,12 @@ export default class TagSelect extends Component<TagSelectProps, TagSelectStates
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
const {selectedTags} = this.state;
|
||||
|
||||
accumulator.push(
|
||||
React.cloneElement(child as React.ReactElement, {
|
||||
onClick: this.handleItemClick,
|
||||
isSelected: this.state.selectedTags.includes(item.props.id as string),
|
||||
isSelected: selectedTags.includes(item.props.id as string),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -138,19 +142,22 @@ export default class TagSelect extends Component<TagSelectProps, TagSelectStates
|
||||
};
|
||||
|
||||
render() {
|
||||
const {defaultValue, placeholder, id, label} = this.props;
|
||||
const {isOpen} = this.state;
|
||||
|
||||
const classes = classnames('select form__element', {
|
||||
'select--is-open': this.state.isOpen,
|
||||
'select--is-open': isOpen,
|
||||
});
|
||||
|
||||
return (
|
||||
<FormRowItem>
|
||||
<label className="form__element__label">{this.props.label}</label>
|
||||
<label className="form__element__label">{label}</label>
|
||||
<div className={classes}>
|
||||
<Textbox
|
||||
id={this.props.id || 'tags'}
|
||||
id={id || 'tags'}
|
||||
addonPlacement="after"
|
||||
defaultValue={this.props.defaultValue}
|
||||
placeholder={this.props.placeholder}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
setRef={(ref) => {
|
||||
this.textboxRef = ref;
|
||||
}}>
|
||||
@@ -159,7 +166,7 @@ export default class TagSelect extends Component<TagSelectProps, TagSelectStates
|
||||
</FormElementAddon>
|
||||
<Portal>
|
||||
<ContextMenu
|
||||
in={this.state.isOpen}
|
||||
isIn={isOpen}
|
||||
onClick={(event) => event.nativeEvent.stopImmediatePropagation()}
|
||||
overlayProps={{isInteractive: false}}
|
||||
setRef={(ref) => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
class ApplicationContent extends React.Component {
|
||||
render() {
|
||||
return <div className="application__content">{this.props.children}</div>;
|
||||
}
|
||||
interface ApplicationContentProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ApplicationContent: React.FC<ApplicationContentProps> = ({children}: ApplicationContentProps) => {
|
||||
return <div className="application__content">{children}</div>;
|
||||
};
|
||||
|
||||
export default ApplicationContent;
|
||||
|
||||
@@ -2,24 +2,25 @@ import classnames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
interface ApplicationContentProps {
|
||||
baseClassName: string;
|
||||
children: React.ReactNode;
|
||||
baseClassName?: string;
|
||||
className: string;
|
||||
modifier: string;
|
||||
}
|
||||
|
||||
class ApplicationContent extends React.Component<ApplicationContentProps> {
|
||||
static defaultProps = {
|
||||
baseClassName: 'application__panel',
|
||||
};
|
||||
const ApplicationContent: React.FC<ApplicationContentProps> = (props: ApplicationContentProps) => {
|
||||
const {children, baseClassName, className, modifier} = props;
|
||||
|
||||
render() {
|
||||
const classes = classnames(this.props.baseClassName, {
|
||||
[`${this.props.baseClassName}--${this.props.modifier}`]: this.props.baseClassName,
|
||||
[this.props.className]: this.props.className,
|
||||
});
|
||||
const classes = classnames(baseClassName, {
|
||||
[`${baseClassName}--${modifier}`]: baseClassName,
|
||||
[className]: className,
|
||||
});
|
||||
|
||||
return <div className={classes}>{this.props.children}</div>;
|
||||
}
|
||||
}
|
||||
return <div className={classes}>{children}</div>;
|
||||
};
|
||||
|
||||
ApplicationContent.defaultProps = {
|
||||
baseClassName: 'application__panel',
|
||||
};
|
||||
|
||||
export default ApplicationContent;
|
||||
|
||||
@@ -2,17 +2,22 @@ import classnames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
interface ApplicationViewProps {
|
||||
children: React.ReactNode;
|
||||
modifier?: string;
|
||||
}
|
||||
|
||||
class ApplicationView extends React.Component<ApplicationViewProps> {
|
||||
render() {
|
||||
const classes = classnames('application__view', {
|
||||
[`application__view--${this.props.modifier}`]: this.props.modifier != null,
|
||||
});
|
||||
const ApplicationView: React.FC<ApplicationViewProps> = (props: ApplicationViewProps) => {
|
||||
const {children, modifier} = props;
|
||||
|
||||
return <div className={classes}>{this.props.children}</div>;
|
||||
}
|
||||
}
|
||||
const classes = classnames('application__view', {
|
||||
[`application__view--${modifier}`]: modifier != null,
|
||||
});
|
||||
|
||||
return <div className={classes}>{children}</div>;
|
||||
};
|
||||
|
||||
ApplicationView.defaultProps = {
|
||||
modifier: undefined,
|
||||
};
|
||||
|
||||
export default ApplicationView;
|
||||
|
||||
@@ -4,10 +4,11 @@ import React from 'react';
|
||||
import ModalActions from './ModalActions';
|
||||
import ModalTabs from './ModalTabs';
|
||||
|
||||
import type {ModalAction} from './ModalActions';
|
||||
import type {Tab} from './ModalTabs';
|
||||
|
||||
interface ModalProps {
|
||||
heading?: React.ReactNode;
|
||||
heading: React.ReactNode;
|
||||
content?: React.ReactNode;
|
||||
className?: string | null;
|
||||
alignment?: 'left' | 'center';
|
||||
@@ -15,131 +16,104 @@ interface ModalProps {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
tabsInBody?: boolean;
|
||||
inverse?: boolean;
|
||||
actions?: ModalActions['props']['actions'];
|
||||
actions?: Array<ModalAction>;
|
||||
tabs?: Record<string, Tab>;
|
||||
onSetRef?: (id: string, ref: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
interface ModalStates {
|
||||
activeTabId: string | null;
|
||||
}
|
||||
const Modal: React.FC<ModalProps> = (props: ModalProps) => {
|
||||
const {alignment, size, orientation, tabsInBody, inverse, className, content, heading, tabs, actions} = props;
|
||||
|
||||
const METHODS_TO_BIND = ['handleTabChange'] as const;
|
||||
const contentWrapperClasses = classnames(
|
||||
'modal__content__wrapper',
|
||||
`modal--align-${alignment}`,
|
||||
`modal--size-${size}`,
|
||||
{
|
||||
'modal--horizontal': orientation === 'horizontal',
|
||||
'modal--vertical': orientation === 'vertical',
|
||||
'modal--tabs-in-header': !tabsInBody,
|
||||
'modal--tabs-in-body': tabsInBody,
|
||||
inverse,
|
||||
},
|
||||
className,
|
||||
);
|
||||
let modalBody = content;
|
||||
const modalHeader = heading;
|
||||
const headerClasses = classnames('modal__header', {
|
||||
'has-tabs': tabs,
|
||||
});
|
||||
|
||||
export default class Modal extends React.Component<ModalProps, ModalStates> {
|
||||
domRefs: Record<string, HTMLDivElement | null> = {};
|
||||
let bodyTabs;
|
||||
let footer;
|
||||
let headerTabs;
|
||||
|
||||
static defaultProps = {
|
||||
alignment: 'left',
|
||||
className: null,
|
||||
inverse: true,
|
||||
size: 'medium',
|
||||
orientation: 'horizontal',
|
||||
tabsInBody: false,
|
||||
};
|
||||
if (tabs) {
|
||||
const [activeTabId, setActiveTabId] = React.useState(Object.keys(tabs)[0]);
|
||||
|
||||
constructor(props: ModalProps) {
|
||||
super(props);
|
||||
const activeTab = tabs[activeTabId];
|
||||
const contentClasses = classnames('modal__content', activeTab.modalContentClasses);
|
||||
|
||||
this.state = {
|
||||
activeTabId: null,
|
||||
};
|
||||
const ModalContentComponent = activeTab.content as React.FunctionComponent;
|
||||
const modalContentData = activeTab.props;
|
||||
|
||||
METHODS_TO_BIND.forEach((method) => {
|
||||
this[method] = this[method].bind(this);
|
||||
});
|
||||
}
|
||||
|
||||
handleTabChange(tab: Tab) {
|
||||
this.setState({activeTabId: tab.id || null});
|
||||
}
|
||||
|
||||
setRef(id: string, ref: HTMLDivElement | null) {
|
||||
this.domRefs[id] = ref;
|
||||
|
||||
if (this.props.onSetRef) {
|
||||
this.props.onSetRef(id, ref);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const contentWrapperClasses = classnames(
|
||||
'modal__content__wrapper',
|
||||
`modal--align-${this.props.alignment}`,
|
||||
`modal--size-${this.props.size}`,
|
||||
{
|
||||
'modal--horizontal': this.props.orientation === 'horizontal',
|
||||
'modal--vertical': this.props.orientation === 'vertical',
|
||||
'modal--tabs-in-header': !this.props.tabsInBody,
|
||||
'modal--tabs-in-body': this.props.tabsInBody,
|
||||
inverse: this.props.inverse,
|
||||
},
|
||||
this.props.className,
|
||||
const modalTabs = (
|
||||
<ModalTabs
|
||||
activeTabId={activeTabId}
|
||||
key="modal-tabs"
|
||||
onTabChange={(tab) => {
|
||||
if (tab.id != null) {
|
||||
setActiveTabId(tab.id);
|
||||
}
|
||||
}}
|
||||
tabs={tabs}
|
||||
/>
|
||||
);
|
||||
let modalBody = this.props.content;
|
||||
const modalHeader = this.props.heading;
|
||||
const headerClasses = classnames('modal__header', {
|
||||
'has-tabs': this.props.tabs,
|
||||
});
|
||||
|
||||
let bodyTabs;
|
||||
let footer;
|
||||
let headerTabs;
|
||||
|
||||
if (this.props.tabs) {
|
||||
let {activeTabId} = this.state;
|
||||
if (activeTabId == null) {
|
||||
[activeTabId] = Object.keys(this.props.tabs);
|
||||
}
|
||||
|
||||
const activeTab = this.props.tabs[activeTabId];
|
||||
const contentClasses = classnames('modal__content', activeTab.modalContentClasses);
|
||||
|
||||
const ModalContentComponent = activeTab.content as React.FunctionComponent;
|
||||
const modalContentData = activeTab.props;
|
||||
|
||||
const tabs = (
|
||||
<ModalTabs
|
||||
activeTabId={activeTabId}
|
||||
key="modal-tabs"
|
||||
onTabChange={this.handleTabChange}
|
||||
tabs={this.props.tabs}
|
||||
/>
|
||||
);
|
||||
|
||||
if (this.props.tabsInBody) {
|
||||
bodyTabs = tabs;
|
||||
} else {
|
||||
headerTabs = tabs;
|
||||
}
|
||||
|
||||
modalBody = [
|
||||
bodyTabs,
|
||||
<div className={contentClasses} key="modal-content">
|
||||
<ModalContentComponent {...modalContentData} />
|
||||
</div>,
|
||||
];
|
||||
if (tabsInBody) {
|
||||
bodyTabs = modalTabs;
|
||||
} else {
|
||||
headerTabs = modalTabs;
|
||||
}
|
||||
|
||||
if (this.props.actions) {
|
||||
footer = (
|
||||
<div className="modal__footer">
|
||||
<ModalActions actions={this.props.actions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
modalBody = [
|
||||
bodyTabs,
|
||||
<div className={contentClasses} key="modal-content">
|
||||
<ModalContentComponent {...modalContentData} />
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={contentWrapperClasses}>
|
||||
<div className={headerClasses}>
|
||||
{modalHeader}
|
||||
{headerTabs}
|
||||
</div>
|
||||
<div className="modal__body" ref={(ref) => this.setRef('modal-body', ref)}>
|
||||
{modalBody}
|
||||
{footer}
|
||||
</div>
|
||||
if (actions) {
|
||||
footer = (
|
||||
<div className="modal__footer">
|
||||
<ModalActions actions={actions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={contentWrapperClasses}>
|
||||
<div className={headerClasses}>
|
||||
{modalHeader}
|
||||
{headerTabs}
|
||||
</div>
|
||||
<div className="modal__body">
|
||||
{modalBody}
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Modal.defaultProps = {
|
||||
alignment: 'left',
|
||||
className: null,
|
||||
inverse: true,
|
||||
size: 'medium',
|
||||
orientation: 'horizontal',
|
||||
tabsInBody: false,
|
||||
content: undefined,
|
||||
actions: undefined,
|
||||
tabs: undefined,
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
|
||||
@@ -10,8 +10,8 @@ interface BaseAction {
|
||||
|
||||
interface CheckboxAction extends BaseAction {
|
||||
type: 'checkbox';
|
||||
id?: Checkbox['props']['id'];
|
||||
checked?: Checkbox['props']['checked'];
|
||||
id?: string;
|
||||
checked?: boolean;
|
||||
clickHandler?: ((event: React.MouseEvent<HTMLInputElement> | KeyboardEvent) => void) | null;
|
||||
}
|
||||
|
||||
@@ -22,59 +22,63 @@ interface ButtonAction extends BaseAction {
|
||||
clickHandler?: ((event: React.MouseEvent<HTMLButtonElement>) => void) | null;
|
||||
}
|
||||
|
||||
export type ModalAction = CheckboxAction | ButtonAction;
|
||||
|
||||
interface ModalActionsProps {
|
||||
actions: Array<CheckboxAction | ButtonAction>;
|
||||
actions: Array<ModalAction>;
|
||||
}
|
||||
|
||||
export default class ModalActions extends React.Component<ModalActionsProps> {
|
||||
render() {
|
||||
const buttons = this.props.actions.map((action, index) => {
|
||||
let dismissIfNeeded = () => {
|
||||
// do nothing by default.
|
||||
const ModalActions: React.FC<ModalActionsProps> = (props: ModalActionsProps) => {
|
||||
const {actions} = props;
|
||||
|
||||
const buttons = actions.map((action, index) => {
|
||||
let dismissIfNeeded = () => {
|
||||
// do nothing by default.
|
||||
};
|
||||
|
||||
if (action.triggerDismiss) {
|
||||
dismissIfNeeded = () => {
|
||||
UIActions.dismissModal();
|
||||
};
|
||||
}
|
||||
|
||||
if (action.triggerDismiss) {
|
||||
dismissIfNeeded = () => {
|
||||
UIActions.dismissModal();
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'checkbox') {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={action.checked}
|
||||
id={action.id}
|
||||
key={index} // eslint-disable-line react/no-array-index-key
|
||||
onChange={(event) => {
|
||||
if (action.clickHandler != null) {
|
||||
action.clickHandler(event);
|
||||
}
|
||||
dismissIfNeeded();
|
||||
}}>
|
||||
{action.content}
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
|
||||
if (action.type === 'checkbox') {
|
||||
return (
|
||||
<Button
|
||||
isLoading={action.isLoading}
|
||||
onClick={(event) => {
|
||||
<Checkbox
|
||||
checked={action.checked}
|
||||
id={action.id}
|
||||
key={index} // eslint-disable-line react/no-array-index-key
|
||||
onChange={(event) => {
|
||||
if (action.clickHandler != null) {
|
||||
action.clickHandler(event);
|
||||
}
|
||||
dismissIfNeeded();
|
||||
}}
|
||||
priority={action.type}
|
||||
key={index} // eslint-disable-line react/no-array-index-key
|
||||
type={action.submit ? 'submit' : 'button'}>
|
||||
}}>
|
||||
{action.content}
|
||||
</Button>
|
||||
</Checkbox>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const buttonsGroup = <div className="modal__button-group">{buttons}</div>;
|
||||
return (
|
||||
<Button
|
||||
isLoading={action.isLoading}
|
||||
onClick={(event) => {
|
||||
if (action.clickHandler != null) {
|
||||
action.clickHandler(event);
|
||||
}
|
||||
dismissIfNeeded();
|
||||
}}
|
||||
priority={action.type}
|
||||
key={index} // eslint-disable-line react/no-array-index-key
|
||||
type={action.submit ? 'submit' : 'button'}>
|
||||
{action.content}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
return <div className="modal__actions">{buttonsGroup}</div>;
|
||||
}
|
||||
}
|
||||
const buttonsGroup = <div className="modal__button-group">{buttons}</div>;
|
||||
|
||||
return <div className="modal__actions">{buttonsGroup}</div>;
|
||||
};
|
||||
|
||||
export default ModalActions;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
class ModalFormSectionHeader extends React.PureComponent {
|
||||
render() {
|
||||
return <h2 className="h4">{this.props.children}</h2>;
|
||||
}
|
||||
interface ModalFormSectionHeaderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalFormSectionHeader: React.FC<ModalFormSectionHeaderProps> = ({children}: ModalFormSectionHeaderProps) => {
|
||||
return <h2 className="h4">{children}</h2>;
|
||||
};
|
||||
|
||||
export default ModalFormSectionHeader;
|
||||
|
||||
@@ -5,7 +5,6 @@ import throttle from 'lodash/throttle';
|
||||
import AddTorrentsModal from './add-torrents-modal/AddTorrentsModal';
|
||||
import ConfirmModal from './confirm-modal/ConfirmModal';
|
||||
import connectStores from '../../util/connectStores';
|
||||
import EventTypes from '../../constants/EventTypes';
|
||||
import FeedsModal from './feeds-modal/FeedsModal';
|
||||
import MoveTorrentsModal from './move-torrents-modal/MoveTorrentsModal';
|
||||
import RemoveTorrentsModal from './remove-torrents-modal/RemoveTorrentsModal';
|
||||
@@ -77,14 +76,15 @@ class Modals extends React.Component<ModalsProps> {
|
||||
};
|
||||
|
||||
render() {
|
||||
let modal;
|
||||
const {activeModal} = this.props;
|
||||
|
||||
if (this.props.activeModal != null) {
|
||||
let modal;
|
||||
if (activeModal != null) {
|
||||
modal = (
|
||||
<CSSTransition key={this.props.activeModal.id} classNames="modal__animation" timeout={{enter: 500, exit: 500}}>
|
||||
<CSSTransition key={activeModal.id} classNames="modal__animation" timeout={{enter: 500, exit: 500}}>
|
||||
<div className="modal">
|
||||
<div className="modal__overlay" onClick={this.handleOverlayClick} />
|
||||
{createModal(this.props.activeModal)}
|
||||
{createModal(activeModal)}
|
||||
</div>
|
||||
</CSSTransition>
|
||||
);
|
||||
@@ -98,7 +98,7 @@ const ConnectedModals = connectStores(Modals, () => {
|
||||
return [
|
||||
{
|
||||
store: UIStore,
|
||||
event: EventTypes.UI_MODAL_CHANGE,
|
||||
event: 'UI_MODAL_CHANGE',
|
||||
getValue: () => {
|
||||
return {
|
||||
activeModal: UIStore.getActiveModal(),
|
||||
|
||||
@@ -4,13 +4,15 @@ import React, {PureComponent} from 'react';
|
||||
import ModalActions from '../ModalActions';
|
||||
import SettingsStore from '../../../stores/SettingsStore';
|
||||
|
||||
import type {ModalAction} from '../ModalActions';
|
||||
|
||||
interface AddTorrentsActionsProps extends WrappedComponentProps {
|
||||
isAddingTorrents: boolean;
|
||||
onAddTorrentsClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
class AddTorrentsActions extends PureComponent<AddTorrentsActionsProps> {
|
||||
getActions(): ModalActions['props']['actions'] {
|
||||
getActions(): Array<ModalAction> {
|
||||
return [
|
||||
{
|
||||
checked: Boolean(SettingsStore.getFloodSetting('startTorrentsOnLoad')),
|
||||
|
||||
@@ -67,6 +67,9 @@ class AddTorrentsByCreation extends React.Component<WrappedComponentProps, AddTo
|
||||
};
|
||||
|
||||
render() {
|
||||
const {intl} = this.props;
|
||||
const {isCreatingTorrents, trackerTextboxes} = this.state;
|
||||
|
||||
return (
|
||||
<Form
|
||||
className="inverse"
|
||||
@@ -75,27 +78,27 @@ class AddTorrentsByCreation extends React.Component<WrappedComponentProps, AddTo
|
||||
}}>
|
||||
<FilesystemBrowserTextbox
|
||||
id="sourcePath"
|
||||
label={this.props.intl.formatMessage({
|
||||
label={intl.formatMessage({
|
||||
id: 'torrents.create.source.path.label',
|
||||
})}
|
||||
/>
|
||||
<TextboxRepeater
|
||||
id="trackers"
|
||||
label={this.props.intl.formatMessage({
|
||||
label={intl.formatMessage({
|
||||
id: 'torrents.create.trackers.label',
|
||||
})}
|
||||
placeholder={this.props.intl.formatMessage({
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'torrents.create.tracker.input.placeholder',
|
||||
})}
|
||||
defaultValues={this.state.trackerTextboxes}
|
||||
defaultValues={trackerTextboxes}
|
||||
/>
|
||||
<FormRow>
|
||||
<Textbox
|
||||
id="name"
|
||||
label={this.props.intl.formatMessage({
|
||||
label={intl.formatMessage({
|
||||
id: 'torrents.create.base.name.label',
|
||||
})}
|
||||
placeholder={this.props.intl.formatMessage({
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'torrents.create.base.name.input.placeholder',
|
||||
})}
|
||||
/>
|
||||
@@ -103,10 +106,10 @@ class AddTorrentsByCreation extends React.Component<WrappedComponentProps, AddTo
|
||||
<FormRow>
|
||||
<Textbox
|
||||
id="comment"
|
||||
label={this.props.intl.formatMessage({
|
||||
label={intl.formatMessage({
|
||||
id: 'torrents.create.comment.label',
|
||||
})}
|
||||
placeholder={this.props.intl.formatMessage({
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'torrents.create.comment.input.placeholder',
|
||||
})}
|
||||
/>
|
||||
@@ -114,34 +117,31 @@ class AddTorrentsByCreation extends React.Component<WrappedComponentProps, AddTo
|
||||
<FormRow>
|
||||
<Textbox
|
||||
id="infoSource"
|
||||
label={this.props.intl.formatMessage({
|
||||
label={intl.formatMessage({
|
||||
id: 'torrents.create.info.source.label',
|
||||
})}
|
||||
placeholder={this.props.intl.formatMessage({
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'torrents.create.info.source.input.placeholder',
|
||||
})}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<Checkbox grow={false} id="isPrivate">
|
||||
{this.props.intl.formatMessage({id: 'torrents.create.is.private.label'})}
|
||||
{intl.formatMessage({id: 'torrents.create.is.private.label'})}
|
||||
</Checkbox>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<TagSelect
|
||||
id="tags"
|
||||
label={this.props.intl.formatMessage({
|
||||
label={intl.formatMessage({
|
||||
id: 'torrents.add.tags',
|
||||
})}
|
||||
placeholder={this.props.intl.formatMessage({
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'torrents.create.tags.input.placeholder',
|
||||
})}
|
||||
/>
|
||||
</FormRow>
|
||||
<AddTorrentsActions
|
||||
onAddTorrentsClick={this.handleAddTorrents}
|
||||
isAddingTorrents={this.state.isCreatingTorrents}
|
||||
/>
|
||||
<AddTorrentsActions onAddTorrentsClick={this.handleAddTorrents} isAddingTorrents={isCreatingTorrents} />
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,33 +41,30 @@ class AddTorrentsByFile extends React.Component<WrappedComponentProps, AddTorren
|
||||
}
|
||||
|
||||
getFileDropzone() {
|
||||
let fileContent = null;
|
||||
const {files} = this.state;
|
||||
|
||||
if (this.state.files.length > 0) {
|
||||
const files = this.state.files.map((file, index) => (
|
||||
<li className="dropzone__selected-files__file interactive-list__item" key={file.name} title={file.name}>
|
||||
<span className="interactive-list__icon">
|
||||
<FileIcon />
|
||||
</span>
|
||||
<span className="interactive-list__label">{file.name}</span>
|
||||
<span
|
||||
className="interactive-list__icon interactive-list__icon--action interactive-list__icon--action--warning"
|
||||
onClick={() => this.handleFileRemove(index)}>
|
||||
<CloseIcon />
|
||||
</span>
|
||||
</li>
|
||||
));
|
||||
|
||||
fileContent = (
|
||||
const fileContent =
|
||||
files.length > 0 ? (
|
||||
<ul
|
||||
className="dropzone__selected-files interactive-list"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}>
|
||||
{files}
|
||||
{files.map((file, index) => (
|
||||
<li className="dropzone__selected-files__file interactive-list__item" key={file.name} title={file.name}>
|
||||
<span className="interactive-list__icon">
|
||||
<FileIcon />
|
||||
</span>
|
||||
<span className="interactive-list__label">{file.name}</span>
|
||||
<span
|
||||
className="interactive-list__icon interactive-list__icon--action interactive-list__icon--action--warning"
|
||||
onClick={() => this.handleFileRemove(index)}>
|
||||
<CloseIcon />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<FormRowItem>
|
||||
@@ -164,6 +161,9 @@ class AddTorrentsByFile extends React.Component<WrappedComponentProps, AddTorren
|
||||
};
|
||||
|
||||
render() {
|
||||
const {intl} = this.props;
|
||||
const {isAddingTorrents} = this.state;
|
||||
|
||||
return (
|
||||
<Form
|
||||
className="inverse"
|
||||
@@ -173,24 +173,21 @@ class AddTorrentsByFile extends React.Component<WrappedComponentProps, AddTorren
|
||||
<FormRow>{this.getFileDropzone()}</FormRow>
|
||||
<FilesystemBrowserTextbox
|
||||
id="destination"
|
||||
label={this.props.intl.formatMessage({
|
||||
label={intl.formatMessage({
|
||||
id: 'torrents.add.destination.label',
|
||||
})}
|
||||
selectable="directories"
|
||||
basePathToggle
|
||||
showBasePathToggle
|
||||
/>
|
||||
<FormRow>
|
||||
<TagSelect
|
||||
label={this.props.intl.formatMessage({
|
||||
label={intl.formatMessage({
|
||||
id: 'torrents.add.tags',
|
||||
})}
|
||||
id="tags"
|
||||
/>
|
||||
</FormRow>
|
||||
<AddTorrentsActions
|
||||
onAddTorrentsClick={this.handleAddTorrents}
|
||||
isAddingTorrents={this.state.isAddingTorrents}
|
||||
/>
|
||||
<AddTorrentsActions onAddTorrentsClick={this.handleAddTorrents} isAddingTorrents={isAddingTorrents} />
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ class AddTorrentsByURL extends React.Component<AddTorrentsByURLProps, AddTorrent
|
||||
id: 'torrents.add.destination.label',
|
||||
})}
|
||||
selectable="directories"
|
||||
basePathToggle
|
||||
showBasePathToggle
|
||||
/>
|
||||
<FormRow>
|
||||
<TagSelect
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {injectIntl, WrappedComponentProps} from 'react-intl';
|
||||
import {useIntl} from 'react-intl';
|
||||
import React from 'react';
|
||||
|
||||
import AddTorrentsByFile from './AddTorrentsByFile';
|
||||
@@ -6,43 +6,44 @@ import AddTorrentsByURL from './AddTorrentsByURL';
|
||||
import Modal from '../Modal';
|
||||
import AddTorrentsByCreation from './AddTorrentsByCreation';
|
||||
|
||||
export interface AddTorrentsModalProps extends WrappedComponentProps {
|
||||
export interface AddTorrentsModalProps {
|
||||
initialURLs?: Array<{id: number; value: string}>;
|
||||
}
|
||||
|
||||
class AddTorrentsModal extends React.Component<AddTorrentsModalProps> {
|
||||
render() {
|
||||
const tabs = {
|
||||
'by-url': {
|
||||
content: AddTorrentsByURL,
|
||||
label: this.props.intl.formatMessage({
|
||||
id: 'torrents.add.tab.url.title',
|
||||
}),
|
||||
props: this.props,
|
||||
},
|
||||
'by-file': {
|
||||
content: AddTorrentsByFile,
|
||||
label: this.props.intl.formatMessage({
|
||||
id: 'torrents.add.tab.file.title',
|
||||
}),
|
||||
},
|
||||
'by-creation': {
|
||||
content: AddTorrentsByCreation,
|
||||
label: this.props.intl.formatMessage({
|
||||
id: 'torrents.add.tab.create.title',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const AddTorrentsModal: React.FC<AddTorrentsModalProps> = (props: AddTorrentsModalProps) => {
|
||||
const {initialURLs} = props;
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
heading={this.props.intl.formatMessage({
|
||||
id: 'torrents.add.heading',
|
||||
})}
|
||||
tabs={tabs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
const tabs = {
|
||||
'by-url': {
|
||||
content: AddTorrentsByURL,
|
||||
label: intl.formatMessage({
|
||||
id: 'torrents.add.tab.url.title',
|
||||
}),
|
||||
props: {initialURLs},
|
||||
},
|
||||
'by-file': {
|
||||
content: AddTorrentsByFile,
|
||||
label: intl.formatMessage({
|
||||
id: 'torrents.add.tab.file.title',
|
||||
}),
|
||||
},
|
||||
'by-creation': {
|
||||
content: AddTorrentsByCreation,
|
||||
label: intl.formatMessage({
|
||||
id: 'torrents.add.tab.create.title',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default injectIntl(AddTorrentsModal);
|
||||
return (
|
||||
<Modal
|
||||
heading={intl.formatMessage({
|
||||
id: 'torrents.add.heading',
|
||||
})}
|
||||
tabs={tabs}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddTorrentsModal;
|
||||
|
||||
@@ -2,11 +2,13 @@ import React from 'react';
|
||||
|
||||
import Modal from '../Modal';
|
||||
|
||||
import type {ModalAction} from '../ModalActions';
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
options: {
|
||||
content: React.ReactNode;
|
||||
heading: React.ReactNode;
|
||||
actions: Modal['props']['actions'];
|
||||
actions: Array<ModalAction>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -141,18 +141,6 @@ class DownloadRulesTab extends React.Component<DownloadRulesTabProps, DownloadRu
|
||||
};
|
||||
}
|
||||
|
||||
checkMatchingPattern(match: RuleFormData['match'], exclude: RuleFormData['exclude'], check: RuleFormData['check']) {
|
||||
let doesPatternMatchTest = false;
|
||||
|
||||
if (validators.isNotEmpty(check) && validators.isRegExValid(match) && validators.isRegExValid(exclude)) {
|
||||
const isMatched = new RegExp(match, 'gi').test(check);
|
||||
const isExcluded = exclude !== '' && new RegExp(exclude, 'gi').test(check);
|
||||
doesPatternMatchTest = isMatched && !isExcluded;
|
||||
}
|
||||
|
||||
this.setState({doesPatternMatchTest});
|
||||
}
|
||||
|
||||
getAmendedFormData(): Rule | null {
|
||||
if (this.formRef == null) {
|
||||
return null;
|
||||
@@ -274,7 +262,7 @@ class DownloadRulesTab extends React.Component<DownloadRulesTabProps, DownloadRu
|
||||
})}
|
||||
selectable="directories"
|
||||
suggested={rule.destination}
|
||||
basePathToggle
|
||||
showBasePathToggle
|
||||
/>
|
||||
</FormRowItem>
|
||||
<TagSelect
|
||||
@@ -287,7 +275,7 @@ class DownloadRulesTab extends React.Component<DownloadRulesTabProps, DownloadRu
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormRowItem width="auto" />
|
||||
<br />
|
||||
<Checkbox id="startOnLoad" checked={rule.startOnLoad} matchTextboxHeight>
|
||||
<FormattedMessage id="feeds.start.on.load" />
|
||||
</Checkbox>
|
||||
@@ -312,7 +300,8 @@ class DownloadRulesTab extends React.Component<DownloadRulesTabProps, DownloadRu
|
||||
<li
|
||||
className="interactive-list__detail-list__item
|
||||
interactive-list__detail interactive-list__detail--tertiary">
|
||||
<FormattedMessage id="feeds.exclude" /> {rule.exclude}
|
||||
<FormattedMessage id="feeds.exclude" />
|
||||
{rule.exclude}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -326,7 +315,8 @@ class DownloadRulesTab extends React.Component<DownloadRulesTabProps, DownloadRu
|
||||
|
||||
tags = (
|
||||
<li className="interactive-list__detail-list__item interactive-list__detail interactive-list__detail--tertiary">
|
||||
<FormattedMessage id="feeds.tags" /> {tagNodes}
|
||||
<FormattedMessage id="feeds.tags" />
|
||||
{tagNodes}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -358,7 +348,8 @@ class DownloadRulesTab extends React.Component<DownloadRulesTabProps, DownloadRu
|
||||
<li
|
||||
className="interactive-list__detail-list__item
|
||||
interactive-list__detail interactive-list__detail--tertiary">
|
||||
<FormattedMessage id="feeds.match" /> {rule.match}
|
||||
<FormattedMessage id="feeds.match" />
|
||||
{rule.match}
|
||||
</li>
|
||||
{excludeNode}
|
||||
{tags}
|
||||
@@ -437,6 +428,10 @@ class DownloadRulesTab extends React.Component<DownloadRulesTabProps, DownloadRu
|
||||
}
|
||||
};
|
||||
|
||||
handleAddRuleClick = () => {
|
||||
this.setState({currentlyEditingRule: defaultRule});
|
||||
};
|
||||
|
||||
handleRemoveRuleClick(rule: Rule) {
|
||||
if (rule._id != null) {
|
||||
FeedsStoreClass.removeRule(rule._id);
|
||||
@@ -447,14 +442,22 @@ class DownloadRulesTab extends React.Component<DownloadRulesTabProps, DownloadRu
|
||||
}
|
||||
}
|
||||
|
||||
handleAddRuleClick = () => {
|
||||
this.setState({currentlyEditingRule: defaultRule});
|
||||
};
|
||||
|
||||
handleModifyRuleClick(rule: Rule) {
|
||||
this.setState({currentlyEditingRule: rule});
|
||||
}
|
||||
|
||||
checkMatchingPattern(match: RuleFormData['match'], exclude: RuleFormData['exclude'], check: RuleFormData['check']) {
|
||||
let doesPatternMatchTest = false;
|
||||
|
||||
if (validators.isNotEmpty(check) && validators.isRegExValid(match) && validators.isRegExValid(exclude)) {
|
||||
const isMatched = new RegExp(match, 'gi').test(check);
|
||||
const isExcluded = exclude !== '' && new RegExp(exclude, 'gi').test(check);
|
||||
doesPatternMatchTest = isMatched && !isExcluded;
|
||||
}
|
||||
|
||||
this.setState({doesPatternMatchTest});
|
||||
}
|
||||
|
||||
validateForm(): {errors?: DownloadRulesTabStates['errors']; isValid: boolean} {
|
||||
const formData = this.getAmendedFormData();
|
||||
|
||||
@@ -515,7 +518,7 @@ class DownloadRulesTab extends React.Component<DownloadRulesTabProps, DownloadRu
|
||||
this.getModifyRuleForm(this.state.currentlyEditingRule)
|
||||
) : (
|
||||
<FormRow>
|
||||
<FormRowItem width="auto" />
|
||||
<br />
|
||||
<Button onClick={this.handleAddRuleClick}>
|
||||
<FormattedMessage id="button.new" />
|
||||
</Button>
|
||||
|
||||
@@ -330,7 +330,7 @@ class FeedsTab extends React.Component<FeedsTabProps, FeedsTabStates> {
|
||||
this.getModifyFeedForm(this.state.currentlyEditingFeed)
|
||||
) : (
|
||||
<FormRow>
|
||||
<FormRowItem width="auto" />
|
||||
<FormRowItem width="auto">{null}</FormRowItem>
|
||||
<Button onClick={() => this.handleAddFeedClick()}>
|
||||
<FormattedMessage id="button.new" />
|
||||
</Button>
|
||||
|
||||
@@ -10,6 +10,8 @@ import ModalActions from '../ModalActions';
|
||||
import TorrentActions from '../../../actions/TorrentActions';
|
||||
import TorrentStore from '../../../stores/TorrentStore';
|
||||
|
||||
import type {ModalAction} from '../ModalActions';
|
||||
|
||||
interface MoveTorrentsStates {
|
||||
isSettingDownloadPath: boolean;
|
||||
originalSource?: string;
|
||||
@@ -53,7 +55,7 @@ class MoveTorrents extends React.Component<WrappedComponentProps, MoveTorrentsSt
|
||||
};
|
||||
}
|
||||
|
||||
getActions(): ModalActions['props']['actions'] {
|
||||
getActions(): Array<ModalAction> {
|
||||
return [
|
||||
{
|
||||
checked: true,
|
||||
@@ -101,7 +103,7 @@ class MoveTorrents extends React.Component<WrappedComponentProps, MoveTorrentsSt
|
||||
id="destination"
|
||||
selectable="directories"
|
||||
suggested={this.state.originalSource}
|
||||
basePathToggle
|
||||
showBasePathToggle
|
||||
/>
|
||||
<ModalActions actions={this.getActions()} />
|
||||
</Form>
|
||||
|
||||
@@ -7,10 +7,12 @@ import SettingsStore from '../../../stores/SettingsStore';
|
||||
import TorrentActions from '../../../actions/TorrentActions';
|
||||
import TorrentStore from '../../../stores/TorrentStore';
|
||||
|
||||
import type {ModalAction} from '../ModalActions';
|
||||
|
||||
class RemoveTorrentsModal extends React.Component<WrappedComponentProps> {
|
||||
formRef?: Form | null;
|
||||
|
||||
getActions(torrentCount: number): Modal['props']['actions'] {
|
||||
getActions(torrentCount: number): Array<ModalAction> {
|
||||
if (torrentCount === 0) {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -7,6 +7,8 @@ import TagSelect from '../../general/form-elements/TagSelect';
|
||||
import TorrentActions from '../../../actions/TorrentActions';
|
||||
import TorrentStore from '../../../stores/TorrentStore';
|
||||
|
||||
import type {ModalAction} from '../ModalActions';
|
||||
|
||||
interface SetTagsModalStates {
|
||||
isSettingTags: boolean;
|
||||
}
|
||||
@@ -21,20 +23,7 @@ class SetTagsModal extends React.Component<WrappedComponentProps, SetTagsModalSt
|
||||
};
|
||||
}
|
||||
|
||||
handleSetTagsClick = () => {
|
||||
if (this.formRef == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = this.formRef.getFormData() as {tags: string};
|
||||
const tags = formData.tags ? formData.tags.split(',') : [];
|
||||
|
||||
this.setState({isSettingTags: true}, () =>
|
||||
TorrentActions.setTags({hashes: TorrentStore.getSelectedTorrents(), tags}),
|
||||
);
|
||||
};
|
||||
|
||||
getActions(): Modal['props']['actions'] {
|
||||
getActions(): Array<ModalAction> {
|
||||
const primaryButtonText = this.props.intl.formatMessage({
|
||||
id: 'torrents.set.tags.button.set',
|
||||
});
|
||||
@@ -79,6 +68,19 @@ class SetTagsModal extends React.Component<WrappedComponentProps, SetTagsModalSt
|
||||
);
|
||||
}
|
||||
|
||||
handleSetTagsClick = () => {
|
||||
if (this.formRef == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = this.formRef.getFormData() as {tags: string};
|
||||
const tags = formData.tags ? formData.tags.split(',') : [];
|
||||
|
||||
this.setState({isSettingTags: true}, () =>
|
||||
TorrentActions.setTags({hashes: TorrentStore.getSelectedTorrents(), tags}),
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal
|
||||
|
||||
@@ -6,6 +6,8 @@ import Modal from '../Modal';
|
||||
import TorrentActions from '../../../actions/TorrentActions';
|
||||
import TorrentStore from '../../../stores/TorrentStore';
|
||||
|
||||
import type {ModalAction} from '../ModalActions';
|
||||
|
||||
interface SetTrackerModalStates {
|
||||
isSettingTracker: boolean;
|
||||
}
|
||||
@@ -20,20 +22,7 @@ class SetTrackerModal extends React.Component<WrappedComponentProps, SetTrackerM
|
||||
};
|
||||
}
|
||||
|
||||
handleSetTrackerClick = (): void => {
|
||||
if (this.formRef == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = this.formRef.getFormData() as {tracker: string};
|
||||
const {tracker} = formData;
|
||||
|
||||
this.setState({isSettingTracker: true}, () =>
|
||||
TorrentActions.setTracker(TorrentStore.getSelectedTorrents(), tracker),
|
||||
);
|
||||
};
|
||||
|
||||
getActions(): Modal['props']['actions'] {
|
||||
getActions(): Array<ModalAction> {
|
||||
const primaryButtonText = this.props.intl.formatMessage({
|
||||
id: 'torrents.set.tracker.button.set',
|
||||
});
|
||||
@@ -80,6 +69,19 @@ class SetTrackerModal extends React.Component<WrappedComponentProps, SetTrackerM
|
||||
);
|
||||
}
|
||||
|
||||
handleSetTrackerClick = (): void => {
|
||||
if (this.formRef == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = this.formRef.getFormData() as {tracker: string};
|
||||
const {tracker} = formData;
|
||||
|
||||
this.setState({isSettingTracker: true}, () =>
|
||||
TorrentActions.setTracker(TorrentStore.getSelectedTorrents(), tracker),
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal
|
||||
|
||||
@@ -11,7 +11,7 @@ interface DiskUsageTabProps {
|
||||
onSettingsChange: (changedSettings: Partial<FloodSettings>) => void;
|
||||
}
|
||||
|
||||
const DiskUsageTab: React.FunctionComponent<DiskUsageTabProps> = (props) => {
|
||||
const DiskUsageTab: React.FC<DiskUsageTabProps> = (props: DiskUsageTabProps) => {
|
||||
return (
|
||||
<Form>
|
||||
<ModalFormSectionHeader>
|
||||
|
||||
@@ -16,6 +16,8 @@ import SettingsStore from '../../../stores/SettingsStore';
|
||||
import UITab from './UITab';
|
||||
import DiskUsageTab from './DiskUsageTab';
|
||||
|
||||
import type {ModalAction} from '../ModalActions';
|
||||
|
||||
interface SettingsModalProps extends WrappedComponentProps {
|
||||
clientSettings?: ClientSettings | null;
|
||||
floodSettings?: FloodSettings | null;
|
||||
@@ -37,7 +39,7 @@ class SettingsModal extends React.Component<SettingsModalProps, SettingsModalSta
|
||||
};
|
||||
}
|
||||
|
||||
getActions(): Modal['props']['actions'] {
|
||||
getActions(): Array<ModalAction> {
|
||||
return [
|
||||
{
|
||||
clickHandler: null,
|
||||
|
||||
@@ -8,8 +8,10 @@ import Languages from '../../../constants/Languages';
|
||||
import ModalFormSectionHeader from '../ModalFormSectionHeader';
|
||||
import SettingsStore from '../../../stores/SettingsStore';
|
||||
import SettingsTab from './SettingsTab';
|
||||
import TorrentContextMenuItemsList from './lists/TorrentContextMenuItemsList';
|
||||
import TorrentDetailItemsList from './lists/TorrentDetailItemsList';
|
||||
import TorrentContextMenuActionsList from './lists/TorrentContextMenuActionsList';
|
||||
import TorrentListColumnsList from './lists/TorrentListColumnsList';
|
||||
|
||||
import type {Language} from '../../../constants/Languages';
|
||||
|
||||
class UITab extends SettingsTab {
|
||||
torrentListViewSize = SettingsStore.getFloodSetting('torrentListViewSize');
|
||||
@@ -29,7 +31,7 @@ class UITab extends SettingsTab {
|
||||
|
||||
return (
|
||||
<SelectItem key={languageID} id={languageID}>
|
||||
{Languages[languageID as keyof typeof Languages]}
|
||||
{Languages[languageID as Language]}
|
||||
</SelectItem>
|
||||
);
|
||||
});
|
||||
@@ -88,7 +90,7 @@ class UITab extends SettingsTab {
|
||||
<FormattedMessage id="settings.ui.displayed.details" />
|
||||
</ModalFormSectionHeader>
|
||||
<FormRow>
|
||||
<TorrentDetailItemsList
|
||||
<TorrentListColumnsList
|
||||
torrentListViewSize={this.torrentListViewSize}
|
||||
onSettingsChange={this.props.onSettingsChange}
|
||||
/>
|
||||
@@ -97,7 +99,7 @@ class UITab extends SettingsTab {
|
||||
<FormattedMessage id="settings.ui.displayed.context.menu.items" />
|
||||
</ModalFormSectionHeader>
|
||||
<FormRow>
|
||||
<TorrentContextMenuItemsList onSettingsChange={this.props.onSettingsChange} />
|
||||
<TorrentContextMenuActionsList onSettingsChange={this.props.onSettingsChange} />
|
||||
</FormRow>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -8,48 +8,50 @@ import ErrorIcon from '../../../icons/ErrorIcon';
|
||||
import SettingsStore from '../../../../stores/SettingsStore';
|
||||
import SortableList, {ListItem} from '../../../general/SortableList';
|
||||
import Tooltip from '../../../general/Tooltip';
|
||||
import TorrentContextMenuItems from '../../../../constants/TorrentContextMenuItems';
|
||||
import TorrentContextMenuActions from '../../../../constants/TorrentContextMenuActions';
|
||||
|
||||
interface TorrentContextMenuItemsListProps {
|
||||
import type {TorrentContextMenuAction} from '../../../../constants/TorrentContextMenuActions';
|
||||
|
||||
interface TorrentContextMenuActionsListProps {
|
||||
onSettingsChange: (changedSettings: Partial<FloodSettings>) => void;
|
||||
}
|
||||
|
||||
interface TorrentContextMenuItemsListStates {
|
||||
torrentContextMenuItems: FloodSettings['torrentContextMenuItems'];
|
||||
interface TorrentContextMenuActionsListStates {
|
||||
torrentContextMenuActions: FloodSettings['torrentContextMenuActions'];
|
||||
}
|
||||
|
||||
const lockedIDs = ['start', 'stop', 'set-taxonomy', 'torrent-details'];
|
||||
const lockedIDs: Array<TorrentContextMenuAction> = ['start', 'stop', 'setTaxonomy', 'torrentDetails'];
|
||||
|
||||
class TorrentContextMenuItemsList extends React.Component<
|
||||
TorrentContextMenuItemsListProps,
|
||||
TorrentContextMenuItemsListStates
|
||||
class TorrentContextMenuActionsList extends React.Component<
|
||||
TorrentContextMenuActionsListProps,
|
||||
TorrentContextMenuActionsListStates
|
||||
> {
|
||||
tooltipRef: Tooltip | null = null;
|
||||
|
||||
constructor(props: TorrentContextMenuItemsListProps) {
|
||||
constructor(props: TorrentContextMenuActionsListProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
torrentContextMenuItems: SettingsStore.getFloodSetting('torrentContextMenuItems'),
|
||||
torrentContextMenuActions: SettingsStore.getFloodSetting('torrentContextMenuActions'),
|
||||
};
|
||||
}
|
||||
|
||||
updateSettings = (torrentContextMenuItems: FloodSettings['torrentContextMenuItems']) => {
|
||||
this.props.onSettingsChange({torrentContextMenuItems});
|
||||
updateSettings = (torrentContextMenuActions: FloodSettings['torrentContextMenuActions']) => {
|
||||
this.props.onSettingsChange({torrentContextMenuActions});
|
||||
};
|
||||
|
||||
handleCheckboxValueChange = (id: string, value: boolean) => {
|
||||
let {torrentContextMenuItems} = this.state;
|
||||
let {torrentContextMenuActions} = this.state;
|
||||
|
||||
torrentContextMenuItems = torrentContextMenuItems.map((setting) => {
|
||||
torrentContextMenuActions = torrentContextMenuActions.map((setting) => {
|
||||
return {
|
||||
id: setting.id,
|
||||
visible: setting.id === id ? value : setting.visible,
|
||||
};
|
||||
});
|
||||
|
||||
this.props.onSettingsChange({torrentContextMenuItems});
|
||||
this.setState({torrentContextMenuItems});
|
||||
this.props.onSettingsChange({torrentContextMenuActions});
|
||||
this.setState({torrentContextMenuActions});
|
||||
};
|
||||
|
||||
handleMouseDown = () => {
|
||||
@@ -63,7 +65,7 @@ class TorrentContextMenuItemsList extends React.Component<
|
||||
};
|
||||
|
||||
renderItem = (item: ListItem) => {
|
||||
const {id, visible} = item as FloodSettings['torrentContextMenuItems'][number];
|
||||
const {id, visible} = item as FloodSettings['torrentContextMenuActions'][number];
|
||||
let checkbox = null;
|
||||
let warning = null;
|
||||
|
||||
@@ -79,8 +81,8 @@ class TorrentContextMenuItemsList extends React.Component<
|
||||
);
|
||||
}
|
||||
|
||||
if (id === 'set-tracker') {
|
||||
const tooltipContent = <FormattedMessage id={TorrentContextMenuItems[id].warning} />;
|
||||
if (id === 'setTracker') {
|
||||
const tooltipContent = <FormattedMessage id={TorrentContextMenuActions[id].warning} />;
|
||||
|
||||
warning = (
|
||||
<Tooltip
|
||||
@@ -102,7 +104,7 @@ class TorrentContextMenuItemsList extends React.Component<
|
||||
<div className="sortable-list__content sortable-list__content__wrapper">
|
||||
{warning}
|
||||
<span className="sortable-list__content sortable-list__content--primary">
|
||||
<FormattedMessage id={TorrentContextMenuItems[id].id} />
|
||||
<FormattedMessage id={TorrentContextMenuActions[id].id} />
|
||||
</span>
|
||||
{checkbox}
|
||||
</div>
|
||||
@@ -112,13 +114,13 @@ class TorrentContextMenuItemsList extends React.Component<
|
||||
};
|
||||
|
||||
render() {
|
||||
const {torrentContextMenuItems} = this.state;
|
||||
const {torrentContextMenuActions} = this.state;
|
||||
|
||||
return (
|
||||
<SortableList
|
||||
id="torrent-context-menu-items"
|
||||
className="sortable-list--torrent-context-menu-items"
|
||||
items={torrentContextMenuItems}
|
||||
items={torrentContextMenuActions}
|
||||
lockedIDs={lockedIDs}
|
||||
isDraggable={false}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
@@ -129,4 +131,4 @@ class TorrentContextMenuItemsList extends React.Component<
|
||||
}
|
||||
}
|
||||
|
||||
export default TorrentContextMenuItemsList;
|
||||
export default TorrentContextMenuActionsList;
|
||||
@@ -8,48 +8,50 @@ import ErrorIcon from '../../../icons/ErrorIcon';
|
||||
import SettingsStore from '../../../../stores/SettingsStore';
|
||||
import SortableList, {ListItem} from '../../../general/SortableList';
|
||||
import Tooltip from '../../../general/Tooltip';
|
||||
import TorrentProperties from '../../../../constants/TorrentProperties';
|
||||
import TorrentListColumns from '../../../../constants/TorrentListColumns';
|
||||
|
||||
interface TorrentDetailItemsListProps {
|
||||
import type {TorrentListColumn} from '../../../../constants/TorrentListColumns';
|
||||
|
||||
interface TorrentListColumnsListProps {
|
||||
torrentListViewSize: FloodSettings['torrentListViewSize'];
|
||||
onSettingsChange: (changedSettings: Partial<FloodSettings>) => void;
|
||||
}
|
||||
|
||||
interface TorrentDetailItemsListStates {
|
||||
torrentDetails: FloodSettings['torrentDetails'];
|
||||
interface TorrentListColumnsListStates {
|
||||
torrentListColumns: FloodSettings['torrentListColumns'];
|
||||
}
|
||||
|
||||
class TorrentDetailItemsList extends React.Component<TorrentDetailItemsListProps, TorrentDetailItemsListStates> {
|
||||
class TorrentListColumnsList extends React.Component<TorrentListColumnsListProps, TorrentListColumnsListStates> {
|
||||
tooltipRef: Tooltip | null = null;
|
||||
|
||||
constructor(props: TorrentDetailItemsListProps) {
|
||||
constructor(props: TorrentListColumnsListProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
torrentDetails: SettingsStore.getFloodSetting('torrentDetails'),
|
||||
torrentListColumns: SettingsStore.getFloodSetting('torrentListColumns'),
|
||||
};
|
||||
}
|
||||
|
||||
getLockedIDs(): Array<keyof typeof TorrentProperties> {
|
||||
getLockedIDs(): Array<TorrentListColumn> {
|
||||
if (this.props.torrentListViewSize === 'expanded') {
|
||||
return ['name', 'eta', 'downRate', 'upRate'];
|
||||
return ['name', 'eta', 'downRate', 'percentComplete', 'downTotal', 'upRate'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
handleCheckboxValueChange = (id: string, value: boolean): void => {
|
||||
let {torrentDetails} = this.state;
|
||||
const {torrentListColumns} = this.state;
|
||||
|
||||
torrentDetails = torrentDetails.map((detail) => {
|
||||
const changedTorrentListColumns = torrentListColumns.map((column) => {
|
||||
return {
|
||||
id: detail.id,
|
||||
visible: detail.id === id ? value : detail.visible,
|
||||
id: column.id,
|
||||
visible: column.id === id ? value : column.visible,
|
||||
};
|
||||
});
|
||||
|
||||
this.props.onSettingsChange({torrentDetails});
|
||||
this.setState({torrentDetails});
|
||||
this.props.onSettingsChange({torrentListColumns: changedTorrentListColumns});
|
||||
this.setState({torrentListColumns: changedTorrentListColumns});
|
||||
};
|
||||
|
||||
handleMouseDown = (): void => {
|
||||
@@ -59,12 +61,13 @@ class TorrentDetailItemsList extends React.Component<TorrentDetailItemsListProps
|
||||
};
|
||||
|
||||
handleMove = (items: Array<ListItem>): void => {
|
||||
this.setState({torrentDetails: items as FloodSettings['torrentDetails']});
|
||||
this.props.onSettingsChange({torrentDetails: items as FloodSettings['torrentDetails']});
|
||||
const changedItems = items.slice() as FloodSettings['torrentListColumns'];
|
||||
this.setState({torrentListColumns: changedItems});
|
||||
this.props.onSettingsChange({torrentListColumns: changedItems});
|
||||
};
|
||||
|
||||
renderItem = (item: ListItem, index: number): React.ReactNode => {
|
||||
const {id, visible} = item as FloodSettings['torrentDetails'][number];
|
||||
const {id, visible} = item as FloodSettings['torrentListColumns'][number];
|
||||
let checkbox = null;
|
||||
let warning = null;
|
||||
|
||||
@@ -83,7 +86,7 @@ class TorrentDetailItemsList extends React.Component<TorrentDetailItemsListProps
|
||||
if (
|
||||
id === 'tags' &&
|
||||
this.props.torrentListViewSize === 'expanded' &&
|
||||
index < this.state.torrentDetails.length - 1
|
||||
index < this.state.torrentListColumns.length - 1
|
||||
) {
|
||||
const tooltipContent = <FormattedMessage id="settings.ui.torrent.details.tags.placement" />;
|
||||
|
||||
@@ -107,7 +110,7 @@ class TorrentDetailItemsList extends React.Component<TorrentDetailItemsListProps
|
||||
<div className="sortable-list__content sortable-list__content__wrapper">
|
||||
{warning}
|
||||
<span className="sortable-list__content sortable-list__content--primary">
|
||||
<FormattedMessage id={TorrentProperties[id].id} />
|
||||
<FormattedMessage id={TorrentListColumns[id].id} />
|
||||
</span>
|
||||
{checkbox}
|
||||
</div>
|
||||
@@ -118,34 +121,31 @@ class TorrentDetailItemsList extends React.Component<TorrentDetailItemsListProps
|
||||
|
||||
render(): React.ReactNode {
|
||||
const lockedIDs = this.getLockedIDs();
|
||||
let torrentDetailItems = this.state.torrentDetails
|
||||
.slice()
|
||||
.filter((property) => Object.prototype.hasOwnProperty.call(TorrentProperties, property.id));
|
||||
let nextUnlockedIndex = lockedIDs.length;
|
||||
|
||||
if (this.props.torrentListViewSize === 'expanded') {
|
||||
let nextUnlockedIndex = lockedIDs.length;
|
||||
const torrentListColumnItems =
|
||||
this.props.torrentListViewSize === 'expanded'
|
||||
? this.state.torrentListColumns
|
||||
.reduce((accumulator: FloodSettings['torrentListColumns'], column) => {
|
||||
const lockedIDIndex = lockedIDs.indexOf(column.id);
|
||||
|
||||
torrentDetailItems = torrentDetailItems
|
||||
.reduce((accumulator: FloodSettings['torrentDetails'], detail) => {
|
||||
const lockedIDIndex = lockedIDs.indexOf(detail.id);
|
||||
if (lockedIDIndex > -1) {
|
||||
accumulator[lockedIDIndex] = column;
|
||||
} else {
|
||||
accumulator[nextUnlockedIndex] = column;
|
||||
nextUnlockedIndex += 1;
|
||||
}
|
||||
|
||||
if (lockedIDIndex > -1) {
|
||||
accumulator[lockedIDIndex] = detail;
|
||||
} else {
|
||||
accumulator[nextUnlockedIndex] = detail;
|
||||
nextUnlockedIndex += 1;
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
}, [])
|
||||
.filter((item) => item != null);
|
||||
}
|
||||
return accumulator;
|
||||
}, [])
|
||||
.filter((column) => column != null)
|
||||
: this.state.torrentListColumns;
|
||||
|
||||
return (
|
||||
<SortableList
|
||||
id="torrent-details"
|
||||
className="sortable-list--torrent-details"
|
||||
items={torrentDetailItems}
|
||||
items={torrentListColumnItems}
|
||||
lockedIDs={lockedIDs}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onDrop={this.handleMove}
|
||||
@@ -155,4 +155,4 @@ class TorrentDetailItemsList extends React.Component<TorrentDetailItemsListProps
|
||||
}
|
||||
}
|
||||
|
||||
export default TorrentDetailItemsList;
|
||||
export default TorrentListColumnsList;
|
||||
@@ -5,7 +5,6 @@ import type {TorrentDetails, TorrentProperties} from '@shared/types/Torrent';
|
||||
|
||||
import connectStores from '../../../util/connectStores';
|
||||
import Modal from '../Modal';
|
||||
import EventTypes from '../../../constants/EventTypes';
|
||||
import TorrentMediainfo from './TorrentMediainfo';
|
||||
import TorrentFiles from './TorrentFiles';
|
||||
import TorrentGeneralInfo from './TorrentGeneralInfo';
|
||||
@@ -100,7 +99,7 @@ const ConnectedTorrentDetailsModal = connectStores<Omit<TorrentDetailsModalProps
|
||||
return [
|
||||
{
|
||||
store: TorrentStore,
|
||||
event: EventTypes.CLIENT_TORRENT_DETAILS_CHANGE,
|
||||
event: 'CLIENT_TORRENT_DETAILS_CHANGE',
|
||||
getValue: ({props}) => {
|
||||
return {
|
||||
torrentDetails: TorrentStore.getTorrentDetails(props.options.hash),
|
||||
@@ -109,7 +108,7 @@ const ConnectedTorrentDetailsModal = connectStores<Omit<TorrentDetailsModalProps
|
||||
},
|
||||
{
|
||||
store: TorrentStore,
|
||||
event: EventTypes.CLIENT_TORRENTS_REQUEST_SUCCESS,
|
||||
event: 'CLIENT_TORRENTS_REQUEST_SUCCESS',
|
||||
getValue: ({props}) => {
|
||||
return {
|
||||
torrent: TorrentStore.getTorrent(props.options.hash),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {FormattedMessage, FormattedNumber, injectIntl, WrappedComponentProps} from 'react-intl';
|
||||
import {FormattedMessage, FormattedNumber, useIntl} from 'react-intl';
|
||||
import React from 'react';
|
||||
|
||||
import type {TorrentProperties} from '@shared/types/Torrent';
|
||||
|
||||
import Size from '../../general/Size';
|
||||
|
||||
interface TorrentGeneralInfoProps extends WrappedComponentProps {
|
||||
interface TorrentGeneralInfoProps {
|
||||
torrent: TorrentProperties;
|
||||
}
|
||||
|
||||
@@ -17,172 +17,170 @@ const getTags = (tags: TorrentProperties['tags']) => {
|
||||
));
|
||||
};
|
||||
|
||||
class TorrentGeneralInfo extends React.Component<TorrentGeneralInfoProps> {
|
||||
render() {
|
||||
const {torrent} = this.props;
|
||||
const TorrentGeneralInfo: React.FC<TorrentGeneralInfoProps> = ({torrent}: TorrentGeneralInfoProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
let dateAdded = null;
|
||||
if (torrent.dateAdded) {
|
||||
dateAdded = new Date(torrent.dateAdded * 1000);
|
||||
}
|
||||
|
||||
let creation = null;
|
||||
if (torrent.dateCreated) {
|
||||
creation = new Date(torrent.dateCreated * 1000);
|
||||
}
|
||||
|
||||
const VALUE_NOT_AVAILABLE = (
|
||||
<span className="not-available">
|
||||
<FormattedMessage id="torrents.details.general.none" />
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="torrent-details__section torrent-details__section--general">
|
||||
<table className="torrent-details__table table">
|
||||
<tbody>
|
||||
<tr className="torrent-details__table__heading">
|
||||
<td className="torrent-details__table__heading--tertiary" colSpan={2}>
|
||||
<FormattedMessage id="torrents.details.general.heading.general" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--dateAdded">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.date.added" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
{dateAdded
|
||||
? `${this.props.intl.formatDate(dateAdded, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: '2-digit',
|
||||
})} ${this.props.intl.formatTime(dateAdded)}`
|
||||
: VALUE_NOT_AVAILABLE}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--location">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.location" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">{torrent.basePath}</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--tags">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.tags" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
{torrent.tags.length ? getTags(torrent.tags) : VALUE_NOT_AVAILABLE}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__table__heading">
|
||||
<td className="torrent-details__table__heading--tertiary" colSpan={2}>
|
||||
<FormattedMessage id="torrents.details.general.heading.transfer" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--downloaded">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.downloaded" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
<FormattedNumber value={torrent.percentComplete} />
|
||||
<em className="unit">%</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--peers">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.peers" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
<FormattedMessage
|
||||
id="torrents.details.general.connected"
|
||||
values={{
|
||||
connectedCount: torrent.peersConnected,
|
||||
connected: <FormattedNumber value={torrent.peersConnected} />,
|
||||
total: <FormattedNumber value={torrent.peersTotal} />,
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--seeds">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.seeds" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
<FormattedMessage
|
||||
id="torrents.details.general.connected"
|
||||
values={{
|
||||
connectedCount: torrent.seedsConnected,
|
||||
connected: <FormattedNumber value={torrent.seedsConnected} />,
|
||||
total: <FormattedNumber value={torrent.seedsTotal} />,
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__table__heading">
|
||||
<td className="torrent-details__table__heading--tertiary" colSpan={2}>
|
||||
<FormattedMessage id="torrents.details.general.heading.torrent" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--created">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.date.created" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
{creation
|
||||
? `${this.props.intl.formatDate(creation, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: '2-digit',
|
||||
})} ${this.props.intl.formatTime(creation)}`
|
||||
: VALUE_NOT_AVAILABLE}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--hash">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.hash" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">{torrent.hash}</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--size">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.size" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
<Size value={torrent.sizeBytes} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--type">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.type" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
{torrent.isPrivate
|
||||
? this.props.intl.formatMessage({
|
||||
id: 'torrents.details.general.type.private',
|
||||
})
|
||||
: this.props.intl.formatMessage({
|
||||
id: 'torrents.details.general.type.public',
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__table__heading">
|
||||
<td className="torrent-details__table__heading--tertiary" colSpan={2}>
|
||||
<FormattedMessage id="torrents.details.general.heading.tracker" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--tracker-message">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.tracker.message" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
{torrent.message ? torrent.message : VALUE_NOT_AVAILABLE}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
let dateAdded = null;
|
||||
if (torrent.dateAdded) {
|
||||
dateAdded = new Date(torrent.dateAdded * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(TorrentGeneralInfo);
|
||||
let creation = null;
|
||||
if (torrent.dateCreated) {
|
||||
creation = new Date(torrent.dateCreated * 1000);
|
||||
}
|
||||
|
||||
const VALUE_NOT_AVAILABLE = (
|
||||
<span className="not-available">
|
||||
<FormattedMessage id="torrents.details.general.none" />
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="torrent-details__section torrent-details__section--general">
|
||||
<table className="torrent-details__table table">
|
||||
<tbody>
|
||||
<tr className="torrent-details__table__heading">
|
||||
<td className="torrent-details__table__heading--tertiary" colSpan={2}>
|
||||
<FormattedMessage id="torrents.details.general.heading.general" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--dateAdded">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.date.added" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
{dateAdded
|
||||
? `${intl.formatDate(dateAdded, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: '2-digit',
|
||||
})} ${intl.formatTime(dateAdded)}`
|
||||
: VALUE_NOT_AVAILABLE}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--location">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.location" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">{torrent.basePath}</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--tags">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.tags" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
{torrent.tags.length ? getTags(torrent.tags) : VALUE_NOT_AVAILABLE}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__table__heading">
|
||||
<td className="torrent-details__table__heading--tertiary" colSpan={2}>
|
||||
<FormattedMessage id="torrents.details.general.heading.transfer" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--downloaded">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.downloaded" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
<FormattedNumber value={torrent.percentComplete} />
|
||||
<em className="unit">%</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--peers">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.peers" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
<FormattedMessage
|
||||
id="torrents.details.general.connected"
|
||||
values={{
|
||||
connectedCount: torrent.peersConnected,
|
||||
connected: <FormattedNumber value={torrent.peersConnected} />,
|
||||
total: <FormattedNumber value={torrent.peersTotal} />,
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--seeds">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.seeds" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
<FormattedMessage
|
||||
id="torrents.details.general.connected"
|
||||
values={{
|
||||
connectedCount: torrent.seedsConnected,
|
||||
connected: <FormattedNumber value={torrent.seedsConnected} />,
|
||||
total: <FormattedNumber value={torrent.seedsTotal} />,
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__table__heading">
|
||||
<td className="torrent-details__table__heading--tertiary" colSpan={2}>
|
||||
<FormattedMessage id="torrents.details.general.heading.torrent" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--created">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.date.created" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
{creation
|
||||
? `${intl.formatDate(creation, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: '2-digit',
|
||||
})} ${intl.formatTime(creation)}`
|
||||
: VALUE_NOT_AVAILABLE}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--hash">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.hash" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">{torrent.hash}</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--size">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.size" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
<Size value={torrent.sizeBytes} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--type">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.type" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
{torrent.isPrivate
|
||||
? intl.formatMessage({
|
||||
id: 'torrents.details.general.type.private',
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: 'torrents.details.general.type.public',
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__table__heading">
|
||||
<td className="torrent-details__table__heading--tertiary" colSpan={2}>
|
||||
<FormattedMessage id="torrents.details.general.heading.tracker" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="torrent-details__detail torrent-details__detail--tracker-message">
|
||||
<td className="torrent-details__detail__label">
|
||||
<FormattedMessage id="torrents.details.general.tracker.message" />
|
||||
</td>
|
||||
<td className="torrent-details__detail__value">
|
||||
{torrent.message ? torrent.message : VALUE_NOT_AVAILABLE}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TorrentGeneralInfo;
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {TorrentProperties} from '@shared/types/Torrent';
|
||||
import {Button} from '../../../ui';
|
||||
import ClipboardIcon from '../../icons/ClipboardIcon';
|
||||
import connectStores from '../../../util/connectStores';
|
||||
import EventTypes from '../../../constants/EventTypes';
|
||||
import Tooltip from '../../general/Tooltip';
|
||||
import TorrentActions from '../../../actions/TorrentActions';
|
||||
import TorrentStore from '../../../stores/TorrentStore';
|
||||
@@ -163,7 +162,7 @@ const ConnectedTorrentMediainfo = connectStores<Omit<TorrentMediainfoProps, 'int
|
||||
return [
|
||||
{
|
||||
store: TorrentStore,
|
||||
event: EventTypes.CLIENT_FETCH_TORRENT_MEDIAINFO_SUCCESS,
|
||||
event: 'CLIENT_FETCH_TORRENT_MEDIAINFO_SUCCESS',
|
||||
getValue: ({props}) => {
|
||||
return {
|
||||
mediainfo: TorrentStore.getMediainfo(props.hash),
|
||||
|
||||
@@ -80,7 +80,7 @@ export default class TorrentPeers extends React.Component<TorrentPeersProps> {
|
||||
<td>
|
||||
<Size value={peer.uploadRate} isSpeed />
|
||||
</td>
|
||||
<td>{peer.completedPercent}%</td>
|
||||
<td>{`${peer.completedPercent}%`}</td>
|
||||
<td>{peer.clientVersion}</td>
|
||||
<td className="peers-list__encryption">{encryptedIcon}</td>
|
||||
<td className="peers-list__incoming">{incomingIcon}</td>
|
||||
|
||||
@@ -9,44 +9,42 @@ interface TorrentTrackersProps {
|
||||
trackers: Array<TorrentTracker>;
|
||||
}
|
||||
|
||||
export default class TorrentTrackers extends React.Component<TorrentTrackersProps> {
|
||||
render() {
|
||||
const trackers = this.props.trackers || [];
|
||||
const TorrentTrackers: React.FC<TorrentTrackersProps> = ({trackers}: TorrentTrackersProps) => {
|
||||
const trackerCount = trackers.length;
|
||||
const trackerTypes = ['http', 'udp', 'dht'];
|
||||
|
||||
const trackerCount = trackers.length;
|
||||
const trackerTypes = ['http', 'udp', 'dht'];
|
||||
const trackerDetails = trackers.map((tracker) => (
|
||||
<tr key={tracker.url}>
|
||||
<td>{tracker.url}</td>
|
||||
<td>{trackerTypes[tracker.type - 1]}</td>
|
||||
</tr>
|
||||
));
|
||||
|
||||
const trackerDetails = trackers.map((tracker) => (
|
||||
<tr key={tracker.url}>
|
||||
<td>{tracker.url}</td>
|
||||
<td>{trackerTypes[tracker.type - 1]}</td>
|
||||
</tr>
|
||||
));
|
||||
|
||||
if (trackerCount) {
|
||||
return (
|
||||
<div className="torrent-details__trackers torrent-details__section">
|
||||
<table className="torrent-details__table table">
|
||||
<thead className="torrent-details__table__heading">
|
||||
<tr>
|
||||
<th className="torrent-details__table__heading--primary">
|
||||
<FormattedMessage id="torrents.details.trackers" />
|
||||
<Badge>{trackerCount}</Badge>
|
||||
</th>
|
||||
<th className="torrent-details__table__heading--secondary">
|
||||
<FormattedMessage id="torrents.details.trackers.type" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{trackerDetails}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (trackerCount) {
|
||||
return (
|
||||
<span className="torrent-details__section__null-data">
|
||||
<FormattedMessage id="torrents.details.trackers.no.data" />
|
||||
</span>
|
||||
<div className="torrent-details__trackers torrent-details__section">
|
||||
<table className="torrent-details__table table">
|
||||
<thead className="torrent-details__table__heading">
|
||||
<tr>
|
||||
<th className="torrent-details__table__heading--primary">
|
||||
<FormattedMessage id="torrents.details.trackers" />
|
||||
<Badge>{trackerCount}</Badge>
|
||||
</th>
|
||||
<th className="torrent-details__table__heading--secondary">
|
||||
<FormattedMessage id="torrents.details.trackers.type" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{trackerDetails}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span className="torrent-details__section__null-data">
|
||||
<FormattedMessage id="torrents.details.trackers.no.data" />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default TorrentTrackers;
|
||||
|
||||
@@ -15,7 +15,12 @@ interface DiskUsageProps {
|
||||
mountPoints?: Array<string>;
|
||||
}
|
||||
|
||||
const DiskUsageTooltipItem = ({label, value}: {label: React.ReactNode; value: number}) => {
|
||||
interface DiskUsageTooltipItemProps {
|
||||
label: React.ReactNode;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const DiskUsageTooltipItem: React.FC<DiskUsageTooltipItemProps> = ({label, value}: DiskUsageTooltipItemProps) => {
|
||||
return (
|
||||
<li className="diskusage__details-list__item">
|
||||
<label className="diskusage__details-list__label">{label}</label>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {defineMessages, injectIntl, WrappedComponentProps} from 'react-intl';
|
||||
import {useIntl} from 'react-intl';
|
||||
import React from 'react';
|
||||
|
||||
import AuthActions from '../../actions/AuthActions';
|
||||
@@ -6,35 +6,30 @@ import ConfigStore from '../../stores/ConfigStore';
|
||||
import Logout from '../icons/Logout';
|
||||
import Tooltip from '../general/Tooltip';
|
||||
|
||||
const MESSAGES = defineMessages({
|
||||
logOut: {
|
||||
id: 'sidebar.button.log.out',
|
||||
},
|
||||
});
|
||||
|
||||
class LogoutButton extends React.Component<WrappedComponentProps> {
|
||||
static handleLogoutClick() {
|
||||
AuthActions.logout().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
const LogoutButton = () => {
|
||||
if (ConfigStore.getDisableAuth()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (ConfigStore.getDisableAuth()) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Tooltip
|
||||
content={this.props.intl.formatMessage(MESSAGES.logOut)}
|
||||
onClick={LogoutButton.handleLogoutClick}
|
||||
position="bottom"
|
||||
wrapperClassName="sidebar__action sidebar__action--last
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={intl.formatMessage({
|
||||
id: 'sidebar.button.log.out',
|
||||
})}
|
||||
onClick={() =>
|
||||
AuthActions.logout().then(() => {
|
||||
window.location.reload();
|
||||
})
|
||||
}
|
||||
position="bottom"
|
||||
wrapperClassName="sidebar__action sidebar__action--last
|
||||
sidebar__icon-button sidebar__icon-button--interactive
|
||||
tooltip__wrapper">
|
||||
<Logout />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
<Logout />
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default injectIntl(LogoutButton);
|
||||
export default LogoutButton;
|
||||
|
||||
@@ -83,21 +83,11 @@ class NotificationsButton extends React.Component<NotificationsButtonProps, Noti
|
||||
NotificationStore.unlisten('NOTIFICATIONS_COUNT_CHANGE', this.handleNotificationCountChange);
|
||||
}
|
||||
|
||||
fetchNotifications = () => {
|
||||
this.setState({isLoading: true});
|
||||
|
||||
FloodActions.fetchNotifications({
|
||||
id: 'notification-tooltip',
|
||||
limit: NOTIFICATIONS_PER_PAGE,
|
||||
start: this.state.paginationStart,
|
||||
}).then(() => {
|
||||
this.setState({isLoading: false});
|
||||
});
|
||||
};
|
||||
|
||||
getBadge() {
|
||||
if (this.props.count != null && this.props.count.total > 0) {
|
||||
return <span className="notifications__badge">{this.props.count.total}</span>;
|
||||
const {count} = this.props;
|
||||
|
||||
if (count != null && count.total > 0) {
|
||||
return <span className="notifications__badge">{count.total}</span>;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -138,7 +128,8 @@ class NotificationsButton extends React.Component<NotificationsButtonProps, Noti
|
||||
className="notifications__toolbar toolbar toolbar--dark
|
||||
toolbar--bottom">
|
||||
<li className={newerButtonClass} onClick={this.handleNewerNotificationsClick}>
|
||||
<ChevronLeftIcon /> {newerFrom + 1} – {newerTo}
|
||||
<ChevronLeftIcon />
|
||||
{`${newerFrom + 1} – ${newerTo}`}
|
||||
</li>
|
||||
<li
|
||||
className="toolbar__item toolbar__item--button
|
||||
@@ -147,8 +138,8 @@ class NotificationsButton extends React.Component<NotificationsButtonProps, Noti
|
||||
{this.props.intl.formatMessage(MESSAGES.clearAll)}
|
||||
</li>
|
||||
<li className={olderButtonClass} onClick={this.handleOlderNotificationsClick}>
|
||||
{olderFrom} –
|
||||
{olderTo} <ChevronRightIcon />
|
||||
{`${olderFrom} – ${olderTo}`}
|
||||
<ChevronRightIcon />
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
@@ -158,8 +149,9 @@ class NotificationsButton extends React.Component<NotificationsButtonProps, Noti
|
||||
};
|
||||
|
||||
getNotification = (notification: Notification, index: number) => {
|
||||
const date = this.props.intl.formatDate(notification.ts, {year: 'numeric', month: 'long', day: '2-digit'});
|
||||
const time = this.props.intl.formatTime(notification.ts);
|
||||
const {intl} = this.props;
|
||||
const date = intl.formatDate(notification.ts, {year: 'numeric', month: 'long', day: '2-digit'});
|
||||
const time = intl.formatTime(notification.ts);
|
||||
|
||||
let notificationBody = null;
|
||||
|
||||
@@ -181,19 +173,17 @@ class NotificationsButton extends React.Component<NotificationsButtonProps, Noti
|
||||
);
|
||||
} else {
|
||||
const messageID = MESSAGES[`${notification.id}.body` as keyof typeof MESSAGES];
|
||||
notificationBody = this.props.intl.formatMessage(messageID, notification.data);
|
||||
notificationBody = intl.formatMessage(messageID, notification.data);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="notifications__list__item" key={index}>
|
||||
<div className="notification__heading">
|
||||
<span className="notification__category">
|
||||
{this.props.intl.formatMessage(MESSAGES[`${notification.id}.heading` as keyof typeof MESSAGES])}
|
||||
{intl.formatMessage(MESSAGES[`${notification.id}.heading` as keyof typeof MESSAGES])}
|
||||
</span>
|
||||
{' — '}
|
||||
<span className="notification__timestamp">
|
||||
{date} {this.props.intl.formatMessage(MESSAGES.at)} {time}
|
||||
</span>
|
||||
<span className="notification__timestamp">{`${date} ${intl.formatMessage(MESSAGES.at)} ${time}`}</span>
|
||||
</div>
|
||||
<div className="notification__message">{notificationBody}</div>
|
||||
</li>
|
||||
@@ -201,29 +191,31 @@ class NotificationsButton extends React.Component<NotificationsButtonProps, Noti
|
||||
};
|
||||
|
||||
getTopToolbar() {
|
||||
if (this.props.count != null && this.props.count.total > NOTIFICATIONS_PER_PAGE) {
|
||||
let countStart = this.state.paginationStart + 1;
|
||||
let countEnd = this.state.paginationStart + NOTIFICATIONS_PER_PAGE;
|
||||
const {count, intl} = this.props;
|
||||
const {paginationStart} = this.state;
|
||||
if (count != null && count.total > NOTIFICATIONS_PER_PAGE) {
|
||||
let countStart = paginationStart + 1;
|
||||
let countEnd = paginationStart + NOTIFICATIONS_PER_PAGE;
|
||||
|
||||
if (countStart > this.props.count.total) {
|
||||
countStart = this.props.count.total;
|
||||
if (countStart > count.total) {
|
||||
countStart = count.total;
|
||||
}
|
||||
|
||||
if (countEnd > this.props.count.total) {
|
||||
countEnd = this.props.count.total;
|
||||
if (countEnd > count.total) {
|
||||
countEnd = count.total;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="toolbar toolbar--dark toolbar--top tooltip__toolbar tooltip__content--padding-surrogate">
|
||||
<span className="toolbar__item toolbar__item--label">
|
||||
{`${this.props.intl.formatMessage(MESSAGES.showing)} `}
|
||||
{`${intl.formatMessage(MESSAGES.showing)} `}
|
||||
<strong>
|
||||
{countStart}
|
||||
{` ${this.props.intl.formatMessage(MESSAGES.to)} `}
|
||||
{` ${intl.formatMessage(MESSAGES.to)} `}
|
||||
{countEnd}
|
||||
</strong>
|
||||
{` ${this.props.intl.formatMessage(MESSAGES.of)} `}
|
||||
<strong>{this.props.count.total}</strong>
|
||||
{` ${intl.formatMessage(MESSAGES.of)} `}
|
||||
<strong>{count.total}</strong>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -264,6 +256,18 @@ class NotificationsButton extends React.Component<NotificationsButtonProps, Noti
|
||||
);
|
||||
};
|
||||
|
||||
fetchNotifications = () => {
|
||||
this.setState({isLoading: true});
|
||||
|
||||
FloodActions.fetchNotifications({
|
||||
id: 'notification-tooltip',
|
||||
limit: NOTIFICATIONS_PER_PAGE,
|
||||
start: this.state.paginationStart,
|
||||
}).then(() => {
|
||||
this.setState({isLoading: false});
|
||||
});
|
||||
};
|
||||
|
||||
handleClearNotificationsClick = () => {
|
||||
this.setState({
|
||||
paginationStart: 0,
|
||||
@@ -313,16 +317,18 @@ class NotificationsButton extends React.Component<NotificationsButtonProps, Noti
|
||||
};
|
||||
|
||||
render() {
|
||||
const {count} = this.props;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
contentClassName="tooltip__content tooltip__content--no-padding"
|
||||
content={this.getTooltipContent()}
|
||||
interactive={this.props.count != null && this.props.count.total !== 0}
|
||||
interactive={count != null && count.total !== 0}
|
||||
onOpen={this.handleTooltipOpen}
|
||||
ref={(ref) => {
|
||||
this.tooltipRef = ref;
|
||||
}}
|
||||
width={this.props.count == null || this.props.count.total === 0 ? undefined : 340}
|
||||
width={count == null || count.total === 0 ? undefined : 340}
|
||||
position="bottom"
|
||||
wrapperClassName="sidebar__action sidebar__icon-button
|
||||
tooltip__wrapper">
|
||||
|
||||
@@ -51,6 +51,7 @@ class SearchBox extends React.Component<WrappedComponentProps, SearchBoxStates>
|
||||
};
|
||||
|
||||
render() {
|
||||
const {intl} = this.props;
|
||||
const {inputFieldKey, isSearchActive} = this.state;
|
||||
let clearSearchButton = null;
|
||||
const classes = classnames({
|
||||
@@ -61,7 +62,7 @@ class SearchBox extends React.Component<WrappedComponentProps, SearchBoxStates>
|
||||
|
||||
if (isSearchActive) {
|
||||
clearSearchButton = (
|
||||
<button className="button search__reset-button" onClick={this.handleResetClick}>
|
||||
<button className="button search__reset-button" onClick={this.handleResetClick} type="button">
|
||||
<Close />
|
||||
</button>
|
||||
);
|
||||
@@ -75,7 +76,7 @@ class SearchBox extends React.Component<WrappedComponentProps, SearchBoxStates>
|
||||
className="textbox"
|
||||
key={inputFieldKey}
|
||||
type="text"
|
||||
placeholder={this.props.intl.formatMessage({
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'sidebar.search.placeholder',
|
||||
})}
|
||||
onChange={this.handleSearchChange}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
class SidebarActions extends React.Component {
|
||||
render() {
|
||||
return <div className="sidebar__actions">{this.props.children}</div>;
|
||||
}
|
||||
interface SidebarActionsProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SidebarActions: React.FC<SidebarActionsProps> = ({children}: SidebarActionsProps) => {
|
||||
return <div className="sidebar__actions">{children}</div>;
|
||||
};
|
||||
|
||||
export default SidebarActions;
|
||||
|
||||
@@ -2,22 +2,21 @@ import classnames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
interface SidebarItemProps {
|
||||
baseClassName: string;
|
||||
children: React.ReactNode;
|
||||
baseClassName?: string;
|
||||
modifier: string;
|
||||
}
|
||||
|
||||
class SidebarItem extends React.Component<SidebarItemProps> {
|
||||
static defaultProps = {
|
||||
baseClassName: 'sidebar__item',
|
||||
};
|
||||
const SidebarItem: React.FC<SidebarItemProps> = ({children, baseClassName, modifier}: SidebarItemProps) => {
|
||||
const classes = classnames(baseClassName, {
|
||||
[`${baseClassName}--${modifier}`]: modifier,
|
||||
});
|
||||
|
||||
render() {
|
||||
const classes = classnames(this.props.baseClassName, {
|
||||
[`${this.props.baseClassName}--${this.props.modifier}`]: this.props.modifier,
|
||||
});
|
||||
return <div className={classes}>{children}</div>;
|
||||
};
|
||||
|
||||
return <div className={classes}>{this.props.children}</div>;
|
||||
}
|
||||
}
|
||||
SidebarItem.defaultProps = {
|
||||
baseClassName: 'sidebar__item',
|
||||
};
|
||||
|
||||
export default SidebarItem;
|
||||
|
||||
@@ -52,7 +52,8 @@ class SpeedLimitDropdown extends React.Component<SpeedLimitDropdownProps> {
|
||||
<button
|
||||
className="sidebar__icon-button sidebar__icon-button--interactive
|
||||
sidebar__icon-button--limits"
|
||||
title={this.props.intl.formatMessage(MESSAGES.speedLimits)}>
|
||||
title={this.props.intl.formatMessage(MESSAGES.speedLimits)}
|
||||
type="button">
|
||||
<LimitsIcon />
|
||||
<FormattedMessage {...MESSAGES.speedLimits} />
|
||||
</button>
|
||||
|
||||
@@ -61,7 +61,10 @@ class TransferData extends React.Component<TransferDataProps, TransferDataStates
|
||||
};
|
||||
|
||||
renderTransferRateGraph() {
|
||||
if (!this.props.isClientConnected) return null;
|
||||
const {isClientConnected} = this.props;
|
||||
const {sidebarWidth} = this.state;
|
||||
|
||||
if (!isClientConnected) return null;
|
||||
|
||||
return (
|
||||
<TransferRateGraph
|
||||
@@ -72,12 +75,14 @@ class TransferData extends React.Component<TransferDataProps, TransferDataStates
|
||||
ref={(ref) => {
|
||||
this.rateGraphRef = ref;
|
||||
}}
|
||||
width={this.state.sidebarWidth}
|
||||
width={sidebarWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {graphInspectorPoint} = this.state;
|
||||
|
||||
return (
|
||||
<Measure
|
||||
offset
|
||||
@@ -93,7 +98,7 @@ class TransferData extends React.Component<TransferDataProps, TransferDataStates
|
||||
onMouseMove={this.handleMouseMove}
|
||||
onMouseOut={this.handleMouseOut}
|
||||
onMouseOver={this.handleMouseOver}>
|
||||
<TransferRateDetails inspectorPoint={this.state.graphInspectorPoint} />
|
||||
<TransferRateDetails inspectorPoint={graphInspectorPoint} />
|
||||
{this.renderTransferRateGraph()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ const icons = {
|
||||
|
||||
class TransferRateDetails extends React.Component<TransferRateDetailsProps> {
|
||||
getCurrentTransferRate(direction: TransferDirection, options: {showHoverDuration?: boolean} = {}) {
|
||||
const {inspectorPoint, isClientConnected, transferSummary} = this.props;
|
||||
const {inspectorPoint, intl, isClientConnected, transferSummary} = this.props;
|
||||
|
||||
const throttles = {
|
||||
download: transferSummary != null ? transferSummary.downThrottle : 0,
|
||||
@@ -78,7 +78,7 @@ class TransferRateDetails extends React.Component<TransferRateDetailsProps> {
|
||||
|
||||
timestamp = (
|
||||
<div className={timestampClasses}>
|
||||
<Duration suffix={this.props.intl.formatMessage(messages.ago)} value={durationSummary} />
|
||||
<Duration suffix={intl.formatMessage(messages.ago)} value={durationSummary} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,8 +81,67 @@ class TransferRateGraph extends React.Component<TransferRateGraphProps> {
|
||||
TransferDataStore.unlisten('CLIENT_TRANSFER_HISTORY_REQUEST_SUCCESS', this.handleTransferHistoryChange);
|
||||
}
|
||||
|
||||
handleTransferHistoryChange(): void {
|
||||
this.updateGraph();
|
||||
private setInspectorCoordinates(slug: TransferDirection, hoverPoint: number): number {
|
||||
const {
|
||||
graphRefs: {
|
||||
[slug]: {inspectPoint},
|
||||
},
|
||||
xScale,
|
||||
yScale,
|
||||
} = this;
|
||||
|
||||
if (xScale == null || yScale == null || inspectPoint == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const historicalData = TransferDataStore.getTransferRates();
|
||||
const upperSpeed = historicalData[slug][Math.ceil(hoverPoint)];
|
||||
const lowerSpeed = historicalData[slug][Math.floor(hoverPoint)];
|
||||
|
||||
const delta = upperSpeed - lowerSpeed;
|
||||
const speedAtHoverPoint = lowerSpeed + delta * (hoverPoint % 1);
|
||||
|
||||
const coordinates = {x: xScale(hoverPoint), y: yScale(speedAtHoverPoint)};
|
||||
|
||||
inspectPoint.attr('transform', `translate(${coordinates.x},${coordinates.y})`);
|
||||
|
||||
return speedAtHoverPoint;
|
||||
}
|
||||
|
||||
private initGraph(): void {
|
||||
if (this.graphRefs.areDefined === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {id} = this.props;
|
||||
|
||||
const graph = select(`#${id}`);
|
||||
TRANSFER_DIRECTIONS.forEach(<T extends TransferDirection>(direction: T) => {
|
||||
// appendEmptyGraphShapes
|
||||
this.graphRefs[direction].graphArea = graph
|
||||
.append('path')
|
||||
.attr('class', 'graph__area')
|
||||
.attr('fill', `url('#graph__gradient--${direction}')`);
|
||||
|
||||
// appendEmptyGraphLines
|
||||
this.graphRefs[direction].rateLine = graph.append('path').attr('class', `graph__line graph__line--${direction}`);
|
||||
|
||||
// appendGraphCircles
|
||||
this.graphRefs[direction].inspectPoint = graph
|
||||
.append('circle')
|
||||
.attr('class', `graph__circle graph__circle--${direction}`)
|
||||
.attr('r', 2.5);
|
||||
});
|
||||
|
||||
this.graphRefs.areDefined = true;
|
||||
}
|
||||
|
||||
private updateGraph() {
|
||||
this.renderGraphData();
|
||||
|
||||
if (this.graphRefs.isHovered) {
|
||||
this.renderPrecisePointInspectors();
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseMove(mouseX: number): void {
|
||||
@@ -118,64 +177,33 @@ class TransferRateGraph extends React.Component<TransferRateGraphProps> {
|
||||
});
|
||||
}
|
||||
|
||||
private initGraph(): void {
|
||||
if (this.graphRefs.areDefined === true) {
|
||||
handleTransferHistoryChange(): void {
|
||||
this.updateGraph();
|
||||
}
|
||||
|
||||
private renderPrecisePointInspectors(): void {
|
||||
const {
|
||||
lastMouseX,
|
||||
props: {onHover},
|
||||
xScale,
|
||||
} = this;
|
||||
|
||||
if (xScale == null || lastMouseX == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const graph = select(`#${this.props.id}`);
|
||||
TRANSFER_DIRECTIONS.forEach(<T extends TransferDirection>(direction: T) => {
|
||||
// appendEmptyGraphShapes
|
||||
this.graphRefs[direction].graphArea = graph
|
||||
.append('path')
|
||||
.attr('class', 'graph__area')
|
||||
.attr('fill', `url('#graph__gradient--${direction}')`);
|
||||
|
||||
// appendEmptyGraphLines
|
||||
this.graphRefs[direction].rateLine = graph.append('path').attr('class', `graph__line graph__line--${direction}`);
|
||||
|
||||
// appendGraphCircles
|
||||
this.graphRefs[direction].inspectPoint = graph
|
||||
.append('circle')
|
||||
.attr('class', `graph__circle graph__circle--${direction}`)
|
||||
.attr('r', 2.5);
|
||||
});
|
||||
|
||||
this.graphRefs.areDefined = true;
|
||||
}
|
||||
|
||||
private setInspectorCoordinates(slug: TransferDirection, hoverPoint: number): number {
|
||||
const {
|
||||
graphRefs: {
|
||||
[slug]: {inspectPoint},
|
||||
},
|
||||
xScale,
|
||||
yScale,
|
||||
} = this;
|
||||
|
||||
if (xScale == null || yScale == null || inspectPoint == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const historicalData = TransferDataStore.getTransferRates();
|
||||
const upperSpeed = historicalData[slug][Math.ceil(hoverPoint)];
|
||||
const lowerSpeed = historicalData[slug][Math.floor(hoverPoint)];
|
||||
const hoverPoint = xScale.invert(lastMouseX);
|
||||
const uploadSpeed = this.setInspectorCoordinates('upload', hoverPoint);
|
||||
const downloadSpeed = this.setInspectorCoordinates('download', hoverPoint);
|
||||
const nearestTimestamp = historicalData.timestamps[Math.round(hoverPoint)];
|
||||
|
||||
const delta = upperSpeed - lowerSpeed;
|
||||
const speedAtHoverPoint = lowerSpeed + delta * (hoverPoint % 1);
|
||||
|
||||
const coordinates = {x: xScale(hoverPoint), y: yScale(speedAtHoverPoint)};
|
||||
|
||||
inspectPoint.attr('transform', `translate(${coordinates.x},${coordinates.y})`);
|
||||
|
||||
return speedAtHoverPoint;
|
||||
}
|
||||
|
||||
private updateGraph() {
|
||||
this.renderGraphData();
|
||||
|
||||
if (this.graphRefs.isHovered) {
|
||||
this.renderPrecisePointInspectors();
|
||||
if (onHover) {
|
||||
onHover({
|
||||
uploadSpeed,
|
||||
downloadSpeed,
|
||||
nearestTimestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,37 +253,13 @@ class TransferRateGraph extends React.Component<TransferRateGraphProps> {
|
||||
});
|
||||
}
|
||||
|
||||
private renderPrecisePointInspectors(): void {
|
||||
const {
|
||||
lastMouseX,
|
||||
props: {onHover},
|
||||
xScale,
|
||||
} = this;
|
||||
|
||||
if (xScale == null || lastMouseX == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const historicalData = TransferDataStore.getTransferRates();
|
||||
const hoverPoint = xScale.invert(lastMouseX);
|
||||
const uploadSpeed = this.setInspectorCoordinates('upload', hoverPoint);
|
||||
const downloadSpeed = this.setInspectorCoordinates('download', hoverPoint);
|
||||
const nearestTimestamp = historicalData.timestamps[Math.round(hoverPoint)];
|
||||
|
||||
if (onHover) {
|
||||
onHover({
|
||||
uploadSpeed,
|
||||
downloadSpeed,
|
||||
nearestTimestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {id} = this.props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="graph"
|
||||
id={this.props.id}
|
||||
id={id}
|
||||
ref={(ref) => {
|
||||
this.graphRefs.graph = ref;
|
||||
}}>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import Tooltip from '../general/Tooltip';
|
||||
|
||||
export default class Action extends React.Component {
|
||||
render() {
|
||||
const {clickHandler, icon, label, slug, noTip} = this.props;
|
||||
const classes = classnames('action tooltip__wrapper', {
|
||||
[`action--${slug}`]: slug != null,
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip content={label} onClick={clickHandler} position="bottom" wrapperClassName={classes} suppress={noTip}>
|
||||
{icon}
|
||||
<span className="action__label">{label}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
32
client/src/javascript/components/torrent-list/Action.tsx
Normal file
32
client/src/javascript/components/torrent-list/Action.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import classnames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import Tooltip from '../general/Tooltip';
|
||||
|
||||
interface ActionProps {
|
||||
clickHandler: () => void;
|
||||
icon: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
slug: string;
|
||||
noTip?: boolean;
|
||||
}
|
||||
|
||||
const Action: React.FC<ActionProps> = (props: ActionProps) => {
|
||||
const {clickHandler, icon, label, slug, noTip} = props;
|
||||
const classes = classnames('action tooltip__wrapper', {
|
||||
[`action--${slug}`]: slug != null,
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip content={label} onClick={clickHandler} position="bottom" wrapperClassName={classes} suppress={noTip}>
|
||||
{icon}
|
||||
<span className="action__label">{label}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
Action.defaultProps = {
|
||||
noTip: false,
|
||||
};
|
||||
|
||||
export default Action;
|
||||
@@ -58,8 +58,10 @@ class ActionBar extends React.Component<ActionBarProps> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {sortBy, torrentListViewSize, intl} = this.props;
|
||||
|
||||
const classes = classnames('action-bar', {
|
||||
'action-bar--is-condensed': this.props.torrentListViewSize === 'condensed',
|
||||
'action-bar--is-condensed': torrentListViewSize === 'condensed',
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -75,15 +77,15 @@ class ActionBar extends React.Component<ActionBarProps> {
|
||||
</div>
|
||||
<div className="actions action-bar__item action-bar__item--sort-torrents">
|
||||
<SortDropdown
|
||||
direction={this.props.sortBy != null ? this.props.sortBy.direction : 'desc'}
|
||||
direction={sortBy != null ? sortBy.direction : 'desc'}
|
||||
onSortChange={ActionBar.handleSortChange}
|
||||
selectedProperty={this.props.sortBy != null ? this.props.sortBy.property : 'dateAdded'}
|
||||
selectedProperty={sortBy != null ? sortBy.property : 'dateAdded'}
|
||||
/>
|
||||
</div>
|
||||
<div className="actions action-bar__item action-bar__item--torrent-operations">
|
||||
<div className="action-bar__group">
|
||||
<Action
|
||||
label={this.props.intl.formatMessage({
|
||||
label={intl.formatMessage({
|
||||
id: 'actionbar.button.start.torrent',
|
||||
})}
|
||||
slug="start-torrent"
|
||||
@@ -91,7 +93,7 @@ class ActionBar extends React.Component<ActionBarProps> {
|
||||
clickHandler={ActionBar.handleStart}
|
||||
/>
|
||||
<Action
|
||||
label={this.props.intl.formatMessage({
|
||||
label={intl.formatMessage({
|
||||
id: 'actionbar.button.stop.torrent',
|
||||
})}
|
||||
slug="stop-torrent"
|
||||
@@ -101,7 +103,7 @@ class ActionBar extends React.Component<ActionBarProps> {
|
||||
</div>
|
||||
<div className="action-bar__group action-bar__group--has-divider">
|
||||
<Action
|
||||
label={this.props.intl.formatMessage({
|
||||
label={intl.formatMessage({
|
||||
id: 'actionbar.button.add.torrent',
|
||||
})}
|
||||
slug="add-torrent"
|
||||
@@ -109,7 +111,7 @@ class ActionBar extends React.Component<ActionBarProps> {
|
||||
clickHandler={ActionBar.handleAddTorrents}
|
||||
/>
|
||||
<Action
|
||||
label={this.props.intl.formatMessage({
|
||||
label={intl.formatMessage({
|
||||
id: 'actionbar.button.remove.torrent',
|
||||
})}
|
||||
slug="remove-torrent"
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import {FormattedMessage, injectIntl} from 'react-intl';
|
||||
import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl';
|
||||
import React from 'react';
|
||||
|
||||
import Dropdown from '../general/form-elements/Dropdown';
|
||||
import TorrentProperties from '../../constants/TorrentProperties';
|
||||
import type {FloodSettings} from '@shared/types/FloodSettings';
|
||||
|
||||
import Dropdown from '../general/form-elements/Dropdown';
|
||||
import TorrentListColumns from '../../constants/TorrentListColumns';
|
||||
|
||||
import type {DropdownItem} from '../general/form-elements/Dropdown';
|
||||
import type {TorrentListColumn} from '../../constants/TorrentListColumns';
|
||||
|
||||
const METHODS_TO_BIND = ['getDropdownHeader', 'handleItemSelect'];
|
||||
const SORT_PROPERTIES = [
|
||||
'name',
|
||||
'eta',
|
||||
@@ -16,27 +20,32 @@ const SORT_PROPERTIES = [
|
||||
'upTotal',
|
||||
'sizeBytes',
|
||||
'dateAdded',
|
||||
];
|
||||
] as const;
|
||||
|
||||
class SortDropdown extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
interface SortDropdownProps extends WrappedComponentProps {
|
||||
selectedProperty: TorrentListColumn;
|
||||
direction: 'asc' | 'desc';
|
||||
onSortChange: (sortBy: FloodSettings['sortTorrents']) => void;
|
||||
}
|
||||
|
||||
METHODS_TO_BIND.forEach((method) => {
|
||||
this[method] = this[method].bind(this);
|
||||
});
|
||||
class SortDropdown extends React.PureComponent<SortDropdownProps> {
|
||||
constructor(props: SortDropdownProps) {
|
||||
super(props);
|
||||
|
||||
this.getDropdownHeader = this.getDropdownHeader.bind(this);
|
||||
this.handleItemSelect = this.handleItemSelect.bind(this);
|
||||
}
|
||||
|
||||
getDropdownHeader() {
|
||||
const {selectedProperty} = this.props;
|
||||
let propertyMessageConfig = TorrentProperties[selectedProperty];
|
||||
let propertyMessageConfig = TorrentListColumns[selectedProperty];
|
||||
|
||||
if (propertyMessageConfig == null) {
|
||||
propertyMessageConfig = TorrentProperties.dateAdded;
|
||||
propertyMessageConfig = TorrentListColumns.dateAdded;
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="dropdown__button">
|
||||
<button className="dropdown__button" type="button">
|
||||
<label className="dropdown__label">
|
||||
<FormattedMessage id="torrents.sort.title" />
|
||||
</label>
|
||||
@@ -48,7 +57,7 @@ class SortDropdown extends React.Component {
|
||||
}
|
||||
|
||||
getDropdownMenus() {
|
||||
const {direction, selectedProperty} = this.props;
|
||||
const {direction, selectedProperty, intl} = this.props;
|
||||
const items = SORT_PROPERTIES.map((sortProp) => {
|
||||
const isSelected = sortProp === selectedProperty;
|
||||
const directionIndicator = isSelected ? (
|
||||
@@ -58,7 +67,7 @@ class SortDropdown extends React.Component {
|
||||
return {
|
||||
displayName: (
|
||||
<div className="sort-dropdown__item">
|
||||
{this.props.intl.formatMessage(TorrentProperties[sortProp])}
|
||||
{intl.formatMessage(TorrentListColumns[sortProp])}
|
||||
{directionIndicator}
|
||||
</div>
|
||||
),
|
||||
@@ -71,10 +80,14 @@ class SortDropdown extends React.Component {
|
||||
return [items];
|
||||
}
|
||||
|
||||
handleItemSelect(selection) {
|
||||
handleItemSelect(selection: DropdownItem<typeof SORT_PROPERTIES[number]>) {
|
||||
let {direction} = this.props;
|
||||
const {property} = selection;
|
||||
|
||||
if (property == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.selectedProperty === property) {
|
||||
direction = direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
@@ -1,170 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import {FormattedMessage, injectIntl} from 'react-intl';
|
||||
import React from 'react';
|
||||
|
||||
import TorrentProperties from '../../constants/TorrentProperties';
|
||||
import UIStore from '../../stores/UIStore';
|
||||
|
||||
const methodsToBind = [
|
||||
'getHeadingElements',
|
||||
'handleCellPointerDown',
|
||||
'handlePointerUp',
|
||||
'handlePointerMove',
|
||||
'updateCellWidth',
|
||||
];
|
||||
|
||||
const pointerDownStyles = `
|
||||
body { user-select: none !important; }
|
||||
* { cursor: col-resize !important; }
|
||||
`;
|
||||
|
||||
class TableHeading extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.focusedCell = null;
|
||||
this.focusedCellWidth = null;
|
||||
this.isPointerDown = false;
|
||||
this.lastPointerX = null;
|
||||
|
||||
methodsToBind.forEach((method) => {
|
||||
this[method] = this[method].bind(this);
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.tableHeadingX = this.tableHeading.getBoundingClientRect().left;
|
||||
}
|
||||
|
||||
handlePointerMove(event) {
|
||||
let widthDelta = 0;
|
||||
|
||||
if (this.lastPointerX != null) {
|
||||
widthDelta = event.clientX - this.lastPointerX;
|
||||
}
|
||||
|
||||
const nextCellWidth = this.focusedCellWidth + widthDelta;
|
||||
|
||||
if (nextCellWidth > 20) {
|
||||
this.focusedCellWidth = nextCellWidth;
|
||||
this.lastPointerX = event.clientX;
|
||||
this.resizeLine.style.transform = `translateX(${Math.max(
|
||||
0,
|
||||
event.clientX - this.tableHeadingX + this.props.scrollOffset,
|
||||
)}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
handlePointerUp() {
|
||||
UIStore.removeGlobalStyle(pointerDownStyles);
|
||||
global.document.removeEventListener('pointerup', this.handlePointerUp);
|
||||
global.document.removeEventListener('pointermove', this.handlePointerMove);
|
||||
|
||||
this.isPointerDown = false;
|
||||
this.lastPointerX = null;
|
||||
this.resizeLine.style.opacity = 0;
|
||||
|
||||
this.updateCellWidth(this.focusedCell, this.focusedCellWidth);
|
||||
|
||||
this.focusedCell = null;
|
||||
this.focusedCellWidth = null;
|
||||
}
|
||||
|
||||
handleCellClick(slug, event) {
|
||||
this.props.onCellClick(slug, event);
|
||||
}
|
||||
|
||||
handleCellPointerDown(event, slug, width) {
|
||||
if (!this.isPointerDown) {
|
||||
UIStore.addGlobalStyle(pointerDownStyles);
|
||||
global.document.addEventListener('pointerup', this.handlePointerUp);
|
||||
global.document.addEventListener('pointermove', this.handlePointerMove);
|
||||
|
||||
this.focusedCell = slug;
|
||||
this.focusedCellWidth = width;
|
||||
this.isPointerDown = true;
|
||||
this.lastPointerX = event.clientX;
|
||||
this.resizeLine.style.transform = `translateX(${Math.max(
|
||||
0,
|
||||
event.clientX - this.tableHeadingX + this.props.scrollOffset,
|
||||
)}px)`;
|
||||
this.resizeLine.style.opacity = 1;
|
||||
}
|
||||
}
|
||||
|
||||
updateCellWidth(cell, width) {
|
||||
this.props.onWidthsChange({[cell]: width});
|
||||
}
|
||||
|
||||
getHeadingElements() {
|
||||
const {defaultWidth, defaultPropWidths, columns, propWidths, sortProp} = this.props;
|
||||
|
||||
return columns.reduce((accumulator, {id, visible}) => {
|
||||
if (!visible) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
let handle = null;
|
||||
const width = propWidths[id] || defaultPropWidths[id] || defaultWidth;
|
||||
|
||||
if (!this.isPointerDown) {
|
||||
handle = (
|
||||
<span
|
||||
className="table__heading__handle"
|
||||
onPointerDown={(event) => {
|
||||
this.handleCellPointerDown(event, id, width);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isSortActive = id === sortProp.property;
|
||||
const classes = classnames('table__cell table__heading', {
|
||||
'table__heading--is-sorted': isSortActive,
|
||||
[`table__heading--direction--${sortProp.direction}`]: isSortActive,
|
||||
});
|
||||
|
||||
const label = <FormattedMessage id={TorrentProperties[id].id} />;
|
||||
|
||||
accumulator.push(
|
||||
<div
|
||||
className={classes}
|
||||
key={id}
|
||||
onClick={(event) => this.handleCellClick(id, event)}
|
||||
style={{width: `${width}px`}}>
|
||||
<span
|
||||
className="table__heading__label"
|
||||
title={this.props.intl.formatMessage({
|
||||
id: TorrentProperties[id].id,
|
||||
})}>
|
||||
{label}
|
||||
</span>
|
||||
{handle}
|
||||
</div>,
|
||||
);
|
||||
|
||||
return accumulator;
|
||||
}, []);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
className="table__row table__row--heading"
|
||||
ref={(ref) => {
|
||||
this.tableHeading = ref;
|
||||
}}>
|
||||
{this.getHeadingElements()}
|
||||
<div className="table__cell table__heading table__heading--fill" />
|
||||
<div
|
||||
className="table__heading__resize-line"
|
||||
ref={(ref) => {
|
||||
this.resizeLine = ref;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(TableHeading);
|
||||
177
client/src/javascript/components/torrent-list/TableHeading.tsx
Normal file
177
client/src/javascript/components/torrent-list/TableHeading.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import classnames from 'classnames';
|
||||
import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl';
|
||||
import React from 'react';
|
||||
|
||||
import defaultFloodSettings from '@shared/constants/defaultFloodSettings';
|
||||
|
||||
import type {FloodSettings} from '@shared/types/FloodSettings';
|
||||
|
||||
import TorrentListColumns, {TorrentListColumn} from '../../constants/TorrentListColumns';
|
||||
import UIStore from '../../stores/UIStore';
|
||||
|
||||
const pointerDownStyles = `
|
||||
body { user-select: none !important; }
|
||||
* { cursor: col-resize !important; }
|
||||
`;
|
||||
|
||||
interface TableHeadingProps extends WrappedComponentProps {
|
||||
columns: FloodSettings['torrentListColumns'];
|
||||
columnWidths: FloodSettings['torrentListColumnWidths'];
|
||||
sortProp: FloodSettings['sortTorrents'];
|
||||
scrollOffset: number;
|
||||
onCellClick: (column: TorrentListColumn) => void;
|
||||
onWidthsChange: (column: TorrentListColumn, width: number) => void;
|
||||
}
|
||||
|
||||
class TableHeading extends React.PureComponent<TableHeadingProps> {
|
||||
focusedCell: TorrentListColumn | null = null;
|
||||
focusedCellWidth: number | null = null;
|
||||
isPointerDown = false;
|
||||
lastPointerX: number | null = null;
|
||||
tableHeading: HTMLDivElement | null = null;
|
||||
resizeLine: HTMLDivElement | null = null;
|
||||
tableHeadingX = 0;
|
||||
|
||||
constructor(props: TableHeadingProps) {
|
||||
super(props);
|
||||
|
||||
this.handlePointerDown = this.handlePointerDown.bind(this);
|
||||
this.handlePointerUp = this.handlePointerUp.bind(this);
|
||||
this.handlePointerMove = this.handlePointerMove.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.tableHeading != null) {
|
||||
this.tableHeadingX = this.tableHeading.getBoundingClientRect().left;
|
||||
}
|
||||
}
|
||||
|
||||
getHeadingElements() {
|
||||
const {intl, columns, columnWidths, sortProp, onCellClick} = this.props;
|
||||
|
||||
return columns.reduce((accumulator: React.ReactNodeArray, {id, visible}) => {
|
||||
if (!visible) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
let handle = null;
|
||||
const width = columnWidths[id] || defaultFloodSettings.torrentListColumnWidths[id];
|
||||
|
||||
if (!this.isPointerDown) {
|
||||
handle = (
|
||||
<span
|
||||
className="table__heading__handle"
|
||||
onPointerDown={(event) => {
|
||||
this.handlePointerDown(event, id, width);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isSortActive = id === sortProp.property;
|
||||
const classes = classnames('table__cell table__heading', {
|
||||
'table__heading--is-sorted': isSortActive,
|
||||
[`table__heading--direction--${sortProp.direction}`]: isSortActive,
|
||||
});
|
||||
|
||||
const label = <FormattedMessage id={TorrentListColumns[id].id} />;
|
||||
|
||||
accumulator.push(
|
||||
<div className={classes} key={id} onClick={() => onCellClick(id)} style={{width: `${width}px`}}>
|
||||
<span
|
||||
className="table__heading__label"
|
||||
title={intl.formatMessage({
|
||||
id: TorrentListColumns[id].id,
|
||||
})}>
|
||||
{label}
|
||||
</span>
|
||||
{handle}
|
||||
</div>,
|
||||
);
|
||||
|
||||
return accumulator;
|
||||
}, []);
|
||||
}
|
||||
|
||||
handlePointerMove(event: PointerEvent) {
|
||||
let widthDelta = 0;
|
||||
if (this.lastPointerX != null) {
|
||||
widthDelta = event.clientX - this.lastPointerX;
|
||||
}
|
||||
|
||||
let nextCellWidth = 20;
|
||||
if (this.focusedCellWidth != null) {
|
||||
nextCellWidth = this.focusedCellWidth + widthDelta;
|
||||
}
|
||||
|
||||
if (nextCellWidth > 20) {
|
||||
this.focusedCellWidth = nextCellWidth;
|
||||
this.lastPointerX = event.clientX;
|
||||
if (this.resizeLine != null) {
|
||||
this.resizeLine.style.transform = `translateX(${Math.max(
|
||||
0,
|
||||
event.clientX - this.tableHeadingX + this.props.scrollOffset,
|
||||
)}px)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlePointerUp() {
|
||||
UIStore.removeGlobalStyle(pointerDownStyles);
|
||||
global.document.removeEventListener('pointerup', this.handlePointerUp);
|
||||
global.document.removeEventListener('pointermove', (e) => this.handlePointerMove(e));
|
||||
|
||||
this.isPointerDown = false;
|
||||
this.lastPointerX = null;
|
||||
|
||||
if (this.resizeLine != null) {
|
||||
this.resizeLine.style.opacity = '0';
|
||||
}
|
||||
|
||||
if (this.focusedCell != null && this.focusedCellWidth != null) {
|
||||
this.props.onWidthsChange(this.focusedCell, this.focusedCellWidth);
|
||||
}
|
||||
|
||||
this.focusedCell = null;
|
||||
this.focusedCellWidth = null;
|
||||
}
|
||||
|
||||
handlePointerDown(event: React.PointerEvent, slug: TorrentListColumn, width: number) {
|
||||
if (!this.isPointerDown && this.resizeLine != null) {
|
||||
global.document.addEventListener('pointerup', this.handlePointerUp);
|
||||
global.document.addEventListener('pointermove', this.handlePointerMove);
|
||||
UIStore.addGlobalStyle(pointerDownStyles);
|
||||
|
||||
this.focusedCell = slug;
|
||||
this.focusedCellWidth = width;
|
||||
this.isPointerDown = true;
|
||||
this.lastPointerX = event.clientX;
|
||||
this.resizeLine.style.transform = `translateX(${Math.max(
|
||||
0,
|
||||
event.clientX - this.tableHeadingX + this.props.scrollOffset,
|
||||
)}px)`;
|
||||
this.resizeLine.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
className="table__row table__row--heading"
|
||||
ref={(ref) => {
|
||||
this.tableHeading = ref;
|
||||
}}>
|
||||
{this.getHeadingElements()}
|
||||
<div className="table__cell table__heading table__heading--fill" />
|
||||
<div
|
||||
className="table__heading__resize-line"
|
||||
ref={(ref) => {
|
||||
this.resizeLine = ref;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(TableHeading);
|
||||
@@ -1,249 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import ProgressBar from '../general/ProgressBar';
|
||||
import torrentStatusIcons from '../../util/torrentStatusIcons';
|
||||
import torrentStatusClasses from '../../util/torrentStatusClasses';
|
||||
import TorrentDetail from './TorrentDetail';
|
||||
|
||||
const condensedValueTransformers = {
|
||||
downloadTotal: (torrent) => torrent.bytesDone,
|
||||
peers: (torrent) => torrent.peersConnected,
|
||||
percentComplete: (torrent) => (
|
||||
<ProgressBar percent={torrent.percentComplete} icon={torrentStatusIcons(torrent.status)} />
|
||||
),
|
||||
seeds: (torrent) => torrent.seedsConnected,
|
||||
};
|
||||
|
||||
const condensedSecondaryValueTransformers = {
|
||||
peers: (torrent) => torrent.peersTotal,
|
||||
seeds: (torrent) => torrent.seedsTotal,
|
||||
};
|
||||
|
||||
const expandedTorrentSectionContent = {
|
||||
primary: ['name'],
|
||||
secondary: ['eta', 'downRate', 'upRate'],
|
||||
tertiary: ['*'],
|
||||
};
|
||||
|
||||
const expandedTorrentDetailsToHide = ['downTotal'];
|
||||
|
||||
const expandedValueTransformers = {
|
||||
peers: (torrent) => torrent.peersConnected,
|
||||
seeds: (torrent) => torrent.seedsConnected,
|
||||
};
|
||||
|
||||
const expandedSecondaryValueTransformers = {
|
||||
peers: (torrent) => torrent.peersTotal,
|
||||
seeds: (torrent) => torrent.seedsTotal,
|
||||
percentComplete: (torrent) => torrent.bytesDone,
|
||||
};
|
||||
|
||||
const METHODS_TO_BIND = ['handleClick', 'handleDoubleClick', 'handleRightClick'];
|
||||
|
||||
const TORRENT_PRIMITIVES_TO_OBSERVE = ['bytesDone', 'downRate', 'peersTotal', 'seedsTotal', 'upRate'];
|
||||
|
||||
const TORRENT_ARRAYS_TO_OBSERVE = ['status', 'tags'];
|
||||
|
||||
class Torrent extends React.Component {
|
||||
static defaultProps = {
|
||||
isCondensed: false,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
METHODS_TO_BIND.forEach((method) => {
|
||||
this[method] = this[method].bind(this);
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.torrentRef != null) {
|
||||
this.torrentRef.addEventListener('long-press', this.handleRightClick);
|
||||
}
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
if (nextProps.isSelected !== this.props.isSelected || nextProps.isCondensed !== this.props.isCondensed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextTorrent = nextProps.torrent;
|
||||
const {torrent} = this.props;
|
||||
|
||||
let shouldUpdate = TORRENT_ARRAYS_TO_OBSERVE.some((key) => {
|
||||
const nextArr = nextTorrent[key];
|
||||
const currentArr = this.props.torrent[key];
|
||||
|
||||
return (
|
||||
nextArr.length !== currentArr.length || nextArr.some((nextValue, index) => nextValue !== currentArr[index])
|
||||
);
|
||||
});
|
||||
|
||||
if (!shouldUpdate) {
|
||||
shouldUpdate = TORRENT_PRIMITIVES_TO_OBSERVE.some((key) => nextTorrent[key] !== torrent[key]);
|
||||
}
|
||||
|
||||
if (!shouldUpdate) {
|
||||
shouldUpdate = Object.keys(nextProps.propWidths).some(
|
||||
(key) => nextProps.propWidths[key] !== this.props.propWidths[key],
|
||||
);
|
||||
}
|
||||
|
||||
if (!shouldUpdate) {
|
||||
shouldUpdate = nextProps.columns.some(({id}, index) => id !== this.props.columns[index].id);
|
||||
}
|
||||
|
||||
return shouldUpdate;
|
||||
}
|
||||
|
||||
getTags(tags) {
|
||||
return tags.map((tag) => (
|
||||
<li className="torrent__tag" key={tag}>
|
||||
{tag}
|
||||
</li>
|
||||
));
|
||||
}
|
||||
|
||||
getWidth(slug) {
|
||||
const {defaultWidth, defaultPropWidths, propWidths} = this.props;
|
||||
|
||||
return propWidths[slug] || defaultPropWidths[slug] || defaultWidth;
|
||||
}
|
||||
|
||||
handleClick(event) {
|
||||
this.props.handleClick(this.props.torrent.hash, event);
|
||||
}
|
||||
|
||||
handleDoubleClick(event) {
|
||||
this.props.handleDoubleClick(this.props.torrent, event);
|
||||
}
|
||||
|
||||
handleRightClick(event) {
|
||||
if (!this.props.isSelected) {
|
||||
this.handleClick(event);
|
||||
}
|
||||
|
||||
this.props.handleRightClick(this.props.torrent, event);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {isCondensed, isSelected, columns, torrent} = this.props;
|
||||
const torrentClasses = torrentStatusClasses(
|
||||
torrent,
|
||||
{
|
||||
'torrent--is-selected': isSelected,
|
||||
'torrent--is-condensed': isCondensed,
|
||||
'torrent--is-expanded': !isCondensed,
|
||||
},
|
||||
'torrent',
|
||||
);
|
||||
|
||||
if (isCondensed) {
|
||||
const torrentPropertyColumns = columns.reduce((accumulator, {id, visible}) => {
|
||||
if (!visible) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
let value = torrent[id];
|
||||
let secondaryValue;
|
||||
|
||||
if (id in condensedValueTransformers) {
|
||||
value = condensedValueTransformers[id](torrent);
|
||||
}
|
||||
|
||||
if (id in condensedSecondaryValueTransformers) {
|
||||
secondaryValue = condensedSecondaryValueTransformers[id](torrent);
|
||||
}
|
||||
|
||||
accumulator.push(
|
||||
<TorrentDetail
|
||||
className="table__cell"
|
||||
key={id}
|
||||
preventTransform={id === 'percentComplete'}
|
||||
secondaryValue={secondaryValue}
|
||||
slug={id}
|
||||
value={value}
|
||||
width={this.getWidth(id)}
|
||||
/>,
|
||||
);
|
||||
|
||||
return accumulator;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={torrentClasses}
|
||||
onClick={this.handleClick}
|
||||
onContextMenu={this.handleRightClick}
|
||||
onDoubleClick={this.handleDoubleClick}
|
||||
ref={(ref) => {
|
||||
this.torrentRef = ref;
|
||||
}}>
|
||||
{torrentPropertyColumns}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const sections = {primary: [], secondary: [], tertiary: []};
|
||||
|
||||
// Using a for loop to maximize performance.
|
||||
for (let index = 0; index < columns.length; index++) {
|
||||
const {id, visible} = columns[index];
|
||||
|
||||
if (visible && !expandedTorrentDetailsToHide.includes(id)) {
|
||||
let value = torrent[id];
|
||||
let secondaryValue;
|
||||
|
||||
if (id in expandedValueTransformers) {
|
||||
value = expandedValueTransformers[id](torrent);
|
||||
}
|
||||
|
||||
if (id in expandedSecondaryValueTransformers) {
|
||||
secondaryValue = expandedSecondaryValueTransformers[id](torrent);
|
||||
}
|
||||
|
||||
if (expandedTorrentSectionContent.primary.includes(id)) {
|
||||
sections.primary.push(
|
||||
<TorrentDetail
|
||||
key={id}
|
||||
className="torrent__details__section torrent__details__section--primary"
|
||||
slug={id}
|
||||
value={value}
|
||||
/>,
|
||||
);
|
||||
} else if (expandedTorrentSectionContent.secondary.includes(id)) {
|
||||
sections.secondary[expandedTorrentSectionContent.secondary.indexOf(id)] = (
|
||||
<TorrentDetail icon key={id} secondaryValue={secondaryValue} slug={id} value={value} />
|
||||
);
|
||||
} else {
|
||||
sections.tertiary.push(
|
||||
<TorrentDetail icon key={id} secondaryValue={secondaryValue} slug={id} value={value} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={torrentClasses}
|
||||
onClick={this.handleClick}
|
||||
onContextMenu={this.handleRightClick}
|
||||
onDoubleClick={this.handleDoubleClick}
|
||||
ref={(ref) => {
|
||||
this.torrentRef = ref;
|
||||
}}>
|
||||
<div className="torrent__details__section__wrapper">
|
||||
{sections.primary}
|
||||
<div className="torrent__details__section torrent__details__section--secondary">{sections.secondary}</div>
|
||||
</div>
|
||||
<div className="torrent__details__section torrent__details__section--tertiary">{sections.tertiary}</div>
|
||||
<div className="torrent__details__section torrent__details__section--quaternary">
|
||||
<ProgressBar percent={torrent.percentComplete} icon={torrentStatusIcons(torrent.status)} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Torrent;
|
||||
@@ -1,135 +0,0 @@
|
||||
import {FormattedDate, FormattedMessage, FormattedNumber} from 'react-intl';
|
||||
import React from 'react';
|
||||
|
||||
import CalendarCreatedIcon from '../icons/CalendarCreatedIcon';
|
||||
import CalendarIcon from '../icons/CalendarIcon';
|
||||
import Checkmark from '../icons/Checkmark';
|
||||
import ClockIcon from '../icons/ClockIcon';
|
||||
import CommentIcon from '../icons/CommentIcon';
|
||||
import DetailNotAvailableIcon from '../icons/DetailNotAvailableIcon';
|
||||
import DiskIcon from '../icons/DiskIcon';
|
||||
import DownloadThickIcon from '../icons/DownloadThickIcon';
|
||||
import Duration from '../general/Duration';
|
||||
import HashIcon from '../icons/HashIcon';
|
||||
import FolderClosedSolid from '../icons/FolderClosedSolid';
|
||||
import PeersIcon from '../icons/PeersIcon';
|
||||
import LockIcon from '../icons/LockIcon';
|
||||
import Ratio from '../general/Ratio';
|
||||
import RadarIcon from '../icons/RadarIcon';
|
||||
import RatioIcon from '../icons/RatioIcon';
|
||||
import SeedsIcon from '../icons/SeedsIcon';
|
||||
import Size from '../general/Size';
|
||||
import TrackerMessageIcon from '../icons/TrackerMessageIcon';
|
||||
import UploadThickIcon from '../icons/UploadThickIcon';
|
||||
|
||||
const icons = {
|
||||
checkmark: <Checkmark className="torrent__detail__icon torrent__detail__icon--checkmark" />,
|
||||
comment: <CommentIcon />,
|
||||
eta: <ClockIcon />,
|
||||
sizeBytes: <DiskIcon />,
|
||||
downRate: <DownloadThickIcon />,
|
||||
basePath: <FolderClosedSolid />,
|
||||
hash: <HashIcon />,
|
||||
dateAdded: <CalendarIcon />,
|
||||
dateCreated: <CalendarCreatedIcon />,
|
||||
isPrivate: <LockIcon />,
|
||||
message: <TrackerMessageIcon />,
|
||||
percentComplete: <DownloadThickIcon />,
|
||||
peers: <PeersIcon />,
|
||||
ratio: <RatioIcon />,
|
||||
seeds: <SeedsIcon />,
|
||||
trackerURIs: <RadarIcon />,
|
||||
upRate: <UploadThickIcon />,
|
||||
upTotal: <UploadThickIcon />,
|
||||
};
|
||||
|
||||
const booleanRenderer = (value) => (value ? icons.checkmark : null);
|
||||
const dateRenderer = (date) => <FormattedDate value={date * 1000} />;
|
||||
const peersRenderer = (peersConnected, totalPeers) => (
|
||||
<FormattedMessage
|
||||
id="torrent.list.peers"
|
||||
values={{
|
||||
connected: <FormattedNumber value={peersConnected} />,
|
||||
of: (
|
||||
<em className="unit">
|
||||
<FormattedMessage id="torrent.list.peers.of" />
|
||||
</em>
|
||||
),
|
||||
total: <FormattedNumber value={totalPeers} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const speedRenderer = (value) => <Size value={value} isSpeed />;
|
||||
const sizeRenderer = (value) => <Size value={value} />;
|
||||
|
||||
const transformers = {
|
||||
dateAdded: dateRenderer,
|
||||
dateCreated: dateRenderer,
|
||||
downRate: speedRenderer,
|
||||
downTotal: sizeRenderer,
|
||||
isPrivate: booleanRenderer,
|
||||
percentComplete: (percent, size) => (
|
||||
<span>
|
||||
<FormattedNumber value={percent} />
|
||||
<em className="unit">%</em>
|
||||
—
|
||||
<Size value={size} />
|
||||
</span>
|
||||
),
|
||||
peers: peersRenderer,
|
||||
seeds: peersRenderer,
|
||||
tags: (tags) => (
|
||||
<ul className="torrent__tags tag">
|
||||
{tags.map((tag) => (
|
||||
<li className="torrent__tag" key={tag}>
|
||||
{tag}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
ratio: (ratio) => <Ratio value={ratio} />,
|
||||
sizeBytes: sizeRenderer,
|
||||
trackerURIs: (trackers) => trackers.join(', '),
|
||||
upRate: speedRenderer,
|
||||
upTotal: sizeRenderer,
|
||||
eta: (eta) => {
|
||||
if (!eta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Duration value={eta} />;
|
||||
},
|
||||
};
|
||||
|
||||
class TorrentDetail extends React.PureComponent {
|
||||
static defaultProps = {
|
||||
preventTransform: false,
|
||||
className: '',
|
||||
};
|
||||
|
||||
render() {
|
||||
const {className, preventTransform, secondaryValue, slug, width} = this.props;
|
||||
let {icon, value} = this.props;
|
||||
|
||||
if (!preventTransform && slug in transformers) {
|
||||
value = transformers[slug](value, secondaryValue);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
value = <DetailNotAvailableIcon />;
|
||||
}
|
||||
|
||||
if (icon) {
|
||||
icon = icons[slug];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`torrent__detail torrent__detail--${slug} ${className}`} style={{width: `${width}px`}}>
|
||||
{icon}
|
||||
{value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TorrentDetail;
|
||||
@@ -1,8 +1,14 @@
|
||||
import {FormattedMessage, injectIntl} from 'react-intl';
|
||||
import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl';
|
||||
import debounce from 'lodash/debounce';
|
||||
import Dropzone from 'react-dropzone';
|
||||
import {Scrollbars} from 'react-custom-scrollbars';
|
||||
import React from 'react';
|
||||
|
||||
import defaultFloodSettings from '@shared/constants/defaultFloodSettings';
|
||||
|
||||
import type {FloodSettings} from '@shared/types/FloodSettings';
|
||||
import type {TorrentProperties} from '@shared/types/Torrent';
|
||||
|
||||
import {Button} from '../../ui';
|
||||
import ClientStatusStore from '../../stores/ClientStatusStore';
|
||||
import connectStores from '../../util/connectStores';
|
||||
@@ -12,23 +18,70 @@ import GlobalContextMenuMountPoint from '../general/GlobalContextMenuMountPoint'
|
||||
import ListViewport from '../general/ListViewport';
|
||||
import SettingsStore from '../../stores/SettingsStore';
|
||||
import TableHeading from './TableHeading';
|
||||
import Torrent from './Torrent';
|
||||
import TorrentActions from '../../actions/TorrentActions';
|
||||
import TorrentFilterStore from '../../stores/TorrentFilterStore';
|
||||
import TorrentListContextMenu from './TorrentListContextMenu';
|
||||
import TorrentListRow from './TorrentListRow';
|
||||
import TorrentStore from '../../stores/TorrentStore';
|
||||
import UIActions from '../../actions/UIActions';
|
||||
|
||||
const defaultWidth = 100;
|
||||
const defaultPropWidths = {
|
||||
name: 200,
|
||||
eta: 100,
|
||||
import type {TorrentListColumn} from '../../constants/TorrentListColumns';
|
||||
|
||||
const getEmptyTorrentListNotification = () => {
|
||||
let clearFilters = null;
|
||||
|
||||
if (TorrentFilterStore.isFilterActive()) {
|
||||
clearFilters = (
|
||||
<div className="torrents__alert__action">
|
||||
<Button
|
||||
onClick={() => {
|
||||
TorrentFilterStore.clearAllFilters();
|
||||
TorrentStore.triggerTorrentsFilter();
|
||||
}}
|
||||
priority="tertiary">
|
||||
<FormattedMessage id="torrents.list.clear.filters" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="torrents__alert__wrapper">
|
||||
<div className="torrents__alert">
|
||||
<FormattedMessage id="torrents.list.no.torrents" />
|
||||
</div>
|
||||
{clearFilters}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
class TorrentListContainer extends React.Component {
|
||||
const handleClick = (torrent: TorrentProperties, event: React.MouseEvent) =>
|
||||
UIActions.handleTorrentClick({hash: torrent.hash, event});
|
||||
const handleDoubleClick = (torrent: TorrentProperties, event: React.MouseEvent) =>
|
||||
TorrentListContextMenu.handleDetailsClick(torrent, event);
|
||||
|
||||
interface TorrentListProps extends WrappedComponentProps {
|
||||
torrents?: Array<TorrentProperties>;
|
||||
torrentListViewSize?: FloodSettings['torrentListViewSize'];
|
||||
torrentListColumns?: FloodSettings['torrentListColumns'];
|
||||
torrentListColumnWidths?: FloodSettings['torrentListColumnWidths'];
|
||||
torrentContextMenuActions?: FloodSettings['torrentContextMenuActions'];
|
||||
isClientConnected?: boolean;
|
||||
}
|
||||
|
||||
interface TorrentListStates extends Record<string, unknown> {
|
||||
tableScrollLeft: number;
|
||||
torrentListViewportSize: number | null;
|
||||
}
|
||||
|
||||
class TorrentList extends React.Component<TorrentListProps, TorrentListStates> {
|
||||
listContainer: HTMLDivElement | null = null;
|
||||
listViewportRef: ListViewport | null = null;
|
||||
horizontalScrollRef: Scrollbars | null = null;
|
||||
verticalScrollbarThumb: HTMLDivElement | null = null;
|
||||
lastScrollLeft = 0;
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: TorrentListProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
tableScrollLeft: 0,
|
||||
@@ -42,7 +95,7 @@ class TorrentListContainer extends React.Component {
|
||||
global.addEventListener('resize', this.updateTorrentListViewWidth);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
componentDidUpdate(prevProps: TorrentListProps) {
|
||||
const {torrentListViewSize: currentTorrentListViewSize} = this.props;
|
||||
const isCondensed = currentTorrentListViewSize === 'condensed';
|
||||
const wasCondensed = prevProps.torrentListViewSize === 'condensed';
|
||||
@@ -54,7 +107,7 @@ class TorrentListContainer extends React.Component {
|
||||
if (this.verticalScrollbarThumb != null) {
|
||||
if (!isCondensed && wasCondensed) {
|
||||
this.updateVerticalThumbPosition(0);
|
||||
} else if (isCondensed) {
|
||||
} else if (isCondensed && this.listContainer != null) {
|
||||
this.updateVerticalThumbPosition(
|
||||
(this.getTotalCellWidth() - this.listContainer.clientWidth) * -1 + this.lastScrollLeft,
|
||||
);
|
||||
@@ -72,32 +125,70 @@ class TorrentListContainer extends React.Component {
|
||||
global.removeEventListener('resize', this.updateTorrentListViewWidth);
|
||||
}
|
||||
|
||||
handleClearFiltersClick() {
|
||||
TorrentFilterStore.clearAllFilters();
|
||||
TorrentStore.triggerTorrentsFilter();
|
||||
getCellWidth(slug: TorrentListColumn) {
|
||||
if (this.props.torrentListColumnWidths == null) {
|
||||
return defaultFloodSettings.torrentListColumnWidths[slug];
|
||||
}
|
||||
|
||||
const value = this.props.torrentListColumnWidths[slug] || defaultFloodSettings.torrentListColumnWidths[slug];
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
handleDoubleClick = (torrent, event) => {
|
||||
TorrentListContextMenu.handleDetailsClick(torrent, event);
|
||||
};
|
||||
getListWrapperStyle(options: {isCondensed: boolean; isListEmpty: boolean}): React.CSSProperties {
|
||||
if (options.isCondensed && !options.isListEmpty) {
|
||||
const totalCellWidth = this.getTotalCellWidth();
|
||||
|
||||
handleContextMenuClick = (torrent, event) => {
|
||||
if (this.state.torrentListViewportSize != null && totalCellWidth >= this.state.torrentListViewportSize) {
|
||||
return {width: `${totalCellWidth}px`};
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
getTotalCellWidth() {
|
||||
const torrentListColumns = this.props.torrentListColumns || defaultFloodSettings.torrentListColumns;
|
||||
|
||||
return torrentListColumns.reduce((accumulator, {id, visible}) => {
|
||||
if (!visible) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
return accumulator + this.getCellWidth(id);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
handleContextMenuClick = (torrent: TorrentProperties, event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!TorrentStore.getSelectedTorrents().includes(torrent.hash)) {
|
||||
UIActions.handleTorrentClick({hash: torrent.hash, event});
|
||||
}
|
||||
|
||||
UIActions.displayContextMenu({
|
||||
id: 'torrent-list-item',
|
||||
clickPosition: {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
},
|
||||
items: TorrentListContextMenu.getContextMenuItems(this.props.intl, torrent, this.props.torrentContextMenuItems),
|
||||
items: TorrentListContextMenu.getContextMenuItems(this.props.intl, torrent).filter((item) => {
|
||||
if (item.type === 'separator') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const torrentContextMenuActions =
|
||||
this.props.torrentContextMenuActions || defaultFloodSettings.torrentContextMenuActions;
|
||||
|
||||
return !torrentContextMenuActions.some((action) => action.id === item.action && action.visible === false);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
handleFileDrop = (files) => {
|
||||
const filesData = [];
|
||||
handleFileDrop = (files: Array<File>) => {
|
||||
const filesData: Array<string> = [];
|
||||
|
||||
const callback = (data) => {
|
||||
const callback = (data: string) => {
|
||||
filesData.concat(data);
|
||||
|
||||
if (filesData.length === files.length) {
|
||||
@@ -134,63 +225,11 @@ class TorrentListContainer extends React.Component {
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
getEmptyTorrentListNotification() {
|
||||
let clearFilters = null;
|
||||
|
||||
if (TorrentFilterStore.isFilterActive()) {
|
||||
clearFilters = (
|
||||
<div className="torrents__alert__action">
|
||||
<Button onClick={this.handleClearFiltersClick} priority="tertiary">
|
||||
<FormattedMessage id="torrents.list.clear.filters" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="torrents__alert__wrapper">
|
||||
<div className="torrents__alert">
|
||||
<FormattedMessage id="torrents.list.no.torrents" />
|
||||
</div>
|
||||
{clearFilters}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getCellWidth(slug) {
|
||||
const value = this.props.torrentListColumnWidths[slug] || defaultPropWidths[slug] || defaultWidth;
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
getListWrapperStyle(options = {}) {
|
||||
if (options.isCondensed && !options.isListEmpty) {
|
||||
const totalCellWidth = this.getTotalCellWidth();
|
||||
|
||||
if (totalCellWidth >= this.state.torrentListViewportSize) {
|
||||
return {width: `${totalCellWidth}px`};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getTotalCellWidth() {
|
||||
return this.props.displayedProperties.reduce((accumulator, {id, visible}) => {
|
||||
if (!visible) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
return accumulator + this.getCellWidth(id);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
getVerticalScrollbarThumb = (props, onMouseUp) => {
|
||||
getVerticalScrollbarThumb: React.StatelessComponent = (props) => {
|
||||
return (
|
||||
<div {...props}>
|
||||
<div
|
||||
className="scrollbars__thumb scrollbars__thumb--horizontal scrollbars__thumb--surrogate"
|
||||
onMouseUp={onMouseUp}
|
||||
ref={(ref) => {
|
||||
this.verticalScrollbarThumb = ref;
|
||||
}}
|
||||
@@ -201,31 +240,9 @@ class TorrentListContainer extends React.Component {
|
||||
);
|
||||
};
|
||||
|
||||
handleTableHeadingCellClick(slug) {
|
||||
const currentSort = TorrentStore.getTorrentsSort();
|
||||
|
||||
let nextDirection = 'asc';
|
||||
|
||||
if (currentSort.property === slug) {
|
||||
nextDirection = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
const sortBy = {
|
||||
property: slug,
|
||||
direction: nextDirection,
|
||||
};
|
||||
|
||||
SettingsStore.setFloodSetting('sortTorrents', sortBy);
|
||||
UIActions.setTorrentsSort(sortBy);
|
||||
}
|
||||
|
||||
handleTorrentClick(hash, event) {
|
||||
UIActions.handleTorrentClick({hash, event});
|
||||
}
|
||||
|
||||
handleHorizontalScroll = (event) => {
|
||||
handleHorizontalScroll = (event: React.UIEvent) => {
|
||||
if (this.verticalScrollbarThumb != null) {
|
||||
const {clientWidth, scrollLeft, scrollWidth} = event.target;
|
||||
const {clientWidth, scrollLeft, scrollWidth} = event.target as HTMLElement;
|
||||
this.lastScrollLeft = scrollLeft;
|
||||
this.updateVerticalThumbPosition((scrollWidth - clientWidth) * -1 + scrollLeft);
|
||||
}
|
||||
@@ -235,8 +252,11 @@ class TorrentListContainer extends React.Component {
|
||||
this.setState({tableScrollLeft: this.lastScrollLeft});
|
||||
};
|
||||
|
||||
handlePropWidthChange = (newPropWidths) => {
|
||||
SettingsStore.setFloodSetting('torrentListColumnWidths', {...this.props.torrentListColumnWidths, ...newPropWidths});
|
||||
handleColumnWidthChange = (column: TorrentListColumn, width: number) => {
|
||||
SettingsStore.setFloodSetting('torrentListColumnWidths', {
|
||||
...(this.props.torrentListColumnWidths || defaultFloodSettings.torrentListColumnWidths),
|
||||
[column]: width,
|
||||
});
|
||||
};
|
||||
|
||||
/* eslint-disable react/sort-comp */
|
||||
@@ -244,7 +264,7 @@ class TorrentListContainer extends React.Component {
|
||||
() => {
|
||||
if (this.horizontalScrollRef != null) {
|
||||
this.setState({
|
||||
torrentListViewportSize: this.horizontalScrollRef.scrollbarRef.getClientWidth(),
|
||||
torrentListViewportSize: this.horizontalScrollRef.getClientWidth(),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -253,46 +273,53 @@ class TorrentListContainer extends React.Component {
|
||||
);
|
||||
/* eslint-enable react/sort-comp */
|
||||
|
||||
updateVerticalThumbPosition = (offset) => {
|
||||
this.verticalScrollbarThumb.style.transform = `translateX(${offset}px)`;
|
||||
updateVerticalThumbPosition = (offset: number) => {
|
||||
if (this.verticalScrollbarThumb != null) {
|
||||
this.verticalScrollbarThumb.style.transform = `translateX(${offset}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
renderListItem = (index) => {
|
||||
renderListItem = (index: number) => {
|
||||
const {torrentListViewSize, torrents} = this.props;
|
||||
const selectedTorrents = TorrentStore.getSelectedTorrents();
|
||||
const {displayedProperties, torrentListViewSize, torrentListColumnWidths, torrents} = this.props;
|
||||
const torrentListColumns = this.props.torrentListColumns || defaultFloodSettings.torrentListColumns;
|
||||
const torrentListColumnWidths = this.props.torrentListColumnWidths || defaultFloodSettings.torrentListColumnWidths;
|
||||
|
||||
if (torrents == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const torrent = torrents[index];
|
||||
const {hash} = torrent;
|
||||
|
||||
return (
|
||||
<Torrent
|
||||
defaultPropWidths={defaultPropWidths}
|
||||
defaultWidth={defaultWidth}
|
||||
handleClick={this.handleTorrentClick}
|
||||
handleDetailsClick={this.handleDoubleClick}
|
||||
handleDoubleClick={this.handleDoubleClick}
|
||||
<TorrentListRow
|
||||
handleClick={handleClick}
|
||||
handleDoubleClick={handleDoubleClick}
|
||||
handleRightClick={this.handleContextMenuClick}
|
||||
index={index}
|
||||
isCondensed={torrentListViewSize === 'condensed'}
|
||||
key={hash}
|
||||
columns={displayedProperties}
|
||||
propWidths={torrentListColumnWidths}
|
||||
isSelected={selectedTorrents.includes(hash)}
|
||||
key={torrent.hash}
|
||||
columns={torrentListColumns}
|
||||
columnWidths={torrentListColumnWidths}
|
||||
isSelected={selectedTorrents.includes(torrent.hash)}
|
||||
torrent={torrent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {displayedProperties, torrentListColumnWidths, isClientConnected, torrentListViewSize, torrents} = this.props;
|
||||
let content = null;
|
||||
let torrentListHeading = null;
|
||||
const {isClientConnected, torrentListViewSize, torrents} = this.props;
|
||||
const torrentListColumns = this.props.torrentListColumns || defaultFloodSettings.torrentListColumns;
|
||||
const torrentListColumnWidths = this.props.torrentListColumnWidths || defaultFloodSettings.torrentListColumnWidths;
|
||||
|
||||
const isCondensed = torrentListViewSize === 'condensed';
|
||||
const isListEmpty = torrents.length === 0;
|
||||
const isListEmpty = torrents == null || torrents.length === 0;
|
||||
const listWrapperStyle = this.getListWrapperStyle({
|
||||
isCondensed,
|
||||
isListEmpty,
|
||||
});
|
||||
|
||||
let content: React.ReactNode = null;
|
||||
let torrentListHeading: React.ReactNode = null;
|
||||
if (!isClientConnected) {
|
||||
content = (
|
||||
<div className="torrents__alert__wrapper">
|
||||
@@ -301,8 +328,8 @@ class TorrentListContainer extends React.Component {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (isListEmpty) {
|
||||
content = this.getEmptyTorrentListNotification();
|
||||
} else if (isListEmpty || torrents == null) {
|
||||
content = getEmptyTorrentListNotification();
|
||||
} else {
|
||||
content = (
|
||||
<ListViewport
|
||||
@@ -320,30 +347,42 @@ class TorrentListContainer extends React.Component {
|
||||
if (isCondensed) {
|
||||
torrentListHeading = (
|
||||
<TableHeading
|
||||
columns={displayedProperties}
|
||||
defaultWidth={defaultWidth}
|
||||
defaultPropWidths={defaultPropWidths}
|
||||
onCellClick={this.handleTableHeadingCellClick}
|
||||
onWidthsChange={this.handlePropWidthChange}
|
||||
propWidths={torrentListColumnWidths}
|
||||
columns={torrentListColumns}
|
||||
columnWidths={torrentListColumnWidths}
|
||||
scrollOffset={this.state.tableScrollLeft}
|
||||
sortProp={TorrentStore.getTorrentsSort()}
|
||||
onCellClick={(property: TorrentListColumn) => {
|
||||
const currentSort = TorrentStore.getTorrentsSort();
|
||||
|
||||
let nextDirection: FloodSettings['sortTorrents']['direction'] = 'asc';
|
||||
|
||||
if (currentSort.property === property) {
|
||||
nextDirection = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
const sortBy = {
|
||||
property,
|
||||
direction: nextDirection,
|
||||
};
|
||||
|
||||
SettingsStore.setFloodSetting('sortTorrents', sortBy);
|
||||
UIActions.setTorrentsSort(sortBy);
|
||||
}}
|
||||
onWidthsChange={this.handleColumnWidthChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
onDrop={this.handleFileDrop}
|
||||
ref={(ref) => {
|
||||
this.listContainer = ref;
|
||||
}}>
|
||||
<Dropzone onDrop={this.handleFileDrop} noClick noKeyboard>
|
||||
{({getRootProps, isDragActive}) => (
|
||||
<div
|
||||
{...getRootProps({onClick: (evt) => evt.preventDefault()})}
|
||||
className={`dropzone dropzone--with-overlay torrents ${isDragActive ? 'dropzone--is-dragging' : ''}`}
|
||||
tabIndex="none">
|
||||
ref={(ref) => {
|
||||
this.listContainer = ref;
|
||||
}}>
|
||||
<CustomScrollbars
|
||||
className="torrent__list__scrollbars--horizontal"
|
||||
onScrollStop={this.handleHorizontalScrollStop}
|
||||
@@ -373,42 +412,42 @@ class TorrentListContainer extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
const ConnectedTorrentList = connectStores(injectIntl(TorrentListContainer), () => {
|
||||
return [
|
||||
{
|
||||
store: ClientStatusStore,
|
||||
event: 'CLIENT_CONNECTION_STATUS_CHANGE',
|
||||
getValue: ({store}) => {
|
||||
const storeClientStatus = store;
|
||||
return {
|
||||
isClientConnected: storeClientStatus.getIsConnected(),
|
||||
};
|
||||
const ConnectedTorrentList = connectStores<Omit<TorrentListProps, 'intl'>, TorrentListStates>(
|
||||
injectIntl(TorrentList),
|
||||
() => {
|
||||
return [
|
||||
{
|
||||
store: ClientStatusStore,
|
||||
event: 'CLIENT_CONNECTION_STATUS_CHANGE',
|
||||
getValue: () => {
|
||||
return {
|
||||
isClientConnected: ClientStatusStore.getIsConnected(),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
store: SettingsStore,
|
||||
event: 'SETTINGS_CHANGE',
|
||||
getValue: ({store}) => {
|
||||
const storeSettings = store;
|
||||
return {
|
||||
displayedProperties: storeSettings.getFloodSetting('torrentDetails'),
|
||||
torrentContextMenuItems: storeSettings.getFloodSetting('torrentContextMenuItems'),
|
||||
torrentListColumnWidths: storeSettings.getFloodSetting('torrentListColumnWidths'),
|
||||
torrentListViewSize: storeSettings.getFloodSetting('torrentListViewSize'),
|
||||
};
|
||||
{
|
||||
store: SettingsStore,
|
||||
event: 'SETTINGS_CHANGE',
|
||||
getValue: () => {
|
||||
return {
|
||||
torrentContextMenuActions: SettingsStore.getFloodSetting('torrentContextMenuActions'),
|
||||
torrentListColumns: SettingsStore.getFloodSetting('torrentListColumns'),
|
||||
torrentListColumnWidths: SettingsStore.getFloodSetting('torrentListColumnWidths'),
|
||||
torrentListViewSize: SettingsStore.getFloodSetting('torrentListViewSize'),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
store: TorrentStore,
|
||||
event: ['UI_TORRENTS_LIST_FILTERED', 'CLIENT_TORRENTS_REQUEST_SUCCESS'],
|
||||
getValue: ({store}) => {
|
||||
const storeTorrent = store;
|
||||
return {
|
||||
torrents: storeTorrent.getTorrents(),
|
||||
};
|
||||
{
|
||||
store: TorrentStore,
|
||||
event: ['UI_TORRENTS_LIST_FILTERED', 'CLIENT_TORRENTS_REQUEST_SUCCESS'],
|
||||
getValue: () => {
|
||||
return {
|
||||
torrents: TorrentStore.getTorrents(),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
export default ConnectedTorrentList;
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
|
||||
import DetailNotAvailableIcon from '../icons/DetailNotAvailableIcon';
|
||||
import torrentPropertyIcons from '../../util/torrentPropertyIcons';
|
||||
|
||||
import type {TorrentListColumn} from '../../constants/TorrentListColumns';
|
||||
|
||||
interface TorrentListCellProps {
|
||||
content: React.ReactNode;
|
||||
column: TorrentListColumn;
|
||||
className?: string;
|
||||
width?: number;
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
const TorrentListCell: React.FC<TorrentListCellProps> = ({
|
||||
content,
|
||||
column,
|
||||
className,
|
||||
width,
|
||||
showIcon,
|
||||
}: TorrentListCellProps) => {
|
||||
const icon = showIcon ? torrentPropertyIcons[column as keyof typeof torrentPropertyIcons] : null;
|
||||
|
||||
return (
|
||||
<div className={`torrent__detail torrent__detail--${column} ${className}`} style={{width: `${width}px`}}>
|
||||
{icon}
|
||||
{content || <DetailNotAvailableIcon />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TorrentListCell.defaultProps = {
|
||||
className: undefined,
|
||||
width: undefined,
|
||||
showIcon: false,
|
||||
};
|
||||
|
||||
export default React.memo(TorrentListCell);
|
||||
@@ -1,16 +1,23 @@
|
||||
import {IntlShape} from 'react-intl';
|
||||
import React from 'react';
|
||||
|
||||
import type {TorrentProperties} from '@shared/types/Torrent';
|
||||
|
||||
import ConfigStore from '../../stores/ConfigStore';
|
||||
import PriorityMeter from '../general/filesystem/PriorityMeter';
|
||||
import TorrentActions from '../../actions/TorrentActions';
|
||||
import TorrentContextMenuItems from '../../constants/TorrentContextMenuItems';
|
||||
import TorrentContextMenuActions from '../../constants/TorrentContextMenuActions';
|
||||
import TorrentStore from '../../stores/TorrentStore';
|
||||
import UIActions from '../../actions/UIActions';
|
||||
|
||||
const priorityMeterRef = React.createRef();
|
||||
import type {ContextMenuItem} from '../../stores/UIStore';
|
||||
import type {PriorityMeterType} from '../general/filesystem/PriorityMeter';
|
||||
import type {TorrentContextMenuAction} from '../../constants/TorrentContextMenuActions';
|
||||
|
||||
const priorityMeterRef: React.RefObject<PriorityMeterType> = React.createRef();
|
||||
let prioritySelected = 1;
|
||||
|
||||
const handleDetailsClick = (torrent, event) => {
|
||||
const handleDetailsClick = (torrent: TorrentProperties, event: React.MouseEvent): void => {
|
||||
UIActions.handleDetailsClick({
|
||||
hash: torrent.hash,
|
||||
event,
|
||||
@@ -22,7 +29,7 @@ const handleDetailsClick = (torrent, event) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleTorrentDownload = (torrent, event) => {
|
||||
const handleTorrentDownload = (torrent: TorrentProperties, event: React.MouseEvent): void => {
|
||||
event.preventDefault();
|
||||
const baseURI = ConfigStore.getBaseURI();
|
||||
const link = document.createElement('a');
|
||||
@@ -33,18 +40,18 @@ const handleTorrentDownload = (torrent, event) => {
|
||||
link.click();
|
||||
};
|
||||
|
||||
const handleItemClick = (action, event, torrent) => {
|
||||
const handleItemClick = (action: TorrentContextMenuAction, event: React.MouseEvent): void => {
|
||||
const selectedTorrents = TorrentStore.getSelectedTorrents();
|
||||
switch (action) {
|
||||
case 'check-hash':
|
||||
case 'checkHash':
|
||||
TorrentActions.checkHash({
|
||||
hashes: selectedTorrents,
|
||||
});
|
||||
break;
|
||||
case 'set-taxonomy':
|
||||
case 'setTaxonomy':
|
||||
UIActions.displayModal({id: 'set-taxonomy'});
|
||||
break;
|
||||
case 'set-tracker':
|
||||
case 'setTracker':
|
||||
UIActions.displayModal({id: 'set-tracker'});
|
||||
break;
|
||||
case 'start':
|
||||
@@ -63,14 +70,16 @@ const handleItemClick = (action, event, torrent) => {
|
||||
case 'move':
|
||||
UIActions.displayModal({id: 'move-torrents'});
|
||||
break;
|
||||
case 'torrent-details':
|
||||
handleDetailsClick(torrent, event);
|
||||
case 'torrentDetails':
|
||||
handleDetailsClick(TorrentStore.getTorrent(selectedTorrents.pop() as string), event);
|
||||
break;
|
||||
case 'torrent-download-tar':
|
||||
handleTorrentDownload(torrent, event);
|
||||
case 'torrentDownload':
|
||||
handleTorrentDownload(TorrentStore.getTorrent(selectedTorrents.pop() as string), event);
|
||||
break;
|
||||
case 'set-priority':
|
||||
priorityMeterRef.current.handleClick();
|
||||
case 'setPriority':
|
||||
if (priorityMeterRef.current != null) {
|
||||
priorityMeterRef.current.handleClick();
|
||||
}
|
||||
TorrentActions.setPriority({
|
||||
hashes: selectedTorrents,
|
||||
priority: prioritySelected,
|
||||
@@ -81,60 +90,74 @@ const handleItemClick = (action, event, torrent) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getContextMenuItems = (intl, torrent, settings) => {
|
||||
const getContextMenuItems = (intl: IntlShape, torrent: TorrentProperties): Array<ContextMenuItem> => {
|
||||
const clickHandler = handleItemClick;
|
||||
|
||||
const ret = [];
|
||||
|
||||
[
|
||||
return [
|
||||
{
|
||||
type: 'action',
|
||||
action: 'start',
|
||||
label: intl.formatMessage(TorrentContextMenuActions.start),
|
||||
clickHandler,
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
action: 'stop',
|
||||
label: intl.formatMessage(TorrentContextMenuActions.stop),
|
||||
clickHandler,
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
action: 'remove',
|
||||
label: intl.formatMessage(TorrentContextMenuActions.remove),
|
||||
clickHandler,
|
||||
},
|
||||
{
|
||||
action: 'check-hash',
|
||||
type: 'action',
|
||||
action: 'checkHash',
|
||||
label: intl.formatMessage(TorrentContextMenuActions.checkHash),
|
||||
clickHandler,
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
action: 'set-taxonomy',
|
||||
type: 'action',
|
||||
action: 'setTaxonomy',
|
||||
label: intl.formatMessage(TorrentContextMenuActions.setTaxonomy),
|
||||
clickHandler,
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
action: 'move',
|
||||
label: intl.formatMessage(TorrentContextMenuActions.move),
|
||||
clickHandler,
|
||||
},
|
||||
{
|
||||
action: 'set-tracker',
|
||||
type: 'action',
|
||||
action: 'setTracker',
|
||||
label: intl.formatMessage(TorrentContextMenuActions.setTracker),
|
||||
clickHandler,
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
action: 'torrent-details',
|
||||
clickHandler: (action, event) => {
|
||||
clickHandler(action, event, torrent);
|
||||
},
|
||||
type: 'action',
|
||||
action: 'torrentDetails',
|
||||
label: intl.formatMessage(TorrentContextMenuActions.torrentDetails),
|
||||
clickHandler,
|
||||
},
|
||||
{
|
||||
action: 'torrent-download-tar',
|
||||
clickHandler: (action, event) => {
|
||||
clickHandler(action, event, torrent);
|
||||
},
|
||||
type: 'action',
|
||||
action: 'torrentDownload',
|
||||
label: intl.formatMessage(TorrentContextMenuActions.torrentDownload),
|
||||
clickHandler,
|
||||
},
|
||||
{
|
||||
action: 'set-priority',
|
||||
type: 'action',
|
||||
action: 'setPriority',
|
||||
label: intl.formatMessage(TorrentContextMenuActions.setPriority),
|
||||
clickHandler,
|
||||
dismissMenu: false,
|
||||
labelAction: (
|
||||
@@ -153,26 +176,7 @@ const getContextMenuItems = (intl, torrent, settings) => {
|
||||
/>
|
||||
),
|
||||
},
|
||||
].forEach((item) => {
|
||||
if (item.action != null) {
|
||||
const hidden = settings.some((setting) => {
|
||||
if (item.action === setting.id) {
|
||||
return !setting.visible;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.label = intl.formatMessage({id: TorrentContextMenuItems[item.action].id});
|
||||
}
|
||||
|
||||
ret.push(item);
|
||||
});
|
||||
|
||||
return ret;
|
||||
];
|
||||
};
|
||||
|
||||
export default {
|
||||
101
client/src/javascript/components/torrent-list/TorrentListRow.tsx
Normal file
101
client/src/javascript/components/torrent-list/TorrentListRow.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import classnames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import type {TorrentProperties} from '@shared/types/Torrent';
|
||||
import type {FloodSettings} from '@shared/types/FloodSettings';
|
||||
|
||||
import torrentStatusClasses from '../../util/torrentStatusClasses';
|
||||
|
||||
import TorrentListRowCondensed from './TorrentListRowCondensed';
|
||||
import TorrentListRowExpanded from './TorrentListRowExpanded';
|
||||
|
||||
interface TorrentListRowProps {
|
||||
torrent: TorrentProperties;
|
||||
columns: FloodSettings['torrentListColumns'];
|
||||
columnWidths: FloodSettings['torrentListColumnWidths'];
|
||||
isSelected: boolean;
|
||||
isCondensed: boolean;
|
||||
handleClick: (torrent: TorrentProperties, event: React.MouseEvent) => void;
|
||||
handleDoubleClick: (torrent: TorrentProperties, event: React.MouseEvent) => void;
|
||||
handleRightClick: (torrent: TorrentProperties, event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
class TorrentListRow extends React.PureComponent<TorrentListRowProps> {
|
||||
torrentRef: HTMLLIElement | null = null;
|
||||
handleRightClick: (event: React.MouseEvent) => void;
|
||||
|
||||
static defaultProps = {
|
||||
isCondensed: false,
|
||||
};
|
||||
|
||||
constructor(props: TorrentListRowProps) {
|
||||
super(props);
|
||||
|
||||
const {handleRightClick, torrent} = props;
|
||||
|
||||
this.handleRightClick = handleRightClick.bind(this, torrent);
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
if (this.torrentRef != null) {
|
||||
this.torrentRef.addEventListener('long-press', (e) => this.handleRightClick((e as unknown) as React.MouseEvent));
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.torrentRef != null) {
|
||||
this.torrentRef.removeEventListener('long-press', (e) =>
|
||||
this.handleRightClick((e as unknown) as React.MouseEvent),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
const {
|
||||
isCondensed,
|
||||
isSelected,
|
||||
columns,
|
||||
columnWidths,
|
||||
torrent,
|
||||
handleClick,
|
||||
handleDoubleClick,
|
||||
handleRightClick,
|
||||
} = this.props;
|
||||
const torrentClasses = torrentStatusClasses(
|
||||
torrent,
|
||||
classnames({
|
||||
'torrent--is-selected': isSelected,
|
||||
'torrent--is-condensed': isCondensed,
|
||||
'torrent--is-expanded': !isCondensed,
|
||||
}),
|
||||
'torrent',
|
||||
);
|
||||
|
||||
if (isCondensed) {
|
||||
return (
|
||||
<TorrentListRowCondensed
|
||||
className={torrentClasses}
|
||||
columns={columns}
|
||||
columnWidths={columnWidths}
|
||||
torrent={torrent}
|
||||
handleClick={handleClick}
|
||||
handleDoubleClick={handleDoubleClick}
|
||||
handleRightClick={handleRightClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TorrentListRowExpanded
|
||||
className={torrentClasses}
|
||||
columns={columns}
|
||||
torrent={torrent}
|
||||
handleClick={handleClick}
|
||||
handleDoubleClick={handleDoubleClick}
|
||||
handleRightClick={handleRightClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TorrentListRow;
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
|
||||
import type {FloodSettings} from '@shared/types/FloodSettings';
|
||||
import type {TorrentProperties} from '@shared/types/Torrent';
|
||||
|
||||
import {getTorrentListCellContent} from '../../util/torrentListCellContents';
|
||||
import ProgressBar from '../general/ProgressBar';
|
||||
import TorrentListCell from './TorrentListCell';
|
||||
import torrentStatusIcons from '../../util/torrentStatusIcons';
|
||||
|
||||
interface TorrentListRowCondensedProps {
|
||||
className: string;
|
||||
columns: FloodSettings['torrentListColumns'];
|
||||
columnWidths: FloodSettings['torrentListColumnWidths'];
|
||||
torrent: TorrentProperties;
|
||||
handleClick: (torrent: TorrentProperties, event: React.MouseEvent) => void;
|
||||
handleDoubleClick: (torrent: TorrentProperties, event: React.MouseEvent) => void;
|
||||
handleRightClick: (torrent: TorrentProperties, event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const TorrentListRowCondensed = React.forwardRef<HTMLLIElement, TorrentListRowCondensedProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
columns,
|
||||
columnWidths,
|
||||
torrent,
|
||||
handleClick,
|
||||
handleDoubleClick,
|
||||
handleRightClick,
|
||||
}: TorrentListRowCondensedProps,
|
||||
ref,
|
||||
) => {
|
||||
const torrentListColumns = columns.reduce((accumulator: React.ReactNodeArray, {id, visible}) => {
|
||||
if (!visible) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
const content: React.ReactNode =
|
||||
id === 'percentComplete' ? (
|
||||
<ProgressBar percent={torrent.percentComplete} icon={torrentStatusIcons(torrent.status)} />
|
||||
) : (
|
||||
getTorrentListCellContent(torrent, id)
|
||||
);
|
||||
|
||||
accumulator.push(
|
||||
<TorrentListCell className="table__cell" key={id} column={id} content={content} width={columnWidths[id]} />,
|
||||
);
|
||||
|
||||
return accumulator;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={className}
|
||||
onClick={handleClick.bind(this, torrent)}
|
||||
onContextMenu={handleRightClick.bind(this, torrent)}
|
||||
onDoubleClick={handleDoubleClick.bind(this, torrent)}
|
||||
ref={ref}>
|
||||
{torrentListColumns}
|
||||
</li>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default React.memo(TorrentListRowCondensed);
|
||||
@@ -0,0 +1,102 @@
|
||||
import {FormattedNumber} from 'react-intl';
|
||||
import React from 'react';
|
||||
|
||||
import type {FloodSettings} from '@shared/types/FloodSettings';
|
||||
import type {TorrentProperties} from '@shared/types/Torrent';
|
||||
|
||||
import {getTorrentListCellContent} from '../../util/torrentListCellContents';
|
||||
import ProgressBar from '../general/ProgressBar';
|
||||
import Size from '../general/Size';
|
||||
import TorrentListCell from './TorrentListCell';
|
||||
import torrentStatusIcons from '../../util/torrentStatusIcons';
|
||||
|
||||
interface TorrentListRowExpandedProps {
|
||||
className: string;
|
||||
columns: FloodSettings['torrentListColumns'];
|
||||
torrent: TorrentProperties;
|
||||
handleClick: (torrent: TorrentProperties, event: React.MouseEvent) => void;
|
||||
handleDoubleClick: (torrent: TorrentProperties, event: React.MouseEvent) => void;
|
||||
handleRightClick: (torrent: TorrentProperties, event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const TorrentListRowExpanded = React.forwardRef<HTMLLIElement, TorrentListRowExpandedProps>(
|
||||
(
|
||||
{className, columns, torrent, handleClick, handleDoubleClick, handleRightClick}: TorrentListRowExpandedProps,
|
||||
ref,
|
||||
) => {
|
||||
const primarySection: React.ReactNodeArray = [];
|
||||
const secondarySection: React.ReactNodeArray = [];
|
||||
const tertiarySection: React.ReactNodeArray = [];
|
||||
|
||||
// Using a for loop to maximize performance.
|
||||
for (let index = 0; index < columns.length; index += 1) {
|
||||
const {id, visible} = columns[index];
|
||||
|
||||
if (visible) {
|
||||
switch (id) {
|
||||
case 'name':
|
||||
primarySection.push(
|
||||
<TorrentListCell
|
||||
key={id}
|
||||
column={id}
|
||||
className="torrent__details__section torrent__details__section--primary"
|
||||
content={getTorrentListCellContent(torrent, id)}
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
case 'downRate':
|
||||
case 'upRate':
|
||||
case 'eta':
|
||||
secondarySection.push(
|
||||
<TorrentListCell key={id} column={id} content={getTorrentListCellContent(torrent, id)} showIcon />,
|
||||
);
|
||||
break;
|
||||
case 'downTotal':
|
||||
break;
|
||||
case 'percentComplete':
|
||||
tertiarySection.push(
|
||||
<TorrentListCell
|
||||
key={id}
|
||||
column={id}
|
||||
content={
|
||||
<span>
|
||||
<FormattedNumber value={torrent.percentComplete} />
|
||||
<em className="unit">%</em>
|
||||
—
|
||||
<Size value={torrent.downTotal} />
|
||||
</span>
|
||||
}
|
||||
showIcon
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
tertiarySection.push(
|
||||
<TorrentListCell key={id} column={id} content={getTorrentListCellContent(torrent, id)} showIcon />,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={className}
|
||||
onClick={handleClick.bind(this, torrent)}
|
||||
onContextMenu={handleRightClick.bind(this, torrent)}
|
||||
onDoubleClick={handleDoubleClick.bind(this, torrent)}
|
||||
ref={ref}>
|
||||
<div className="torrent__details__section__wrapper">
|
||||
{primarySection}
|
||||
<div className="torrent__details__section torrent__details__section--secondary">{secondarySection}</div>
|
||||
</div>
|
||||
<div className="torrent__details__section torrent__details__section--tertiary">{tertiarySection}</div>
|
||||
<div className="torrent__details__section torrent__details__section--quaternary">
|
||||
<ProgressBar percent={torrent.percentComplete} icon={torrentStatusIcons(torrent.status)} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default React.memo(TorrentListRowExpanded);
|
||||
@@ -3,12 +3,12 @@ import React from 'react';
|
||||
import ApplicationView from '../layout/ApplicationView';
|
||||
import AuthForm from '../auth/AuthForm';
|
||||
|
||||
export default class LoginView extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<ApplicationView modifier="auth-form">
|
||||
<AuthForm mode="login" />
|
||||
</ApplicationView>
|
||||
);
|
||||
}
|
||||
}
|
||||
const LoginView = () => {
|
||||
return (
|
||||
<ApplicationView modifier="auth-form">
|
||||
<AuthForm mode="login" />
|
||||
</ApplicationView>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginView;
|
||||
|
||||
@@ -3,12 +3,12 @@ import React from 'react';
|
||||
import ApplicationView from '../layout/ApplicationView';
|
||||
import AuthForm from '../auth/AuthForm';
|
||||
|
||||
export default class LoginView extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<ApplicationView modifier="auth-form">
|
||||
<AuthForm mode="register" />
|
||||
</ApplicationView>
|
||||
);
|
||||
}
|
||||
}
|
||||
const LoginView = () => {
|
||||
return (
|
||||
<ApplicationView modifier="auth-form">
|
||||
<AuthForm mode="register" />
|
||||
</ApplicationView>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginView;
|
||||
|
||||
@@ -1,92 +1,86 @@
|
||||
import objectUtil from '@shared/util/objectUtil';
|
||||
|
||||
const eventTypes = [
|
||||
'ALERTS_CHANGE',
|
||||
'AUTH_CREATE_USER_SUCCESS',
|
||||
'AUTH_DELETE_USER_ERROR',
|
||||
'AUTH_DELETE_USER_SUCCESS',
|
||||
'AUTH_LIST_USERS_SUCCESS',
|
||||
'AUTH_LOGIN_ERROR',
|
||||
'AUTH_LOGIN_SUCCESS',
|
||||
'AUTH_REGISTER_ERROR',
|
||||
'AUTH_REGISTER_SUCCESS',
|
||||
'AUTH_VERIFY_ERROR',
|
||||
'AUTH_VERIFY_SUCCESS',
|
||||
'CLIENT_CONNECTION_STATUS_CHANGE',
|
||||
'CLIENT_ADD_TORRENT_ERROR',
|
||||
'CLIENT_ADD_TORRENT_SUCCESS',
|
||||
'CLIENT_FETCH_TORRENT_MEDIAINFO_SUCCESS',
|
||||
'CLIENT_FETCH_TORRENT_TAXONOMY_SUCCESS',
|
||||
'CLIENT_SET_FILE_PRIORITY_ERROR',
|
||||
'CLIENT_SET_FILE_PRIORITY_SUCCESS',
|
||||
'CLIENT_SET_TORRENT_PRIORITY_ERROR',
|
||||
'CLIENT_SET_TORRENT_PRIORITY_SUCCESS',
|
||||
'CLIENT_MOVE_TORRENTS_REQUEST_ERROR',
|
||||
'CLIENT_MOVE_TORRENTS_SUCCESS',
|
||||
'CLIENT_SETTINGS_FETCH_REQUEST_ERROR',
|
||||
'CLIENT_SETTINGS_FETCH_REQUEST_SUCCESS',
|
||||
'CLIENT_SETTINGS_SAVE_REQUEST_ERROR',
|
||||
'CLIENT_SETTINGS_SAVE_REQUEST_SUCCESS',
|
||||
'CLIENT_TORRENTS_REQUEST_ERROR',
|
||||
'CLIENT_TORRENT_STATUS_COUNT_CHANGE',
|
||||
'CLIENT_TORRENT_STATUS_COUNT_REQUEST_ERROR',
|
||||
'CLIENT_TORRENT_TRACKER_COUNT_CHANGE',
|
||||
'CLIENT_TORRENT_TRACKER_COUNT_REQUEST_ERROR',
|
||||
'CLIENT_TORRENTS_REQUEST_SUCCESS',
|
||||
'CLIENT_TORRENT_DETAILS_CHANGE',
|
||||
'CLIENT_TRANSFER_DATA_REQUEST_SUCCESS',
|
||||
'CLIENT_TRANSFER_DATA_REQUEST_ERROR',
|
||||
'CLIENT_TRANSFER_HISTORY_REQUEST_SUCCESS',
|
||||
'CLIENT_TRANSFER_HISTORY_REQUEST_ERROR',
|
||||
'CLIENT_TRANSFER_SUMMARY_CHANGE',
|
||||
'DISK_USAGE_CHANGE',
|
||||
'NOTIFICATIONS_FETCH_ERROR',
|
||||
'NOTIFICATIONS_FETCH_SUCCESS',
|
||||
'NOTIFICATIONS_COUNT_CHANGE',
|
||||
'NOTIFICATIONS_CLEAR_SUCCESS',
|
||||
'SETTINGS_CHANGE',
|
||||
'SETTINGS_SAVE_REQUEST_ERROR',
|
||||
'SETTINGS_SAVE_REQUEST_SUCCESS',
|
||||
'SETTINGS_FEED_MONITOR_FEED_ADD_ERROR',
|
||||
'SETTINGS_FEED_MONITOR_FEED_ADD_SUCCESS',
|
||||
'SETTINGS_FEED_MONITOR_FEED_MODIFY_ERROR',
|
||||
'SETTINGS_FEED_MONITOR_FEED_MODIFY_SUCCESS',
|
||||
'SETTINGS_FEED_MONITOR_FEEDS_FETCH_ERROR',
|
||||
'SETTINGS_FEED_MONITOR_FEEDS_FETCH_SUCCESS',
|
||||
'SETTINGS_FEED_MONITOR_RULE_ADD_ERROR',
|
||||
'SETTINGS_FEED_MONITOR_RULE_ADD_SUCCESS',
|
||||
'SETTINGS_FEED_MONITOR_RULES_FETCH_ERROR',
|
||||
'SETTINGS_FEED_MONITOR_RULES_FETCH_SUCCESS',
|
||||
'SETTINGS_FEED_MONITOR_REMOVE_ERROR',
|
||||
'SETTINGS_FEED_MONITOR_REMOVE_SUCCESS',
|
||||
'SETTINGS_FEED_MONITORS_FETCH_ERROR',
|
||||
'SETTINGS_FEED_MONITORS_FETCH_SUCCESS',
|
||||
'SETTINGS_FEED_MONITOR_ITEMS_FETCH_ERROR',
|
||||
'SETTINGS_FEED_MONITOR_ITEMS_FETCH_SUCCESS',
|
||||
'SETTINGS_FETCH_REQUEST_ERROR',
|
||||
'SETTINGS_FETCH_REQUEST_SUCCESS',
|
||||
'UI_CONTEXT_MENU_CHANGE',
|
||||
'UI_DEPENDENCIES_CHANGE',
|
||||
'UI_DEPENDENCIES_LOADED',
|
||||
'UI_DROPDOWN_MENU_CHANGE',
|
||||
'UI_MODAL_DISMISSED',
|
||||
'UI_MODAL_CHANGE',
|
||||
'UI_LATEST_TORRENT_LOCATION_CHANGE',
|
||||
'UI_TORRENT_DETAILS_HASH_CHANGE',
|
||||
'UI_TORRENT_DETAILS_OPEN_CHANGE',
|
||||
'UI_TORRENT_SELECTION_CHANGE',
|
||||
'UI_TORRENTS_FILTER_CHANGE',
|
||||
'UI_TORRENTS_FILTER_CLEAR',
|
||||
'UI_TORRENTS_FILTER_STATUS_CHANGE',
|
||||
'UI_TORRENTS_FILTER_TAG_CHANGE',
|
||||
'UI_TORRENTS_FILTER_TRACKER_CHANGE',
|
||||
'UI_TORRENTS_FILTER_SEARCH_CHANGE',
|
||||
'UI_TORRENTS_LIST_FILTERED',
|
||||
'UI_TORRENTS_SORT_CHANGE',
|
||||
] as const;
|
||||
|
||||
export default objectUtil.createStringMapFromArray(eventTypes);
|
||||
export type EventType = typeof eventTypes[number];
|
||||
export type EventType =
|
||||
| 'ALERTS_CHANGE'
|
||||
| 'AUTH_CREATE_USER_SUCCESS'
|
||||
| 'AUTH_DELETE_USER_ERROR'
|
||||
| 'AUTH_DELETE_USER_SUCCESS'
|
||||
| 'AUTH_LIST_USERS_SUCCESS'
|
||||
| 'AUTH_LOGIN_ERROR'
|
||||
| 'AUTH_LOGIN_SUCCESS'
|
||||
| 'AUTH_REGISTER_ERROR'
|
||||
| 'AUTH_REGISTER_SUCCESS'
|
||||
| 'AUTH_VERIFY_ERROR'
|
||||
| 'AUTH_VERIFY_SUCCESS'
|
||||
| 'CLIENT_CONNECTION_STATUS_CHANGE'
|
||||
| 'CLIENT_ADD_TORRENT_ERROR'
|
||||
| 'CLIENT_ADD_TORRENT_SUCCESS'
|
||||
| 'CLIENT_FETCH_TORRENT_MEDIAINFO_SUCCESS'
|
||||
| 'CLIENT_FETCH_TORRENT_TAXONOMY_SUCCESS'
|
||||
| 'CLIENT_SET_FILE_PRIORITY_ERROR'
|
||||
| 'CLIENT_SET_FILE_PRIORITY_SUCCESS'
|
||||
| 'CLIENT_SET_TORRENT_PRIORITY_ERROR'
|
||||
| 'CLIENT_SET_TORRENT_PRIORITY_SUCCESS'
|
||||
| 'CLIENT_MOVE_TORRENTS_REQUEST_ERROR'
|
||||
| 'CLIENT_MOVE_TORRENTS_SUCCESS'
|
||||
| 'CLIENT_SETTINGS_FETCH_REQUEST_ERROR'
|
||||
| 'CLIENT_SETTINGS_FETCH_REQUEST_SUCCESS'
|
||||
| 'CLIENT_SETTINGS_SAVE_REQUEST_ERROR'
|
||||
| 'CLIENT_SETTINGS_SAVE_REQUEST_SUCCESS'
|
||||
| 'CLIENT_TORRENTS_REQUEST_ERROR'
|
||||
| 'CLIENT_TORRENT_STATUS_COUNT_CHANGE'
|
||||
| 'CLIENT_TORRENT_STATUS_COUNT_REQUEST_ERROR'
|
||||
| 'CLIENT_TORRENT_TRACKER_COUNT_CHANGE'
|
||||
| 'CLIENT_TORRENT_TRACKER_COUNT_REQUEST_ERROR'
|
||||
| 'CLIENT_TORRENTS_REQUEST_SUCCESS'
|
||||
| 'CLIENT_TORRENT_DETAILS_CHANGE'
|
||||
| 'CLIENT_TRANSFER_DATA_REQUEST_SUCCESS'
|
||||
| 'CLIENT_TRANSFER_DATA_REQUEST_ERROR'
|
||||
| 'CLIENT_TRANSFER_HISTORY_REQUEST_SUCCESS'
|
||||
| 'CLIENT_TRANSFER_HISTORY_REQUEST_ERROR'
|
||||
| 'CLIENT_TRANSFER_SUMMARY_CHANGE'
|
||||
| 'DISK_USAGE_CHANGE'
|
||||
| 'NOTIFICATIONS_FETCH_ERROR'
|
||||
| 'NOTIFICATIONS_FETCH_SUCCESS'
|
||||
| 'NOTIFICATIONS_COUNT_CHANGE'
|
||||
| 'NOTIFICATIONS_CLEAR_SUCCESS'
|
||||
| 'SETTINGS_CHANGE'
|
||||
| 'SETTINGS_SAVE_REQUEST_ERROR'
|
||||
| 'SETTINGS_SAVE_REQUEST_SUCCESS'
|
||||
| 'SETTINGS_FEED_MONITOR_FEED_ADD_ERROR'
|
||||
| 'SETTINGS_FEED_MONITOR_FEED_ADD_SUCCESS'
|
||||
| 'SETTINGS_FEED_MONITOR_FEED_MODIFY_ERROR'
|
||||
| 'SETTINGS_FEED_MONITOR_FEED_MODIFY_SUCCESS'
|
||||
| 'SETTINGS_FEED_MONITOR_FEEDS_FETCH_ERROR'
|
||||
| 'SETTINGS_FEED_MONITOR_FEEDS_FETCH_SUCCESS'
|
||||
| 'SETTINGS_FEED_MONITOR_RULE_ADD_ERROR'
|
||||
| 'SETTINGS_FEED_MONITOR_RULE_ADD_SUCCESS'
|
||||
| 'SETTINGS_FEED_MONITOR_RULES_FETCH_ERROR'
|
||||
| 'SETTINGS_FEED_MONITOR_RULES_FETCH_SUCCESS'
|
||||
| 'SETTINGS_FEED_MONITOR_REMOVE_ERROR'
|
||||
| 'SETTINGS_FEED_MONITOR_REMOVE_SUCCESS'
|
||||
| 'SETTINGS_FEED_MONITORS_FETCH_ERROR'
|
||||
| 'SETTINGS_FEED_MONITORS_FETCH_SUCCESS'
|
||||
| 'SETTINGS_FEED_MONITOR_ITEMS_FETCH_ERROR'
|
||||
| 'SETTINGS_FEED_MONITOR_ITEMS_FETCH_SUCCESS'
|
||||
| 'SETTINGS_FETCH_REQUEST_ERROR'
|
||||
| 'SETTINGS_FETCH_REQUEST_SUCCESS'
|
||||
| 'UI_CONTEXT_MENU_CHANGE'
|
||||
| 'UI_DEPENDENCIES_CHANGE'
|
||||
| 'UI_DEPENDENCIES_LOADED'
|
||||
| 'UI_DROPDOWN_MENU_CHANGE'
|
||||
| 'UI_MODAL_DISMISSED'
|
||||
| 'UI_MODAL_CHANGE'
|
||||
| 'UI_LATEST_TORRENT_LOCATION_CHANGE'
|
||||
| 'UI_TORRENT_DETAILS_HASH_CHANGE'
|
||||
| 'UI_TORRENT_DETAILS_OPEN_CHANGE'
|
||||
| 'UI_TORRENT_SELECTION_CHANGE'
|
||||
| 'UI_TORRENTS_FILTER_CHANGE'
|
||||
| 'UI_TORRENTS_FILTER_CLEAR'
|
||||
| 'UI_TORRENTS_FILTER_STATUS_CHANGE'
|
||||
| 'UI_TORRENTS_FILTER_TAG_CHANGE'
|
||||
| 'UI_TORRENTS_FILTER_TRACKER_CHANGE'
|
||||
| 'UI_TORRENTS_FILTER_SEARCH_CHANGE'
|
||||
| 'UI_TORRENTS_LIST_FILTERED'
|
||||
| 'UI_TORRENTS_SORT_CHANGE';
|
||||
|
||||
// TODO: Convert all events to type-checked interfaces
|
||||
export type BaseEvents = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const LANGUAGES = {
|
||||
const Languages = {
|
||||
auto: {
|
||||
id: 'locale.language.auto',
|
||||
},
|
||||
@@ -24,4 +24,5 @@ const LANGUAGES = {
|
||||
ar: 'اَلْعَرَبِيَّةُ',
|
||||
} as const;
|
||||
|
||||
export default LANGUAGES;
|
||||
export default Languages;
|
||||
export type Language = keyof typeof Languages;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const PRIORITY_LEVELS = {
|
||||
const PriorityLevels = {
|
||||
file: {
|
||||
0: 'DONT_DOWNLOAD',
|
||||
1: 'NORMAL',
|
||||
@@ -12,4 +12,4 @@ const PRIORITY_LEVELS = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default PRIORITY_LEVELS;
|
||||
export default PriorityLevels;
|
||||
|
||||
@@ -9,56 +9,54 @@ import type {TorrentDetails} from '@shared/types/Torrent';
|
||||
import type {SettingsSaveOptions} from '../stores/SettingsStore';
|
||||
import type {Feeds, Items, Rules} from '../stores/FeedsStore';
|
||||
|
||||
const errorTypes = [
|
||||
'AUTH_LOGIN_ERROR',
|
||||
'AUTH_LOGOUT_ERROR',
|
||||
'AUTH_REGISTER_ERROR',
|
||||
'AUTH_VERIFY_ERROR',
|
||||
'CLIENT_ADD_TORRENT_ERROR',
|
||||
'FLOOD_CLEAR_NOTIFICATIONS_ERROR',
|
||||
'CLIENT_CONNECTION_TEST_ERROR',
|
||||
'CLIENT_FETCH_TORRENT_DETAILS_ERROR',
|
||||
'CLIENT_SET_FILE_PRIORITY_ERROR',
|
||||
'CLIENT_SET_TAXONOMY_ERROR',
|
||||
'CLIENT_SET_TORRENT_PRIORITY_ERROR',
|
||||
'CLIENT_SET_TRACKER_ERROR',
|
||||
'CLIENT_SETTINGS_FETCH_REQUEST_ERROR',
|
||||
'CLIENT_SETTINGS_SAVE_ERROR',
|
||||
'CLIENT_START_TORRENT_ERROR',
|
||||
'CLIENT_STOP_TORRENT_ERROR',
|
||||
'FLOOD_FETCH_NOTIFICATIONS_ERROR',
|
||||
'SETTINGS_FEED_MONITOR_FEED_ADD_ERROR',
|
||||
'SETTINGS_FEED_MONITOR_FEED_MODIFY_ERROR',
|
||||
'SETTINGS_FEED_MONITOR_FEEDS_FETCH_ERROR',
|
||||
'SETTINGS_FEED_MONITORS_FETCH_ERROR',
|
||||
'SETTINGS_FEED_MONITOR_RULE_ADD_ERROR',
|
||||
'SETTINGS_FEED_MONITOR_RULES_FETCH_ERROR',
|
||||
'SETTINGS_FEED_MONITOR_ITEMS_FETCH_ERROR',
|
||||
'SETTINGS_FETCH_REQUEST_ERROR',
|
||||
'SETTINGS_SAVE_REQUEST_ERROR',
|
||||
] as const;
|
||||
type ErrorType =
|
||||
| 'AUTH_LOGIN_ERROR'
|
||||
| 'AUTH_LOGOUT_ERROR'
|
||||
| 'AUTH_REGISTER_ERROR'
|
||||
| 'AUTH_VERIFY_ERROR'
|
||||
| 'CLIENT_ADD_TORRENT_ERROR'
|
||||
| 'FLOOD_CLEAR_NOTIFICATIONS_ERROR'
|
||||
| 'CLIENT_CONNECTION_TEST_ERROR'
|
||||
| 'CLIENT_FETCH_TORRENT_DETAILS_ERROR'
|
||||
| 'CLIENT_SET_FILE_PRIORITY_ERROR'
|
||||
| 'CLIENT_SET_TAXONOMY_ERROR'
|
||||
| 'CLIENT_SET_TORRENT_PRIORITY_ERROR'
|
||||
| 'CLIENT_SET_TRACKER_ERROR'
|
||||
| 'CLIENT_SETTINGS_FETCH_REQUEST_ERROR'
|
||||
| 'CLIENT_SETTINGS_SAVE_ERROR'
|
||||
| 'CLIENT_START_TORRENT_ERROR'
|
||||
| 'CLIENT_STOP_TORRENT_ERROR'
|
||||
| 'FLOOD_FETCH_NOTIFICATIONS_ERROR'
|
||||
| 'SETTINGS_FEED_MONITOR_FEED_ADD_ERROR'
|
||||
| 'SETTINGS_FEED_MONITOR_FEED_MODIFY_ERROR'
|
||||
| 'SETTINGS_FEED_MONITOR_FEEDS_FETCH_ERROR'
|
||||
| 'SETTINGS_FEED_MONITORS_FETCH_ERROR'
|
||||
| 'SETTINGS_FEED_MONITOR_RULE_ADD_ERROR'
|
||||
| 'SETTINGS_FEED_MONITOR_RULES_FETCH_ERROR'
|
||||
| 'SETTINGS_FEED_MONITOR_ITEMS_FETCH_ERROR'
|
||||
| 'SETTINGS_FETCH_REQUEST_ERROR'
|
||||
| 'SETTINGS_SAVE_REQUEST_ERROR';
|
||||
|
||||
const successTypes = [
|
||||
'AUTH_LOGOUT_SUCCESS',
|
||||
'CLIENT_CHECK_HASH_SUCCESS',
|
||||
'CLIENT_CONNECTION_TEST_SUCCESS',
|
||||
'CLIENT_SET_TORRENT_PRIORITY_SUCCESS',
|
||||
'CLIENT_SET_TAXONOMY_SUCCESS',
|
||||
'CLIENT_SET_TRACKER_SUCCESS',
|
||||
'CLIENT_START_TORRENT_SUCCESS',
|
||||
'CLIENT_STOP_TORRENT_SUCCESS',
|
||||
'SETTINGS_FEED_MONITOR_FEED_ADD_SUCCESS',
|
||||
'SETTINGS_FEED_MONITOR_FEED_MODIFY_SUCCESS',
|
||||
'SETTINGS_FEED_MONITOR_RULE_ADD_SUCCESS',
|
||||
] as const;
|
||||
type SuccessType =
|
||||
| 'AUTH_LOGOUT_SUCCESS'
|
||||
| 'CLIENT_CHECK_HASH_SUCCESS'
|
||||
| 'CLIENT_CONNECTION_TEST_SUCCESS'
|
||||
| 'CLIENT_SET_TORRENT_PRIORITY_SUCCESS'
|
||||
| 'CLIENT_SET_TAXONOMY_SUCCESS'
|
||||
| 'CLIENT_SET_TRACKER_SUCCESS'
|
||||
| 'CLIENT_START_TORRENT_SUCCESS'
|
||||
| 'CLIENT_STOP_TORRENT_SUCCESS'
|
||||
| 'SETTINGS_FEED_MONITOR_FEED_ADD_SUCCESS'
|
||||
| 'SETTINGS_FEED_MONITOR_FEED_MODIFY_SUCCESS'
|
||||
| 'SETTINGS_FEED_MONITOR_RULE_ADD_SUCCESS';
|
||||
|
||||
interface BaseErrorAction {
|
||||
type: typeof errorTypes[number];
|
||||
type: ErrorType;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface BaseSuccessAction {
|
||||
type: typeof successTypes[number];
|
||||
type: SuccessType;
|
||||
}
|
||||
|
||||
// AuthActions
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Actually string ID of TorrentContextMenuItems.
|
||||
const TorrentContextMenuItems = {
|
||||
const TorrentContextMenuActions = {
|
||||
start: {
|
||||
id: 'torrents.list.context.start',
|
||||
},
|
||||
@@ -9,28 +8,29 @@ const TorrentContextMenuItems = {
|
||||
remove: {
|
||||
id: 'torrents.list.context.remove',
|
||||
},
|
||||
'check-hash': {
|
||||
checkHash: {
|
||||
id: 'torrents.list.context.check.hash',
|
||||
},
|
||||
'set-taxonomy': {
|
||||
setTaxonomy: {
|
||||
id: 'torrents.list.context.set.tags',
|
||||
},
|
||||
move: {
|
||||
id: 'torrents.list.context.move',
|
||||
},
|
||||
'set-tracker': {
|
||||
setTracker: {
|
||||
id: 'torrents.list.context.set.tracker',
|
||||
warning: 'settings.warning.set.tracker',
|
||||
},
|
||||
'torrent-details': {
|
||||
torrentDetails: {
|
||||
id: 'torrents.list.context.details',
|
||||
},
|
||||
'torrent-download-tar': {
|
||||
torrentDownload: {
|
||||
id: 'torrents.list.context.download',
|
||||
},
|
||||
'set-priority': {
|
||||
setPriority: {
|
||||
id: 'torrents.list.context.priority',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default TorrentContextMenuItems;
|
||||
export default TorrentContextMenuActions;
|
||||
export type TorrentContextMenuAction = keyof typeof TorrentContextMenuActions;
|
||||
@@ -1,5 +1,4 @@
|
||||
// Actually string ID of torrentProperties.
|
||||
const torrentProperties = {
|
||||
const TorrentListColumns = {
|
||||
dateAdded: {
|
||||
id: 'torrents.properties.date.added',
|
||||
},
|
||||
@@ -59,4 +58,5 @@ const torrentProperties = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default torrentProperties;
|
||||
export default TorrentListColumns;
|
||||
export type TorrentListColumn = keyof typeof TorrentListColumns;
|
||||
@@ -11,14 +11,13 @@ import detectLocale from '../util/detectLocale';
|
||||
import EN from './strings.compiled.json';
|
||||
import Languages from '../constants/Languages';
|
||||
|
||||
let dayjsLocale: Exclude<keyof typeof Languages, 'auto' | 'zh-Hans' | 'zh-Hant'> | 'zh-cn' | 'zh-tw' = 'en';
|
||||
import type {Language} from '../constants/Languages';
|
||||
|
||||
const messagesCache: Partial<Record<
|
||||
Exclude<keyof typeof Languages, 'auto'>,
|
||||
Record<string, MessageFormatElement[]>
|
||||
>> = {en: EN};
|
||||
let dayjsLocale: Exclude<Language, 'auto' | 'zh-Hans' | 'zh-Hant'> | 'zh-cn' | 'zh-tw' = 'en';
|
||||
|
||||
async function loadMessages(locale: Exclude<keyof typeof Languages, 'auto' | 'en'>) {
|
||||
const messagesCache: Partial<Record<Exclude<Language, 'auto'>, Record<string, MessageFormatElement[]>>> = {en: EN};
|
||||
|
||||
async function loadMessages(locale: Exclude<Language, 'auto' | 'en'>) {
|
||||
const messages: Record<string, MessageFormatElement[]> = await import(`./compiled/${locale}.json`);
|
||||
messagesCache[locale] = messages;
|
||||
|
||||
@@ -27,7 +26,7 @@ async function loadMessages(locale: Exclude<keyof typeof Languages, 'auto' | 'en
|
||||
return messages;
|
||||
}
|
||||
|
||||
function getMessages(locale: Exclude<keyof typeof Languages, 'auto'>) {
|
||||
function getMessages(locale: Exclude<Language, 'auto'>) {
|
||||
if (locale === 'zh-Hans') {
|
||||
dayjsLocale = 'zh-cn';
|
||||
} else if (locale === 'zh-Hant') {
|
||||
@@ -41,11 +40,16 @@ function getMessages(locale: Exclude<keyof typeof Languages, 'auto'>) {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw loadMessages(locale as Exclude<keyof typeof Languages, 'auto' | 'en'>);
|
||||
throw loadMessages(locale as Exclude<Language, 'auto' | 'en'>);
|
||||
}
|
||||
|
||||
const AsyncIntlProvider = ({locale, children}: {locale?: keyof typeof Languages; children: React.ReactNode}) => {
|
||||
let validatedLocale: Exclude<keyof typeof Languages, 'auto'>;
|
||||
interface AsyncIntlProviderProps {
|
||||
locale?: Language;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const AsyncIntlProvider: React.FC<AsyncIntlProviderProps> = ({locale, children}: AsyncIntlProviderProps) => {
|
||||
let validatedLocale: Exclude<Language, 'auto'>;
|
||||
if (locale == null || locale === 'auto' || !Object.prototype.hasOwnProperty.call(Languages, locale)) {
|
||||
validatedLocale = detectLocale();
|
||||
} else {
|
||||
@@ -60,6 +64,10 @@ const AsyncIntlProvider = ({locale, children}: {locale?: keyof typeof Languages;
|
||||
);
|
||||
};
|
||||
|
||||
AsyncIntlProvider.defaultProps = {
|
||||
locale: 'en',
|
||||
};
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
|
||||
@@ -859,6 +859,12 @@
|
||||
"value": "This path does not exist. It will be created."
|
||||
}
|
||||
],
|
||||
"filesystem.error.unknown": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "An unknown error occurred. Please try again."
|
||||
}
|
||||
],
|
||||
"filesystem.fetching": [
|
||||
{
|
||||
"type": 0,
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
"filesystem.empty.directory": "Empty directory.",
|
||||
"filesystem.error.eacces": "Flood does not have permission to read this directory.",
|
||||
"filesystem.error.enoent": "This path does not exist. It will be created.",
|
||||
"filesystem.error.unknown": "An unknown error occurred. Please try again.",
|
||||
"filesystem.fetching": "Fetching directory structure...",
|
||||
"filesystem.parent.directory": "Parent Directory",
|
||||
"filter.all": "All",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import defaultFloodSettings from '@shared/constants/defaultFloodSettings';
|
||||
|
||||
import type {ClientSetting, ClientSettings} from '@shared/types/ClientSettings';
|
||||
import type {FloodSetting, FloodSettings} from '@shared/types/FloodSettings';
|
||||
|
||||
@@ -22,54 +24,7 @@ class SettingsStoreClass extends BaseStore {
|
||||
clientSettings: ClientSettings | null = null;
|
||||
|
||||
// Default settings are overridden by settings stored in database.
|
||||
floodSettings: FloodSettings = {
|
||||
language: 'auto',
|
||||
sortTorrents: {
|
||||
direction: 'desc',
|
||||
property: 'dateAdded',
|
||||
},
|
||||
torrentDetails: [
|
||||
{id: 'name', visible: true},
|
||||
{id: 'percentComplete', visible: true},
|
||||
{id: 'downTotal', visible: true},
|
||||
{id: 'downRate', visible: true},
|
||||
{id: 'upTotal', visible: true},
|
||||
{id: 'upRate', visible: true},
|
||||
{id: 'eta', visible: true},
|
||||
{id: 'ratio', visible: true},
|
||||
{id: 'sizeBytes', visible: true},
|
||||
{id: 'peers', visible: true},
|
||||
{id: 'seeds', visible: true},
|
||||
{id: 'dateAdded', visible: true},
|
||||
{id: 'dateCreated', visible: false},
|
||||
{id: 'basePath', visible: false},
|
||||
{id: 'hash', visible: false},
|
||||
{id: 'isPrivate', visible: false},
|
||||
{id: 'message', visible: false},
|
||||
{id: 'trackerURIs', visible: false},
|
||||
{id: 'tags', visible: true},
|
||||
],
|
||||
torrentListColumnWidths: {},
|
||||
torrentContextMenuItems: [
|
||||
{id: 'start', visible: true},
|
||||
{id: 'stop', visible: true},
|
||||
{id: 'remove', visible: true},
|
||||
{id: 'check-hash', visible: true},
|
||||
{id: 'set-taxonomy', visible: true},
|
||||
{id: 'move', visible: true},
|
||||
{id: 'set-tracker', visible: false},
|
||||
{id: 'torrent-details', visible: true},
|
||||
{id: 'torrent-download-tar', visible: true},
|
||||
{id: 'set-priority', visible: false},
|
||||
],
|
||||
torrentListViewSize: 'condensed',
|
||||
speedLimits: {
|
||||
download: [1024, 10240, 102400, 512000, 1048576, 2097152, 5242880, 10485760, 0],
|
||||
upload: [1024, 10240, 102400, 512000, 1048576, 2097152, 5242880, 10485760, 0],
|
||||
},
|
||||
startTorrentsOnLoad: false,
|
||||
mountPoints: [],
|
||||
};
|
||||
floodSettings: FloodSettings = {...defaultFloodSettings};
|
||||
|
||||
getClientSetting<T extends ClientSetting>(property: T): ClientSettings[T] | null {
|
||||
if (this.clientSettings == null) {
|
||||
|
||||
@@ -206,7 +206,7 @@ class TorrentStoreClass extends BaseStore {
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedTorrents({event, hash}: {event: React.MouseEvent<HTMLLIElement>; hash: string}) {
|
||||
setSelectedTorrents({event, hash}: {event: React.MouseEvent; hash: string}) {
|
||||
this.selectedTorrents = selectTorrents({
|
||||
event,
|
||||
hash,
|
||||
|
||||
@@ -2,17 +2,22 @@ import AppDispatcher from '../dispatcher/AppDispatcher';
|
||||
import BaseStore from './BaseStore';
|
||||
|
||||
import type {ConfirmModalProps} from '../components/modals/confirm-modal/ConfirmModal';
|
||||
import type {TorrentContextMenuAction} from '../constants/TorrentContextMenuActions';
|
||||
import type {TorrentDetailsModalProps} from '../components/modals/torrent-details-modal/TorrentDetailsModal';
|
||||
|
||||
export interface ContextMenuItem {
|
||||
type?: 'separator';
|
||||
action: string;
|
||||
label: string;
|
||||
labelAction?: React.ReactNode;
|
||||
labelSecondary?: React.ReactNode;
|
||||
clickHandler(action: ContextMenuItem['action'], event: React.MouseEvent<HTMLLIElement>): void;
|
||||
dismissMenu?: boolean;
|
||||
}
|
||||
export type ContextMenuItem =
|
||||
| {
|
||||
type: 'action';
|
||||
action: TorrentContextMenuAction;
|
||||
label: string;
|
||||
labelAction?: React.ReactNode;
|
||||
labelSecondary?: React.ReactNode;
|
||||
clickHandler(action: TorrentContextMenuAction, event: React.MouseEvent<HTMLLIElement>): void;
|
||||
dismissMenu?: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'separator';
|
||||
};
|
||||
|
||||
export interface ContextMenu {
|
||||
id: string;
|
||||
|
||||
@@ -13,7 +13,7 @@ export type ButtonProps = Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, 'd
|
||||
labelOffset?: boolean;
|
||||
addonPlacement?: 'before' | 'after';
|
||||
priority?: 'primary' | 'secondary' | 'tertiary' | 'quaternary';
|
||||
type?: 'submit' | 'reset' | 'button';
|
||||
type?: 'submit' | 'button';
|
||||
|
||||
wrap?: boolean;
|
||||
wrapper?: string | React.FunctionComponent;
|
||||
@@ -36,21 +36,15 @@ export default class Button extends Component<ButtonProps> {
|
||||
wrapperProps: {width: 'auto'},
|
||||
};
|
||||
|
||||
doesButtonContainIcon() {
|
||||
return React.Children.toArray(this.props.children).some((child) => {
|
||||
const childAsElement = child as React.ReactElement;
|
||||
return childAsElement.type === FormElementAddon;
|
||||
});
|
||||
}
|
||||
|
||||
getButtonContent() {
|
||||
const buttonContent = React.Children.toArray(this.props.children).reduce(
|
||||
const {children, addonPlacement} = this.props;
|
||||
const buttonContent = React.Children.toArray(children).reduce(
|
||||
(accumulator: {addonNodes: Array<React.ReactNode>; childNodes: Array<React.ReactNode>}, child) => {
|
||||
const childAsElement = child as React.ReactElement;
|
||||
if (childAsElement.type === FormElementAddon) {
|
||||
accumulator.addonNodes.push(
|
||||
React.cloneElement(childAsElement, {
|
||||
addonPlacement: this.props.addonPlacement,
|
||||
addonPlacement,
|
||||
key: childAsElement.props.className,
|
||||
}),
|
||||
);
|
||||
@@ -76,14 +70,38 @@ export default class Button extends Component<ButtonProps> {
|
||||
};
|
||||
}
|
||||
|
||||
doesButtonContainIcon() {
|
||||
const {children} = this.props;
|
||||
return React.Children.toArray(children).some((child) => {
|
||||
const childAsElement = child as React.ReactElement;
|
||||
return childAsElement.type === FormElementAddon;
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const classes = classnames('button form__element', this.props.additionalClassNames, {
|
||||
'form__element--label-offset': this.props.labelOffset,
|
||||
'form__element--has-addon': this.props.addonPlacement,
|
||||
[`form__element--has-addon--placed-${this.props.addonPlacement}`]: this.props.addonPlacement,
|
||||
[`button--${this.props.priority}`]: this.props.priority,
|
||||
'button--is-loading': this.props.isLoading,
|
||||
'button--is-disabled': this.props.disabled,
|
||||
const {
|
||||
type,
|
||||
additionalClassNames,
|
||||
buttonRef,
|
||||
labelOffset,
|
||||
addonPlacement,
|
||||
priority,
|
||||
isLoading,
|
||||
disabled,
|
||||
wrap,
|
||||
wrapper,
|
||||
wrapperProps,
|
||||
shrink,
|
||||
grow,
|
||||
onClick,
|
||||
} = this.props;
|
||||
const classes = classnames('button form__element', additionalClassNames, {
|
||||
'form__element--label-offset': labelOffset,
|
||||
'form__element--has-addon': addonPlacement,
|
||||
[`form__element--has-addon--placed-${addonPlacement}`]: addonPlacement,
|
||||
[`button--${priority}`]: priority,
|
||||
'button--is-loading': isLoading,
|
||||
'button--is-disabled': disabled,
|
||||
});
|
||||
const {addonNodes, childNode} = this.getButtonContent();
|
||||
|
||||
@@ -91,12 +109,12 @@ export default class Button extends Component<ButtonProps> {
|
||||
<div className="form__element__wrapper">
|
||||
<button
|
||||
className={classes}
|
||||
disabled={this.props.disabled}
|
||||
onClick={this.props.onClick}
|
||||
ref={this.props.buttonRef}
|
||||
type={this.props.type}>
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
ref={buttonRef}
|
||||
type={type === 'submit' ? 'submit' : 'button'}>
|
||||
{childNode}
|
||||
<FadeIn in={this.props.isLoading}>
|
||||
<FadeIn isIn={isLoading}>
|
||||
<LoadingRing />
|
||||
</FadeIn>
|
||||
</button>
|
||||
@@ -104,14 +122,14 @@ export default class Button extends Component<ButtonProps> {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (this.props.wrap) {
|
||||
const WrapperComponent = this.props.wrapper as React.FunctionComponent;
|
||||
if (wrap) {
|
||||
const WrapperComponent = wrapper as React.FunctionComponent;
|
||||
return (
|
||||
<WrapperComponent
|
||||
{...{
|
||||
shrink: this.props.shrink,
|
||||
grow: this.props.grow,
|
||||
...this.props.wrapperProps,
|
||||
shrink,
|
||||
grow,
|
||||
...wrapperProps,
|
||||
}}>
|
||||
{content}
|
||||
</WrapperComponent>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user