receptor/src/ui/torrent_details.js

478 lines
15 KiB
JavaScript

import React, { Component } from 'react';
import { connect } from 'react-redux';
import FontAwesome from 'react-fontawesome';
import {
ButtonGroup,
ButtonDropdown,
DropdownToggle,
DropdownMenu,
DropdownItem,
Collapse,
Card,
CardBlock,
Progress,
Input,
} from 'reactstrap';
import moment from 'moment';
import sha1 from 'js-sha1';
import TorrentOptions from './torrent_options';
import TorrentProgress from './torrent_progress';
import ws_send from '../socket';
import store from '../store';
import DateDisplay from './date';
import selectTorrent, {
EXCLUSIVE,
UNION,
SUBTRACT,
NONE
} from '../actions/selection';
import { updateResource } from '../actions/resources';
import { formatBitrate } from '../bitrate';
const dlURI = (uri, token, id) => {
const dlToken = encodeURIComponent(btoa(String.fromCharCode(...new Uint8Array(sha1.arrayBuffer(id + token)))));
return `${uri.replace('ws', 'http')}/dl/${id}?token=${dlToken}`;
};
function basename(path) {
const parts = path.split("/");
return parts[parts.length - 1];
}
class File extends Component {
shouldComponentUpdate(nextProps, _) {
return nextProps.file !== this.props.file
|| nextProps.server !== this.props.server
|| nextProps.socket !== this.props.socket;
}
render() {
const { dispatch, file, server, socket } = this.props;
const { download_token } = server;
const { uri } = socket;
return (
<div className="file">
<Progress
value={file.progress * 100}
color={file.progress != 1.0 ? "success" : "primary"}
>
{file.progress === 1.0 ?
"done" : `${Math.floor(file.progress * 100)}%`}
</Progress>
<div className="path" title={file.path}>
{file.progress === 1.0 ?
<a href={dlURI(uri, download_token, file.id)} target="_new">
{basename(file.path)}
</a> : basename(file.path)}
</div>
<div>
<Input
type="select"
id="priority"
value={file.priority}
onChange={e => dispatch(updateResource({
id: file.id,
priority: parseInt(e.target.value)
}))}
>
<option value="0">Skip</option>
<option value="1">Lowest</option>
<option value="2">Low</option>
<option value="3">Normal</option>
<option value="4">High</option>
<option value="5">Highest</option>
</Input>
</div>
</div>
);
}
}
class Peer extends Component {
shouldComponentUpdate(nextProps, _) {
return nextProps.peer !== this.props.peer;
}
render() {
const { peer } = this.props;
return (
<div className="peer">
<div style={{flexGrow: 5}}>{peer.ip}</div>
<div style={{flexBasis: "18%"}}>{formatBitrate(peer.rate_up)} up</div>
<div style={{flexBasis: "18%"}}>{formatBitrate(peer.rate_down)} down</div>
<div style={{flexBasis: "18%"}}>has {`${(peer.availability * 100).toFixed(0)}%`}</div>
</div>
);
}
}
// TODO: move to separate component
function CollapseToggle({ text, onToggle, open }) {
return (
<button
className="btn btn-sm btn-default"
onClick={onToggle}
>
{text}
<FontAwesome
name={`caret-${open ? "up" : "down"}`}
style={{marginLeft: "0.25rem"}}
/>
</button>
);
}
class Torrent extends Component {
constructor() {
super();
this.state = {
infoShown: false,
filesShown: false,
trackersShown: false,
peersShown: false,
removeDropdown: false
};
}
shouldComponentUpdate(nextProps, nextState) {
return nextProps.torrent !== this.props.torrent
|| nextProps.trackers !== this.props.trackers
|| nextProps.files !== this.props.files
|| nextProps.peers !== this.props.peers
|| nextState.removeDropdown !== this.state.removeDropdown
|| nextState.peersShown !== this.state.peersShown
|| nextState.trackersShown !== this.state.trackersShown
|| nextState.filesShown !== this.state.filesShown
|| nextState.infoShown !== this.state.infoShown;
}
toggleTorrentState(torrent) {
if (torrent.status === "paused") {
ws_send("RESUME_TORRENT", { id: torrent.id });
} else {
ws_send("PAUSE_TORRENT", { id: torrent.id });
}
}
render() {
const {
dispatch,
torrent,
files,
trackers,
peers,
server,
socket,
} = this.props;
const status = s => s[0].toUpperCase() + s.slice(1);
if (!torrent || !files) {
return (
<p>Loading...</p>
);
}
return (
<div>
<h4>{torrent.name}</h4>
<div className="torrent-controls">
<a
href="#"
style={{margin: "auto 0.5rem"}}
title={torrent.status === "paused" ? "Resume" : "Pause"}
onClick={e => {
e.preventDefault();
this.toggleTorrentState(torrent);
}}
>
<FontAwesome name={torrent.status === "paused" ? "play" : "pause"} />
</a>
<div className="status">{status(torrent.status)}</div>
<TorrentProgress torrent={torrent} />
<ButtonDropdown
isOpen={this.state.removeDropdown}
toggle={() => this.setState({ removeDropdown: !this.state.removeDropdown })}
>
<DropdownToggle color="danger" caret>
Remove
</DropdownToggle>
<DropdownMenu>
<DropdownItem
onClick={() => {
dispatch(selectTorrent([torrent.id], SUBTRACT));
ws_send("REMOVE_RESOURCE", { id: torrent.id });
}}
>Remove</DropdownItem>
<DropdownItem
onClick={() => {
dispatch(selectTorrent([torrent.id], SUBTRACT));
ws_send("REMOVE_RESOURCE", { id: torrent.id, artifacts: true });
}}
>Remove and delete files</DropdownItem>
</DropdownMenu>
</ButtonDropdown>
</div>
<ButtonGroup className="toggles">
<CollapseToggle
text="Info"
onToggle={() => this.setState({ infoShown: !this.state.infoShown })}
open={this.state.infoShown}
/>
<CollapseToggle
text="Files"
onToggle={() => this.setState({ filesShown: !this.state.filesShown })}
open={this.state.filesShown}
/>
<CollapseToggle
text="Trackers"
onToggle={() => this.setState({ trackersShown: !this.state.trackersShown })}
open={this.state.trackersShown}
/>
<CollapseToggle
text="Peers"
onToggle={() => this.setState({ peersShown: !this.state.peersShown })}
open={this.state.peersShown}
/>
</ButtonGroup>
<Collapse isOpen={this.state.infoShown}>
<Card style={{marginBottom: "1rem"}}>
<CardBlock>
<button
className="btn btn-sm btn-outline-primary pull-right"
onClick={() => {
ws_send("VALIDATE_RESOURCES", { ids: [torrent.id] })
}}
>Initiate hash check</button>
<dl>
<dt>Type</dt>
<dd>{torrent.private ? "Private" : "Public"}</dd>
<dt>Downloading to</dt>
<dd>{torrent.path}</dd>
<dt>Created</dt>
<dd><DateDisplay when={moment(torrent.created)} /></dd>
<dt>Comment</dt>
<dd>{torrent.comment || "None"}</dd>
<dt>Creator</dt>
<dd>{torrent.creator || "None"}</dd>
</dl>
<TorrentOptions
id={torrent.id}
priority={torrent.priority}
strategy={torrent.strategy}
priorityChanged={priority =>
dispatch(updateResource({ id: torrent.id, priority }))}
strategyChanged={strategy =>
dispatch(updateResource({ id: torrent.id, strategy }))}
downloadThrottle={torrent.throttle_down}
downloadThrottleChanged={throttle_down =>
dispatch(updateResource({ id: torrent.id, throttle_down }))}
uploadThrottle={torrent.throttle_up}
uploadThrottleChanged={throttle_up =>
dispatch(updateResource({ id: torrent.id, throttle_up }))}
/>
</CardBlock>
</Card>
</Collapse>
<Collapse isOpen={this.state.filesShown}>
<Card style={{marginBottom: "1rem"}}>
<CardBlock style={{padding: "0"}}>
<div className="files flex-table" style={{marginBottom: "0"}}>
{this.state.filesShown
? files.slice()
.sort((a, b) => a.path.localeCompare(b.path, undefined, {numeric: true}))
.map(file => <File
dispatch={dispatch}
file={file}
server={server}
socket={socket}
/>)
: null}
</div>
</CardBlock>
</Card>
</Collapse>
<Collapse isOpen={this.state.trackersShown}>
<Card style={{marginBottom: "1rem"}}>
<CardBlock>
{trackers.map(tracker =>
<div>
<h5>{(() => {
const a = document.createElement("a");
a.href = tracker.url;
return a.hostname;
})()}
<button
className="btn btn-sm btn-outline-primary pull-right"
onClick={() => {
ws_send("UPDATE_TRACKER", { id: tracker.id })
}}
>Report</button>
</h5>
<dl>
<dt>URL</dt>
<dd>{tracker.url}</dd>
<dt>Last report</dt>
<dd><DateDisplay when={moment(tracker.last_report)} /></dd>
{tracker.error && <dt>Error</dt>}
{tracker.error &&
<dd className="text-danger">{tracker.error}</dd>}
</dl>
</div>
)}
</CardBlock>
</Card>
</Collapse>
<Collapse isOpen={this.state.peersShown}>
<Card style={{marginBottom: "1rem"}}>
<CardBlock style={{padding: "0"}}>
<div className="peers flex-table" style={{marginBottom: "0"}}>
{this.state.peersShown ? peers.map(peer => <Peer peer={peer} />) : null}
</div>
{peers.length === 0 &&
<div style={{padding: "0.5rem 0 0 0.5rem"}}>
<p>No connected peers.</p>
</div>}
</CardBlock>
</Card>
</Collapse>
</div>
);
}
}
class TorrentDetails extends Component {
constructor() {
super();
this.state = {
removeDropdown: false
};
}
componentDidMount() {
const { dispatch } = this.props;
const { ids } = this.props.match.params;
const _ids = ids.split(",");
dispatch(selectTorrent(_ids, UNION));
}
componentWillUnmount() {
const { dispatch } = this.props;
dispatch(selectTorrent([], EXCLUSIVE, false));
}
renderHeader() {
const { dispatch, selection } = this.props;
return (
<div>
<h3>
{selection.length} torrents
<div className="bulk-controls">
<a
href="#"
title="Resume all"
onClick={e => {
e.preventDefault();
selection.forEach(id => ws_send("RESUME_TORRENT", { id }));
}}
><FontAwesome name="play" /></a>
<a
href="#"
title="Pause all"
onClick={e => {
e.preventDefault();
selection.forEach(id => ws_send("PAUSE_TORRENT", { id }));
}}
><FontAwesome name="pause" /></a>
<ButtonDropdown
isOpen={this.state.removeDropdown}
toggle={() => this.setState({ removeDropdown: !this.state.removeDropdown })}
>
<DropdownToggle color="danger" caret>
Remove all
</DropdownToggle>
<DropdownMenu>
<DropdownItem
onClick={() => {
dispatch(selectTorrent(selection, SUBTRACT));
selection.forEach(id => ws_send("REMOVE_RESOURCE", { id }));
}}
>Remove selected torrents</DropdownItem>
<DropdownItem
onClick={() => {
dispatch(selectTorrent(selection, SUBTRACT));
selection.forEach(id => ws_send("REMOVE_RESOURCE", {
id,
artifacts: true
}));
}}
>Remove selected torrents and delete files</DropdownItem>
</DropdownMenu>
</ButtonDropdown>
</div>
</h3>
</div>
);
}
render() {
const {
torrents,
files,
trackers,
peers,
selection,
server,
socket,
dispatch
} = this.props;
const index_by_tid = (res) => {
let _indexed = {};
Object.values(res).map(r => {
if (!(r.torrent_id in _indexed)) {
_indexed[r.torrent_id] = [];
}
_indexed[r.torrent_id].push(r);
});
return _indexed;
};
const _files = index_by_tid(files);
const _trackers = index_by_tid(trackers);
const _peers = index_by_tid(peers);
return (
<div>
{selection.length > 1 ? this.renderHeader.bind(this)() : null}
{selection.slice(0, 3).map(id => <Torrent
dispatch={dispatch}
torrent={torrents[id]}
files={_files[id] || []}
trackers={_trackers[id] || []}
peers={_peers[id] || []}
server={server}
socket={socket}
key={id}
/>)}
{selection.length > 3 ?
<p className="text-center text-muted">
<strong>
...{selection.length - 3} more hidden...
</strong>
</p>
: null}
</div>
);
}
}
export default connect(state => ({
router: state.router,
torrents: state.torrents,
files: state.files,
trackers: state.trackers,
peers: state.peers,
selection: state.selection,
server: state.server,
socket: state.socket,
}))(TorrentDetails);