diff --git a/client/source/sass/components/_client-stats.scss b/client/source/sass/components/_client-stats.scss index 0c6194b7..d851573e 100644 --- a/client/source/sass/components/_client-stats.scss +++ b/client/source/sass/components/_client-stats.scss @@ -127,20 +127,6 @@ $client-stats--limits--icon--hover: $blue; transition: fill 0.25s; vertical-align: middle; width: 14.5px; - - .limits { - - &__bars { - - &--top { - opacity: 0.4; - } - - &--bottom { - opacity: 0.6; - } - } - } } &:hover { diff --git a/client/source/sass/components/_icons.scss b/client/source/sass/components/_icons.scss index 02fc3df3..0c3ebf52 100644 --- a/client/source/sass/components/_icons.scss +++ b/client/source/sass/components/_icons.scss @@ -41,4 +41,21 @@ } } } + + &--limits { + + .limits { + + &__bars { + + &--top { + fill-opacity: 0.4; + } + + &--bottom { + fill-opacity: 0.6; + } + } + } + } } diff --git a/client/source/sass/components/_modals.scss b/client/source/sass/components/_modals.scss index 2b597b1f..b2d70c0c 100644 --- a/client/source/sass/components/_modals.scss +++ b/client/source/sass/components/_modals.scss @@ -31,6 +31,16 @@ $modal--tabs--margin--right: $spacing-unit * -1/5; $modal--tabs--margin--bottom: 0; $modal--tabs--margin--left: $spacing-unit * -1/5; +$modal--tabs--padding--top: $spacing-unit * 1/5; +$modal--tabs--padding--right: $spacing-unit * 1/5; +$modal--tabs--padding--bottom: $spacing-unit * 2/5; +$modal--tabs--padding--left: $spacing-unit * 1/5; + +$modal--tabs--padding--vertical--top: $spacing-unit * 2/5; +$modal--tabs--padding--vertical--right: $spacing-unit * 2/5; +$modal--tabs--padding--vertical--bottom: $spacing-unit * 2/5; +$modal--tabs--padding--vertical--left: $modal--padding--horizontal; + .modal { background: $modal--overlay; height: 100%; @@ -57,7 +67,7 @@ $modal--tabs--margin--left: $spacing-unit * -1/5; cursor: pointer; display: inline-block; margin-right: $spacing-unit * 2/5; - padding: $spacing-unit * 1/5 $spacing-unit * 1/5 $spacing-unit * 2/5 $spacing-unit * 1/5; + padding: $modal--tabs--padding--top $modal--tabs--padding--right $modal--tabs--padding--bottom $modal--tabs--padding--left; position: relative; &:after { @@ -86,6 +96,15 @@ $modal--tabs--margin--left: $spacing-unit * -1/5; } } + &__tab { + + &__introduction { + color: $modal--heading--foreground; + font-size: 0.9em; + margin-bottom: $spacing-unit * 3/4; + } + } + &__header { background: $modal--heading--background; border-radius: $modal--border-radius $modal--border-radius 0 0; @@ -165,4 +184,69 @@ $modal--tabs--margin--left: $spacing-unit * -1/5; &__animation-leave-active { opacity: 0; } + + &--orientation { + + &--vertical { + + &.modal__content__wrapper { + flex-direction: row; + } + + .modal { + + &__header { + border-radius: $modal--border-radius 0 0 $modal--border-radius; + box-shadow: inset -1px 0 0 $modal--heading--border; + flex-basis: 175px; + padding-bottom: $modal--padding--horizontal; + padding-right: 0; + max-width: 175px; + } + + &__tabs { + margin: 10px 0 0 $modal--padding--horizontal * -1; + + .modal { + + &__tab { + display: block; + margin-right: 0; + padding: $modal--tabs--padding--vertical--top $modal--tabs--padding--vertical--right $modal--tabs--padding--vertical--bottom $modal--tabs--padding--vertical--left; + + &:after { + bottom: 0; + content: ''; + height: auto; + left: auto; + position: absolute; + right: 0; + top: 0; + transition: background 0.25s; + width: 1px; + } + } + } + } + + &__content { + display: flex; + flex-direction: column; + min-height: 500px; + } + + &__body { + flex: 1 0 auto; + } + + &__actions { + flex: 0 0 auto; + } + } + } + } + + &--large { + width: 700px; + } } diff --git a/client/source/sass/components/_sidebar.scss b/client/source/sass/components/_sidebar.scss index 8e04f43b..1322dcec 100644 --- a/client/source/sass/components/_sidebar.scss +++ b/client/source/sass/components/_sidebar.scss @@ -6,6 +6,12 @@ $sidebar-filter--foreground--header: rgba($sidebar-filter--foreground, 0.5); $sidebar-filter--foreground--active: $blue; $sidebar-filter--foreground--hover: lighten($sidebar-filter--foreground, 15%); +$sidebar--icon-button--fill: $sidebar--foreground; +$sidebar--icon-button--fill--hover: $blue; +$sidebar--icon-button--foreground: $sidebar--foreground; +$sidebar--icon-button--foreground--hover: $blue; + + .application { &__sidebar { @@ -40,4 +46,34 @@ $sidebar-filter--foreground--hover: lighten($sidebar-filter--foreground, 15%); } } } + + &__icon-button { + color: $sidebar--icon-button--foreground; + cursor: pointer; + display: block; + line-height: 0; + transition: color 0.25s; + + &:hover { + color: $sidebar--icon-button--foreground--hover; + + .icon { + fill: $sidebar--icon-button--fill--hover; + } + } + + .icon { + fill: $sidebar--icon-button--fill; + height: 16px; + transition: fill 0.25s; + width: 16px; + } + + &--settings { + padding: 10px 15px; + position: absolute; + right: $spacing-unit * 1/5; + top: $spacing-unit * 1/5; + } + } } diff --git a/client/source/scripts/actions/SettingsActions.js b/client/source/scripts/actions/SettingsActions.js new file mode 100644 index 00000000..08641f82 --- /dev/null +++ b/client/source/scripts/actions/SettingsActions.js @@ -0,0 +1,46 @@ +import axios from 'axios'; + +import AppDispatcher from '../dispatcher/AppDispatcher'; +import ActionTypes from '../constants/ActionTypes'; + +const SettingsActions = { + fetchSettings: () => { + return axios.get('/client/settings') + .then((json = {}) => { + return json.data; + }) + .then((data) => { + AppDispatcher.dispatchServerAction({ + type: ActionTypes.SETTINGS_FETCH_REQUEST_SUCCESS, + data + }); + }) + .catch((error) => { + AppDispatcher.dispatchServerAction({ + type: ActionTypes.SETTINGS_FETCH_REQUEST_ERROR, + error + }); + }); + }, + + saveSettings: (settings) => { + return axios.patch('/client/settings', settings) + .then((json = {}) => { + return json.data; + }) + .then((data) => { + AppDispatcher.dispatchServerAction({ + type: ActionTypes.SETTINGS_SAVE_REQUEST_SUCCESS, + data + }); + }) + .catch((error) => { + AppDispatcher.dispatchServerAction({ + type: ActionTypes.SETTINGS_SAVE_REQUEST_ERROR, + error + }); + }); + } +}; + +export default SettingsActions; diff --git a/client/source/scripts/components/icons/SettingsIcon.js b/client/source/scripts/components/icons/SettingsIcon.js new file mode 100644 index 00000000..770e9a6d --- /dev/null +++ b/client/source/scripts/components/icons/SettingsIcon.js @@ -0,0 +1,14 @@ +import React from 'react'; + +import BaseIcon from './BaseIcon'; + +export default class SettingsIcon extends BaseIcon { + render() { + return ( + + + + ); + } +} diff --git a/client/source/scripts/components/modals/Modal.js b/client/source/scripts/components/modals/Modal.js index 15545d5b..e4a37119 100644 --- a/client/source/scripts/components/modals/Modal.js +++ b/client/source/scripts/components/modals/Modal.js @@ -41,7 +41,10 @@ export default class Modal extends React.Component { let content = this.props.content; let footer = null; let contentClasses = classnames('modal__content__wrapper', - `modal--align-${this.props.alignment}`); + `modal--align-${this.props.alignment}`, { + 'modal--orientation--horizontal': this.props.orientation === 'horizontal', + 'modal--orientation--vertical': this.props.orientation === 'vertical' + }, this.props.classNames); let headerClasses = classnames('modal__header', { 'has-tabs': this.props.tabs }); @@ -58,7 +61,10 @@ export default class Modal extends React.Component { } if (this.props.actions) { - footer = ; + footer = ( + + ); } return ( @@ -79,5 +85,7 @@ export default class Modal extends React.Component { } Modal.defaultProps = { - alignment: 'left' + alignment: 'left', + classNames: null, + orientation: 'horizontal' }; diff --git a/client/source/scripts/components/modals/Modals.js b/client/source/scripts/components/modals/Modals.js index 2c289f86..63da1a94 100644 --- a/client/source/scripts/components/modals/Modals.js +++ b/client/source/scripts/components/modals/Modals.js @@ -7,6 +7,7 @@ import ConfirmModal from './ConfirmModal'; import EventTypes from '../../constants/EventTypes'; import Modal from './Modal'; import MoveTorrents from './MoveTorrents'; +import SettingsModal from './SettingsModal'; import UIActions from '../../actions/UIActions'; import UIStore from '../../stores/UIStore'; @@ -23,7 +24,8 @@ export default class Modals extends React.Component { this.modals = { confirm: ConfirmModal, 'move-torrents': MoveTorrents, - 'add-torrents': AddTorrents + 'add-torrents': AddTorrents, + 'settings': SettingsModal }; this.state = { diff --git a/client/source/scripts/components/modals/SettingsModal.js b/client/source/scripts/components/modals/SettingsModal.js new file mode 100644 index 00000000..62fa4be6 --- /dev/null +++ b/client/source/scripts/components/modals/SettingsModal.js @@ -0,0 +1,120 @@ +import classnames from 'classnames'; +import React from 'react'; + +import EventTypes from '../../constants/EventTypes'; +import Modal from './Modal'; +import SettingsSpeedLimit from './SettingsSpeedLimit'; +import SettingsStore from '../../stores/SettingsStore'; + +const METHODS_TO_BIND = [ + 'handleSettingsChange', + 'handleSaveSettingsClick', + 'handleSettingsFetchRequestSuccess' +]; + +export default class AddTorrents extends React.Component { + constructor() { + super(); + + this.state = { + settings: { + speedLimits: { + download: null, + upload: null + } + } + }; + + METHODS_TO_BIND.forEach((method) => { + this[method] = this[method].bind(this); + }); + } + + componentDidMount() { + SettingsStore.listen(EventTypes.SETTINGS_FETCH_REQUEST_SUCCESS, this.handleSettingsFetchRequestSuccess); + SettingsStore.fetchSettings(EventTypes.SETTINGS_FETCH_REQUEST_ERROR, this.handleSettingsFetchRequestError); + } + + componentWillUnmount() { + SettingsStore.unlisten(EventTypes.SETTINGS_FETCH_REQUEST_SUCCESS, this.handleSettingsFetchRequestSuccess); + } + + getActions() { + let icon = null; + let primaryButtonText = 'Add Torrent'; + + if (this.state.isAddingTorrents) { + icon = ; + primaryButtonText = 'Adding...'; + } + + return [ + { + clickHandler: null, + content: 'Cancel', + triggerDismiss: true, + type: 'secondary' + }, + { + clickHandler: this.handleSaveSettingsClick, + content: 'Save Settings', + triggerDismiss: false, + type: 'primary' + } + ]; + } + + handleSaveSettingsClick() { + SettingsStore.saveSettings(this.state.settings); + } + + handleSettingsFetchRequestError() { + console.log(error); + } + + handleSettingsFetchRequestSuccess() { + this.setState({ + settings: SettingsStore.getSettings() + }); + } + + handleSettingsChange(changedSettings) { + let settings = this.mergeObjects(this.state.settings, changedSettings); + this.setState({settings}); + } + + mergeObjects(objA, objB) { + Object.keys(objB).forEach((key) => { + if (!objB.hasOwnProperty(key) || objB[key] == null) { + return; + } + + // If it's an object, then recursive merge. + if (!Array.isArray(objB[key]) && !Array.isArray(objB[key]) && typeof objA[key] === 'object' && typeof objB[key] === 'object') { + objA[key] = this.mergeObjects(objA[key], objB[key]); + } else { + objA[key] = objB[key]; + } + }); + + return objA; + } + + render() { + let tabs = { + 'speed-limit': { + content: ( + + ), + label: 'Speed Limits' + } + }; + + return ( + + ); + } +} diff --git a/client/source/scripts/components/modals/SettingsSpeedLimit.js b/client/source/scripts/components/modals/SettingsSpeedLimit.js new file mode 100644 index 00000000..982d4ea8 --- /dev/null +++ b/client/source/scripts/components/modals/SettingsSpeedLimit.js @@ -0,0 +1,129 @@ +import React from 'react'; + +const METHODS_TO_BIND = ['handleDownloadTextChange', 'handleUploadTextChange']; + +export default class AddTorrents extends React.Component { + constructor() { + super(); + + this.state = { + downloadValue: null, + uploadValue: null + } + + METHODS_TO_BIND.forEach((method) => { + this[method] = this[method].bind(this); + }); + } + + arrayToString(array) { + return array.join(', '); + } + + getTextboxValue(input = []) { + if (Array.isArray(input)) { + return this.arrayToString(input); + } + + return input; + } + + handleDownloadTextChange(event) { + this.setState({ + downloadValue: event.target.value + }); + + this.props.onSettingsChange({ + speedLimits: { + download: this.processSpeedsForSave(event.target.value) + } + }); + } + + handleUploadTextChange(event) { + this.setState({ + uploadValue: event.target.value + }); + + this.props.onSettingsChange({ + speedLimits: { + upload: this.processSpeedsForSave(event.target.value) + } + }); + } + + getDownloadValue() { + let displayedValue = this.state.downloadValue; + + if (displayedValue == null) { + displayedValue = this.processSpeedsForDisplay(this.props.settings.speedLimits.download); + } + + return displayedValue; + } + + getUploadValue() { + let displayedValue = this.state.uploadValue; + + if (displayedValue == null) { + displayedValue = this.processSpeedsForDisplay(this.props.settings.speedLimits.upload); + } + + return displayedValue; + } + + processSpeedsForDisplay(speeds = []) { + if (!speeds || speeds.length === 0) { + return; + } + + return this.arrayToString(speeds.map((speed) => { + return Number(speed) / 1024; + })); + } + + processSpeedsForSave(speeds = '') { + if (speeds === '') { + return []; + } + + return this.stringToArray(speeds).map((speed) => { + return Number(speed) * 1024; + }); + } + + stringToArray(string = '') { + return string.replace(/\s/g, '').split(','); + } + + render() { + let downloadValue = this.getDownloadValue(); + let uploadValue = this.getUploadValue(); + + return ( +
+

+ Provide a comma-separated list of speed values (in kilobytes per second). 0 represents unlimited. +

+
+
+ + +
+
+ + +
+
+
+ ); + } +} diff --git a/client/source/scripts/components/panels/Sidebar.js b/client/source/scripts/components/panels/Sidebar.js index 3878ea30..09e36d76 100644 --- a/client/source/scripts/components/panels/Sidebar.js +++ b/client/source/scripts/components/panels/Sidebar.js @@ -3,6 +3,7 @@ import React from 'react'; import ClientStats from '../sidebar/TransferData'; import CustomScrollbars from '../ui/CustomScrollbars'; import SearchBox from '../forms/SearchBox'; +import SettingsButton from '../sidebar/SettingsButton'; import SpeedLimitDropdown from '../sidebar/SpeedLimitDropdown'; import StatusFilters from '../sidebar/StatusFilters'; import TrackerFilters from '../sidebar/TrackerFilters'; @@ -11,6 +12,7 @@ class Sidebar extends React.Component { render() { return ( + diff --git a/client/source/scripts/components/sidebar/SettingsButton.js b/client/source/scripts/components/sidebar/SettingsButton.js new file mode 100644 index 00000000..4f70c3c2 --- /dev/null +++ b/client/source/scripts/components/sidebar/SettingsButton.js @@ -0,0 +1,25 @@ +import React from 'react'; + +import SettingsIcon from '../icons/SettingsIcon'; +import UIActions from '../../actions/UIActions'; + +class SettingsButton extends React.Component { + constructor() { + super(); + } + + handleSettingsButtonClick() { + UIActions.displayModal({id: 'settings'}); + } + + render() { + return ( + + + + ); + } +} + +export default SettingsButton; diff --git a/client/source/scripts/components/sidebar/SpeedLimitDropdown.js b/client/source/scripts/components/sidebar/SpeedLimitDropdown.js index 6aab52b1..49dc2ced 100644 --- a/client/source/scripts/components/sidebar/SpeedLimitDropdown.js +++ b/client/source/scripts/components/sidebar/SpeedLimitDropdown.js @@ -4,18 +4,25 @@ import ClientActions from '../../actions/ClientActions'; import Dropdown from '../forms/Dropdown'; import EventTypes from '../../constants/EventTypes'; import format from '../../util/formatData'; -import Limits from '../icons/Limits'; +import LimitsIcon from '../icons/Limits'; import SidebarItem from '../sidebar/SidebarItem'; +import SettingsStore from '../../stores/SettingsStore'; import TransferDataStore from '../../stores/TransferDataStore'; -const METHODS_TO_BIND = ['onTransferDataRequestSuccess']; +const METHODS_TO_BIND = ['handleSettingsFetchRequestSuccess', 'onTransferDataRequestSuccess']; const SPEEDS = [1024, 10240, 102400, 512000, 1048576, 2097152, 5242880, 10485760, 0]; -class Sidebar extends React.Component { +class SpeedLimitDropdown extends React.Component { constructor() { super(); this.state = { + settings: { + speedLimits: { + download: [], + upload: [] + } + }, throttle: null }; @@ -25,6 +32,8 @@ class Sidebar extends React.Component { } componentDidMount() { + SettingsStore.listen(EventTypes.SETTINGS_FETCH_REQUEST_SUCCESS, this.handleSettingsFetchRequestSuccess); + SettingsStore.fetchSettings(EventTypes.SETTINGS_FETCH_REQUEST_ERROR, this.handleSettingsFetchRequestError); TransferDataStore.listen( EventTypes.CLIENT_TRANSFER_DATA_REQUEST_SUCCESS, this.onTransferDataRequestSuccess @@ -33,6 +42,7 @@ class Sidebar extends React.Component { } componentWillUnmount() { + SettingsStore.unlisten(EventTypes.SETTINGS_FETCH_REQUEST_SUCCESS, this.handleSettingsFetchRequestSuccess); TransferDataStore.unlisten( EventTypes.CLIENT_TRANSFER_DATA_REQUEST_SUCCESS, this.onTransferDataRequestSuccess @@ -48,7 +58,7 @@ class Sidebar extends React.Component { getDropdownHeader() { return ( - Speed Limits + Speed Limits ); } @@ -78,8 +88,9 @@ class Sidebar extends React.Component { let insertCurrentThrottle = true; let currentThrottle = this.state.throttle; - let items = SPEEDS.map((bytes) => { + let items = this.state.settings.speedLimits[property].map((bytes) => { let selected = false; + bytes = Number(bytes); // Check if the current throttle setting exists in the preset speeds list. // Determine if we need to add the current throttle setting to the menu. @@ -126,7 +137,14 @@ class Sidebar extends React.Component { ClientActions.setThrottle(data.property, data.value); } + handleSettingsFetchRequestSuccess() { + this.setState({ + settings: SettingsStore.getSettings() + }); + } + render() { + // return hi; return ( { + const {action, source} = payload; + + switch (action.type) { + case ActionTypes.SETTINGS_FETCH_REQUEST_SUCCESS: + SettingsStore.handleSettingsFetchSuccess(action.data); + break; + case ActionTypes.SETTINGS_FETCH_REQUEST_ERROR: + SettingsStore.handleSettingsFetchError(action.error); + break; + } +}); + +export default SettingsStore; diff --git a/server/db/settings/settings.db b/server/db/settings/settings.db new file mode 100644 index 00000000..37fd57e5 --- /dev/null +++ b/server/db/settings/settings.db @@ -0,0 +1 @@ +{"1":"","id":"settings","speedLimits":{"download":[1024,10240,102400,512000,1048576,5242880,10485760,0],"upload":[1024,10240,102400,512000,1048576,2097152,5242880,10485760,0]},"_id":"ZzqDjOcloseZX8mt"} diff --git a/server/models/settings.js b/server/models/settings.js new file mode 100644 index 00000000..79f08aab --- /dev/null +++ b/server/models/settings.js @@ -0,0 +1,37 @@ +'use strict'; + +let Datastore = require('nedb'); + +let client = require('./client'); +let config = require('../../config'); +let HistoryEra = require('./HistoryEra'); + +let settingsDB = new Datastore({ + autoload: true, + filename: `${config.dbPath}settings/settings.db` +}); + +let history = { + get: (opts, callback) => { + settingsDB.find({id: 'settings'}).exec((err, docs) => { + if (err) { + callback(null, err); + return; + } + callback(docs[0]); + } + ); + }, + + set: (settings, callback) => { + settingsDB.update({id: 'settings'}, {$set: settings}, {upsert: true}, (err, docs) => { + if (err) { + callback(null, err); + return; + } + callback(docs); + }); + } +} + +module.exports = history; diff --git a/server/routes/client.js b/server/routes/client.js index 4704cc48..ef770996 100644 --- a/server/routes/client.js +++ b/server/routes/client.js @@ -8,6 +8,7 @@ let ajaxUtil = require('../util/ajaxUtil'); let client = require('../models/client'); let clientUtil = require('../util/clientUtil'); let history = require('../models/history'); +let settings = require('../models/settings'); let upload = multer({ dest: 'uploads/', @@ -27,6 +28,14 @@ router.get('/history', function(req, res, next) { history.get(req.query, ajaxUtil.getResponseFn(res)); }); +router.get('/settings', function(req, res, next) { + settings.get(req.query, ajaxUtil.getResponseFn(res)); +}); + +router.patch('/settings', function(req, res, next) { + settings.set(req.body, ajaxUtil.getResponseFn(res)); +}); + router.put('/settings/speed-limits', function(req, res, next) { client.setSpeedLimits(req.body, ajaxUtil.getResponseFn(res)); });