diff --git a/client/source/sass/components/_notifications.scss b/client/source/sass/components/_notifications.scss
index 4f40e3f6..69b7a3c0 100644
--- a/client/source/sass/components/_notifications.scss
+++ b/client/source/sass/components/_notifications.scss
@@ -33,3 +33,54 @@ $notification--foreground: #8fa2b2;
}
}
}
+
+.notification {
+ display: flex;
+
+ &.is-success {
+
+ .icon {
+ fill: $green;
+ }
+
+ .notification {
+
+ &__count {
+ color: $green;
+ }
+ }
+ }
+
+ &.is-error {
+
+ .icon {
+ fill: $red;
+ }
+
+ .notification {
+
+ &__count {
+ color: $red;
+ }
+ }
+ }
+
+ &__content {
+ flex: 1 1 auto;
+ }
+
+ &__count {
+ font-weight: 800;
+ }
+
+ .icon {
+ align-self: center;
+ display: inline-block;
+ fill: currentColor;
+ flex: 0 0 auto;
+ height: 20px;
+ margin-right: $spacing-unit * 1/4;
+ width: 20px;
+ vertical-align: middle;
+ }
+}
diff --git a/client/source/sass/tools/_colors.scss b/client/source/sass/tools/_colors.scss
index 47c82749..659270ea 100644
--- a/client/source/sass/tools/_colors.scss
+++ b/client/source/sass/tools/_colors.scss
@@ -1,5 +1,6 @@
$blue: #258de5;
$green: #39ce83;
+$red: #e95779;
$white: #fff;
$background: #1a2f3d;
diff --git a/client/source/scripts/actions/TorrentActions.js b/client/source/scripts/actions/TorrentActions.js
index df50254e..2757d415 100644
--- a/client/source/scripts/actions/TorrentActions.js
+++ b/client/source/scripts/actions/TorrentActions.js
@@ -13,7 +13,8 @@ const TorrentActions = {
AppDispatcher.dispatchServerAction({
type: ActionTypes.CLIENT_ADD_TORRENT_SUCCESS,
data: {
- request: options,
+ count: options.urls.length,
+ destination: options.destination,
response
}
});
@@ -37,6 +38,8 @@ const TorrentActions = {
AppDispatcher.dispatchServerAction({
type: ActionTypes.CLIENT_ADD_TORRENT_SUCCESS,
data: {
+ count: filesData.getAll('torrents').length,
+ destination,
response
}
});
@@ -59,13 +62,19 @@ const TorrentActions = {
.then((data) => {
AppDispatcher.dispatchServerAction({
type: ActionTypes.CLIENT_REMOVE_TORRENT_SUCCESS,
- data
+ data: {
+ data,
+ count: hash.length
+ }
});
})
.catch((error) => {
AppDispatcher.dispatchServerAction({
type: ActionTypes.CLIENT_REMOVE_TORRENT_ERROR,
- error
+ error: {
+ error,
+ count: hash.length
+ }
});
});
},
@@ -169,7 +178,10 @@ const TorrentActions = {
.then((data) => {
AppDispatcher.dispatchServerAction({
type: ActionTypes.CLIENT_MOVE_TORRENTS_SUCCESS,
- data
+ data: {
+ data,
+ count: hashes.length
+ }
});
})
.catch((error) => {
diff --git a/client/source/scripts/components/icons/CircleCheckmarkIcon.js b/client/source/scripts/components/icons/CircleCheckmarkIcon.js
new file mode 100644
index 00000000..3e326c1f
--- /dev/null
+++ b/client/source/scripts/components/icons/CircleCheckmarkIcon.js
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import BaseIcon from './BaseIcon';
+
+export default class CircleCheckmarkIcon extends BaseIcon {
+ render() {
+ return (
+
+ );
+ }
+}
diff --git a/client/source/scripts/components/icons/CircleExclamationIcon.js b/client/source/scripts/components/icons/CircleExclamationIcon.js
new file mode 100644
index 00000000..741e06af
--- /dev/null
+++ b/client/source/scripts/components/icons/CircleExclamationIcon.js
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import BaseIcon from './BaseIcon';
+
+export default class CircleExclamationIcon extends BaseIcon {
+ render() {
+ return (
+
+ );
+ }
+}
diff --git a/client/source/scripts/components/modals/AddTorrentsByFile.js b/client/source/scripts/components/modals/AddTorrentsByFile.js
index 788c4908..d5f5e79a 100644
--- a/client/source/scripts/components/modals/AddTorrentsByFile.js
+++ b/client/source/scripts/components/modals/AddTorrentsByFile.js
@@ -8,7 +8,6 @@ import Close from '../icons/Close';
import File from '../icons/File';
import Files from '../icons/Files';
import ModalActions from './ModalActions';
-import SettingsStore from '../../stores/SettingsStore';
import TorrentActions from '../../actions/TorrentActions';
const METHODS_TO_BIND = [
@@ -75,7 +74,7 @@ export default class AddTorrentsByFile extends React.Component {
- {file.name}{file.name}
+ {file.name}
@@ -106,8 +105,6 @@ export default class AddTorrentsByFile extends React.Component {
return;
}
- SettingsStore.saveSettings({id: 'torrentDestination', data: this.state.destination});
-
this.setState({isAddingTorrents: true});
let fileData = new FormData();
diff --git a/client/source/scripts/components/modals/AddTorrentsByURL.js b/client/source/scripts/components/modals/AddTorrentsByURL.js
index cc17095e..b3673aaf 100644
--- a/client/source/scripts/components/modals/AddTorrentsByURL.js
+++ b/client/source/scripts/components/modals/AddTorrentsByURL.js
@@ -2,7 +2,6 @@ import React from 'react';
import AddTorrentsActions from './AddTorrentsActions';
import AddTorrentsDestination from './AddTorrentsDestination';
-import SettingsStore from '../../stores/SettingsStore';
import TextboxRepeater from '../forms/TextboxRepeater';
import TorrentActions from '../../actions/TorrentActions';
@@ -21,7 +20,7 @@ export default class AddTorrentsByURL extends React.Component {
this.state = {
addTorrentsError: null,
- destination: null,
+ destination: '',
isAddingTorrents: false,
urlTextboxes: [{value: ''}],
startTorrents: true
@@ -40,7 +39,6 @@ export default class AddTorrentsByURL extends React.Component {
destination: this.state.destination,
start: this.state.startTorrents
});
- SettingsStore.saveSettings({id: 'torrentDestination', data: this.state.destination});
}
handleDestinationChange(destination) {
diff --git a/client/source/scripts/components/modals/AddTorrentsDestination.js b/client/source/scripts/components/modals/AddTorrentsDestination.js
index cac8c91d..f3e94b14 100644
--- a/client/source/scripts/components/modals/AddTorrentsDestination.js
+++ b/client/source/scripts/components/modals/AddTorrentsDestination.js
@@ -11,7 +11,7 @@ export default class AddTorrentsDestination extends React.Component {
super();
this.state = {
- destination: null
+ destination: ''
};
METHODS_TO_BIND.forEach((method) => {
@@ -20,7 +20,7 @@ export default class AddTorrentsDestination extends React.Component {
}
componentWillMount() {
- let destination = SettingsStore.getSettings('torrentDestination');
+ let destination = SettingsStore.getSettings('torrentDestination') || '';
if (this.props.suggested) {
destination = this.props.suggested;
}
diff --git a/client/source/scripts/components/notifications/Notification.js b/client/source/scripts/components/notifications/Notification.js
new file mode 100644
index 00000000..9d7328fa
--- /dev/null
+++ b/client/source/scripts/components/notifications/Notification.js
@@ -0,0 +1,55 @@
+import classnames from 'classnames';
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import CircleCheckmarkIcon from '../icons/CircleCheckmarkIcon';
+import CircleExclamationIcon from '../icons/CircleExclamationIcon';
+import stringUtil from '../../../../../shared/util/stringUtil';
+
+export default class Notification extends React.Component {
+ render() {
+ let icon = ;
+ let countText = null;
+ let itemText = this.props.subject;
+ let notificationClasses = classnames('notification', {
+ 'is-success': this.props.type === 'success',
+ 'is-error': this.props.type === 'error'
+ });
+
+ if (this.props.type === 'error') {
+ icon = ;
+ }
+
+ if (this.props.count !== 1) {
+ countText = (
+
+ {this.props.count}
+
+ );
+
+ itemText = stringUtil.pluralize(itemText, this.props.count);
+ }
+
+ return (
+
+ {icon}
+
+ {this.props.adverb} {this.props.action} {countText} {itemText}.
+
+
+ );
+ }
+}
+
+Notification.defaultProps = {
+ count: 0,
+ type: 'success'
+};
+
+Notification.propTypes = {
+ count: React.PropTypes.number,
+ action: React.PropTypes.string.isRequired,
+ adverb: React.PropTypes.string.isRequired,
+ subject: React.PropTypes.string.isRequired,
+ subject: React.PropTypes.string
+};
diff --git a/client/source/scripts/components/notifications/Notifications.js b/client/source/scripts/components/notifications/Notifications.js
index 0532027d..b549120b 100644
--- a/client/source/scripts/components/notifications/Notifications.js
+++ b/client/source/scripts/components/notifications/Notifications.js
@@ -4,6 +4,7 @@ import React from 'react';
import ReactDOM from 'react-dom';
import EventTypes from '../../constants/EventTypes';
+import Notification from './Notification';
import NotificationStore from '../../stores/NotificationStore';
const METHODS_TO_BIND = ['handleNotificationChange'];
@@ -31,11 +32,7 @@ export default class NotificationList extends React.Component {
getNotifications() {
return this.state.notifications.map((notification, index) => {
- return (
-
- {notification.content}
-
- );
+ return ;
});
}
diff --git a/client/source/scripts/stores/NotificationStore.js b/client/source/scripts/stores/NotificationStore.js
index e8f69fa6..ad772c2d 100644
--- a/client/source/scripts/stores/NotificationStore.js
+++ b/client/source/scripts/stores/NotificationStore.js
@@ -27,31 +27,17 @@ class NotificationStoreClass extends BaseStore {
notification.duration = this.getDuration(notification);
notification.id = this.getID(notification);
- if (notification.content == null) {
- throw new Error('Notification content cannot be empty.');
- }
-
if (!!notification.accumulation) {
this.accumulate(notification);
}
this.scheduleCleanse(notification);
- this.notifications[notification.id] = {
- content: this.getContent(notification)
- };
+ this.notifications[notification.id] = notification;
this.emit(EventTypes.NOTIFICATIONS_CHANGE);
}
- getContent(notification) {
- if (!!notification.accumulation) {
- return notification.content(this.accumulation[notification.accumulation.id]);
- }
-
- return notification.content;
- }
-
getDuration(notification) {
return notification.duration || DEFAULT_DURATION;
}
@@ -60,7 +46,13 @@ class NotificationStoreClass extends BaseStore {
let notificationIDs = Object.keys(this.notifications).sort();
return notificationIDs.map((id) => {
- return this.notifications[id];
+ let notification = this.notifications[id];
+
+ if (!!notification.accumulation) {
+ notification.count = this.accumulation[notification.accumulation.id];
+ }
+
+ return notification;
});
}
diff --git a/client/source/scripts/stores/TorrentStore.js b/client/source/scripts/stores/TorrentStore.js
index 08e4de36..3e44a8a5 100644
--- a/client/source/scripts/stores/TorrentStore.js
+++ b/client/source/scripts/stores/TorrentStore.js
@@ -9,6 +9,7 @@ import {filterTorrents} from '../util/filterTorrents';
import NotificationStore from './NotificationStore';
import {searchTorrents} from '../util/searchTorrents';
import {selectTorrents} from '../util/selectTorrents';
+import SettingsStore from './SettingsStore';
import {sortTorrents} from '../util/sortTorrents';
import TorrentActions from '../actions/TorrentActions';
import TorrentFilterStore from './TorrentFilterStore';
@@ -100,22 +101,20 @@ class TorrentStoreClass extends BaseStore {
this.emit(EventTypes.CLIENT_ADD_TORRENT_ERROR);
}
- handleAddTorrentSuccess(responseData) {
+ handleAddTorrentSuccess(response) {
this.emit(EventTypes.CLIENT_ADD_TORRENT_SUCCESS);
- NotificationStore.add({
- content: function (count = 0) {
- if (count === 1) {
- return 'Successfully added torrent.';
- }
+ SettingsStore.saveSettings({id: 'torrentDestination', data: response.destination});
- return `Successfully added ${count} torrents.`;
- },
+ NotificationStore.add({
+ adverb: 'Successfully',
+ action: 'added',
+ subject: 'torrent',
accumulation: {
- id: 'add-torrents',
- value: responseData.request.urls.length || 1
+ id: 'add-torrents-success',
+ value: response.count || 1
},
- id: 'add-torrents'
+ id: 'add-torrents-success'
});
}
@@ -135,12 +134,34 @@ class TorrentStoreClass extends BaseStore {
return this.sortedTorrents;
}
- handleMoveTorrentsSuccess(data) {
+ handleMoveTorrentsSuccess(response) {
this.emit(EventTypes.CLIENT_MOVE_TORRENTS_SUCCESS);
+
+ NotificationStore.add({
+ adverb: 'Successfully',
+ action: 'moved',
+ accumulation: {
+ id: 'move-torrents-success',
+ value: response.count
+ },
+ id: 'move-torrents-success',
+ subject: 'torrent'
+ });
}
handleMoveTorrentsError(error) {
this.emit(EventTypes.CLIENT_MOVE_TORRENTS_REQUEST_ERROR);
+
+ NotificationStore.add({
+ adverb: 'Failed to',
+ action: 'move',
+ subject: 'torrent',
+ accumulation: {
+ id: 'move-torrents-error',
+ value: error.count
+ },
+ id: 'move-torrents-error'
+ });
}
setSelectedTorrents(event, hash) {
@@ -153,11 +174,23 @@ class TorrentStoreClass extends BaseStore {
this.emit(EventTypes.UI_TORRENT_SELECTION_CHANGE);
}
- handleFetchTorrentsError(action) {
- console.log(action);
+ handleFetchTorrentsError(error) {
+ console.log(error);
}
handleFetchTorrentsSuccess(torrents) {
+ NotificationStore.add({
+ adverb: 'Successfully',
+ action: 'fetched',
+ duration: 20000,
+ subject: 'torrent',
+ accumulation: {
+ id: 'remove-torrents-error',
+ value: 1
+ },
+ id: 'remove-torrents-error'
+ });
+
this.sortTorrents(torrents);
this.filterTorrents();
@@ -165,6 +198,32 @@ class TorrentStoreClass extends BaseStore {
this.resolveRequest('fetch-torrents');
}
+ handleRemoveTorrentsSuccess(response) {
+ NotificationStore.add({
+ adverb: 'Successfully',
+ action: 'removed',
+ subject: 'torrent',
+ accumulation: {
+ id: 'remove-torrents-error',
+ value: response.count
+ },
+ id: 'remove-torrents-error'
+ });
+ }
+
+ handleRemoveTorrentsError(error) {
+ NotificationStore.add({
+ adverb: 'Failed to',
+ action: 'remove',
+ subject: 'torrent',
+ accumulation: {
+ id: 'remove-torrents-error',
+ value: error.count
+ },
+ id: 'remove-torrents-error'
+ });
+ }
+
setTorrentDetails(hash, torrentDetails) {
this.torrents[hash].details = torrentDetails;
this.resolveRequest('fetch-torrent-details');
@@ -231,14 +290,20 @@ TorrentStore.dispatcherID = AppDispatcher.register((payload) => {
case ActionTypes.CLIENT_FETCH_TORRENTS_SUCCESS:
TorrentStore.handleFetchTorrentsSuccess(action.data.torrents);
break;
+ case ActionTypes.CLIENT_FETCH_TORRENTS_ERROR:
+ TorrentStore.handleFetchTorrentsError(action.error);
+ break;
case ActionTypes.CLIENT_MOVE_TORRENTS_SUCCESS:
TorrentStore.handleMoveTorrentsSuccess(action.data);
break;
case ActionTypes.CLIENT_MOVE_TORRENTS_ERROR:
TorrentStore.handleMoveTorrentsError(action.error);
break;
- case ActionTypes.CLIENT_FETCH_TORRENTS_ERROR:
- TorrentStore.handleFetchTorrentsError();
+ case ActionTypes.CLIENT_REMOVE_TORRENT_SUCCESS:
+ TorrentStore.handleRemoveTorrentsSuccess(action.data);
+ break;
+ case ActionTypes.CLIENT_REMOVE_TORRENT_ERROR:
+ TorrentStore.handleRemoveTorrentsError(action.error);
break;
case ActionTypes.UI_CLICK_TORRENT:
TorrentStore.setSelectedTorrents(action.data.event, action.data.hash);
diff --git a/package.json b/package.json
index e0c0883c..18f37077 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"events": "^1.1.0",
"express": "~4.12.2",
"flux": "^2.1.1",
+ "gulp-sass": "^2.3.1",
"inuit-box-sizing": "^0.2.0",
"inuit-defaults": "^0.2.3",
"inuit-functions": "^0.2.0",
@@ -46,6 +47,7 @@
"react-dropzone": "^3.4.0",
"sax": "^0.6.1",
"serve-favicon": "~2.2.0",
+ "webpack": "^1.13.1",
"xmlbuilder": "^2.6.2",
"xmlrpc": "^1.3.0"
},