client: fully migrate to TypeScript

This commit is contained in:
Jesse Chan
2020-10-16 00:54:14 +08:00
parent dbeecf8d69
commit 8fb9f70c35
129 changed files with 3636 additions and 3246 deletions

View File

@@ -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: {

View File

@@ -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': {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
});
}

View File

@@ -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,
)}

View File

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

View File

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

View File

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

View File

@@ -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',

View File

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

View File

@@ -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()}

View File

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

View File

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

View File

@@ -103,4 +103,5 @@ class PriorityMeter extends React.Component<PriorityMeterProps, PriorityMeterSta
}
}
export type PriorityMeterType = PriorityMeter;
export default injectIntl(PriorityMeter, {forwardRef: true});

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

@@ -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')),

View File

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

View File

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

View File

@@ -86,7 +86,7 @@ class AddTorrentsByURL extends React.Component<AddTorrentsByURLProps, AddTorrent
id: 'torrents.add.destination.label',
})}
selectable="directories"
basePathToggle
showBasePathToggle
/>
<FormRow>
<TagSelect

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 [
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),

View File

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

View File

@@ -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),

View File

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

View File

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

View File

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

View File

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

View File

@@ -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} &ndash; {newerTo}
<ChevronLeftIcon />
{`${newerFrom + 1} &ndash; ${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} &ndash;
{olderTo} <ChevronRightIcon />
{`${olderFrom} &ndash; ${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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View 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);

View File

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

View File

@@ -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>
&nbsp;&mdash;&nbsp;
<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;

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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>
&nbsp;&mdash;&nbsp;
<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);

View File

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

View File

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

View File

@@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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) {

View File

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

View File

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

View File

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