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