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