diff --git a/.eslintrc b/.eslintrc index 92fad475c3..0e95beca2c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -227,7 +227,7 @@ "react/jsx-boolean-value": [1, "always"], "jsx-quotes": [1, "prefer-single"], "react/jsx-no-undef": 1, - "react/jsx-sort-props": 1, + "react/jsx-sort-props": [1, { "ignoreCase": true }], "react/jsx-uses-react": 1, "react/jsx-uses-vars": 1, "react/no-did-mount-set-state": 2, diff --git a/client/err-saga.js b/client/err-saga.js new file mode 100644 index 0000000000..d681e4cfcc --- /dev/null +++ b/client/err-saga.js @@ -0,0 +1,9 @@ +export default function toastSaga(err$, toast) { + err$ + .doOnNext(() => toast({ + type: 'error', + title: 'Oops, something went wrong', + message: `Something went wrong, please try again later` + })) + .subscribe(err => console.error(err)); +} diff --git a/client/synchronise-history.js b/client/history-saga.js similarity index 96% rename from client/synchronise-history.js rename to client/history-saga.js index 3e36cf9747..25e65340b9 100644 --- a/client/synchronise-history.js +++ b/client/history-saga.js @@ -20,7 +20,7 @@ const emptyLocation = { let prevKey; let isSyncing = false; -export default function synchroniseHistory( +export default function historySaga( history, updateLocation, goTo, diff --git a/client/index.js b/client/index.js index c3796092cb..ceaeb012ed 100644 --- a/client/index.js +++ b/client/index.js @@ -9,7 +9,8 @@ import { hydrate } from 'thundercats'; import { render$ } from 'thundercats-react'; import { app$ } from '../common/app'; -import synchroniseHistory from './synchronise-history'; +import historySaga from './history-saga'; +import errSaga from './err-saga'; const debug = debugFactory('fcc:client'); const DOMContianer = document.getElementById('fcc'); @@ -38,9 +39,16 @@ app$({ history, location: appLocation }) ({ nextLocation, props }, appCat) => ({ nextLocation, props, appCat }) ) .doOnNext(({ appCat }) => { - const { updateLocation, goTo, goBack } = appCat.getActions('appActions'); const appStore$ = appCat.getStore('appStore'); + const { + toast, + updateLocation, + goTo, + goBack + } = appCat.getActions('appActions'); + + const routerState$ = appStore$ .map(({ location }) => location) .distinctUntilChanged( @@ -50,22 +58,24 @@ app$({ history, location: appLocation }) // set page title appStore$ .pluck('title') + .distinctUntilChanged() .doOnNext(title => document.title = title) .subscribe(() => {}); - appStore$ - .pluck('err') - .filter(err => !!err) - .distinctUntilChanged() - .subscribe(err => console.error(err)); - - synchroniseHistory( + historySaga( history, updateLocation, goTo, goBack, routerState$ ); + + const err$ = appStore$ + .pluck('err') + .filter(err => !!err) + .distinctUntilChanged(); + + errSaga(err$, toast); }) // allow store subscribe to subscribe to actions .delay(10) diff --git a/client/less/main.less b/client/less/main.less index 69679ccbe2..053e5ddd4c 100644 --- a/client/less/main.less +++ b/client/less/main.less @@ -1138,3 +1138,4 @@ code { @import "chat.less"; @import "jobs.less"; @import "challenge.less"; +@import "toastr.less"; diff --git a/client/less/toastr.less b/client/less/toastr.less new file mode 100644 index 0000000000..f4774004a2 --- /dev/null +++ b/client/less/toastr.less @@ -0,0 +1,269 @@ +// sourced from https://github.com/CodeSeven/toastr +// MIT license +// Mix-ins +.borderRadius(@radius) { + -moz-border-radius: @radius; + -webkit-border-radius: @radius; + border-radius: @radius; +} + +.boxShadow(@boxShadow) { + -moz-box-shadow: @boxShadow; + -webkit-box-shadow: @boxShadow; + box-shadow: @boxShadow; +} + +.opacity(@opacity) { + @opacityPercent: @opacity * 100; + opacity: @opacity; + -ms-filter: ~"progid:DXImageTransform.Microsoft.Alpha(Opacity=@{opacityPercent})"; + filter: ~"alpha(opacity=@{opacityPercent})"; +} + +.wordWrap(@wordWrap: break-word) { + -ms-word-wrap: @wordWrap; + word-wrap: @wordWrap; +} + +// Variables +@black: #000000; +@grey: #999999; +@light-grey: #CCCCCC; +@white: #FFFFFF; +@near-black: #030303; +@green: #51A351; +@red: #BD362F; +@blue: #2F96B4; +@orange: #F89406; +@default-container-opacity: .8; + +// Styles +.toast-title { + font-weight: bold; +} + +.toast-message { + .wordWrap(); + + a, + label { + color: @white; + } + + a:hover { + color: @light-grey; + text-decoration: none; + } +} + +.toast-close-button { + position: relative; + right: -0.3em; + top: -0.3em; + float: right; + font-size: 20px; + font-weight: bold; + color: @white; + -webkit-text-shadow: 0 1px 0 rgba(255,255,255,1); + text-shadow: 0 1px 0 rgba(255,255,255,1); + .opacity(0.8); + + &:hover, + &:focus { + color: @black; + text-decoration: none; + cursor: pointer; + .opacity(0.4); + } +} + +/*Additional properties for button version + iOS requires the button element instead of an anchor tag. + If you want the anchor version, it requires `href="#"`.*/ +button.toast-close-button { + padding: 0; + cursor: pointer; + background: transparent; + border: 0; + -webkit-appearance: none; +} + +//#endregion + +.toast-top-center { + top: 0; + right: 0; + width: 100%; +} + +.toast-bottom-center { + bottom: 0; + right: 0; + width: 100%; +} + +.toast-top-full-width { + top: 0; + right: 0; + width: 100%; +} + +.toast-bottom-full-width { + bottom: 0; + right: 0; + width: 100%; +} + +.toast-top-left { + top: 12px; + left: 12px; +} + +.toast-top-right { + top: 12px; + right: 12px; +} + +.toast-bottom-right { + right: 12px; + bottom: 12px; +} + +.toast-bottom-left { + bottom: 12px; + left: 12px; +} + +#toast-container { + position: fixed; + z-index: 999999; + // The container should not be clickable. + pointer-events: none; + * { + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + box-sizing: border-box; + } + + > div { + position: relative; + // The toast itself should be clickable. + pointer-events: auto; + overflow: hidden; + margin: 0 0 6px; + padding: 15px 15px 15px 50px; + width: 300px; + .borderRadius(3px 3px 3px 3px); + background-position: 15px center; + background-repeat: no-repeat; + .boxShadow(0 0 12px @grey); + color: @white; + .opacity(@default-container-opacity); + } + + > :hover { + .boxShadow(0 0 12px @black); + .opacity(1); + cursor: pointer; + } + + > .toast-info { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGwSURBVEhLtZa9SgNBEMc9sUxxRcoUKSzSWIhXpFMhhYWFhaBg4yPYiWCXZxBLERsLRS3EQkEfwCKdjWJAwSKCgoKCcudv4O5YLrt7EzgXhiU3/4+b2ckmwVjJSpKkQ6wAi4gwhT+z3wRBcEz0yjSseUTrcRyfsHsXmD0AmbHOC9Ii8VImnuXBPglHpQ5wwSVM7sNnTG7Za4JwDdCjxyAiH3nyA2mtaTJufiDZ5dCaqlItILh1NHatfN5skvjx9Z38m69CgzuXmZgVrPIGE763Jx9qKsRozWYw6xOHdER+nn2KkO+Bb+UV5CBN6WC6QtBgbRVozrahAbmm6HtUsgtPC19tFdxXZYBOfkbmFJ1VaHA1VAHjd0pp70oTZzvR+EVrx2Ygfdsq6eu55BHYR8hlcki+n+kERUFG8BrA0BwjeAv2M8WLQBtcy+SD6fNsmnB3AlBLrgTtVW1c2QN4bVWLATaIS60J2Du5y1TiJgjSBvFVZgTmwCU+dAZFoPxGEEs8nyHC9Bwe2GvEJv2WXZb0vjdyFT4Cxk3e/kIqlOGoVLwwPevpYHT+00T+hWwXDf4AJAOUqWcDhbwAAAAASUVORK5CYII=") !important; + } + + > .toast-error { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHOSURBVEhLrZa/SgNBEMZzh0WKCClSCKaIYOED+AAKeQQLG8HWztLCImBrYadgIdY+gIKNYkBFSwu7CAoqCgkkoGBI/E28PdbLZmeDLgzZzcx83/zZ2SSXC1j9fr+I1Hq93g2yxH4iwM1vkoBWAdxCmpzTxfkN2RcyZNaHFIkSo10+8kgxkXIURV5HGxTmFuc75B2RfQkpxHG8aAgaAFa0tAHqYFfQ7Iwe2yhODk8+J4C7yAoRTWI3w/4klGRgR4lO7Rpn9+gvMyWp+uxFh8+H+ARlgN1nJuJuQAYvNkEnwGFck18Er4q3egEc/oO+mhLdKgRyhdNFiacC0rlOCbhNVz4H9FnAYgDBvU3QIioZlJFLJtsoHYRDfiZoUyIxqCtRpVlANq0EU4dApjrtgezPFad5S19Wgjkc0hNVnuF4HjVA6C7QrSIbylB+oZe3aHgBsqlNqKYH48jXyJKMuAbiyVJ8KzaB3eRc0pg9VwQ4niFryI68qiOi3AbjwdsfnAtk0bCjTLJKr6mrD9g8iq/S/B81hguOMlQTnVyG40wAcjnmgsCNESDrjme7wfftP4P7SP4N3CJZdvzoNyGq2c/HWOXJGsvVg+RA/k2MC/wN6I2YA2Pt8GkAAAAASUVORK5CYII=") !important; + } + + > .toast-success { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADsSURBVEhLY2AYBfQMgf///3P8+/evAIgvA/FsIF+BavYDDWMBGroaSMMBiE8VC7AZDrIFaMFnii3AZTjUgsUUWUDA8OdAH6iQbQEhw4HyGsPEcKBXBIC4ARhex4G4BsjmweU1soIFaGg/WtoFZRIZdEvIMhxkCCjXIVsATV6gFGACs4Rsw0EGgIIH3QJYJgHSARQZDrWAB+jawzgs+Q2UO49D7jnRSRGoEFRILcdmEMWGI0cm0JJ2QpYA1RDvcmzJEWhABhD/pqrL0S0CWuABKgnRki9lLseS7g2AlqwHWQSKH4oKLrILpRGhEQCw2LiRUIa4lwAAAABJRU5ErkJggg==") !important; + } + + > .toast-warning { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGYSURBVEhL5ZSvTsNQFMbXZGICMYGYmJhAQIJAICYQPAACiSDB8AiICQQJT4CqQEwgJvYASAQCiZiYmJhAIBATCARJy+9rTsldd8sKu1M0+dLb057v6/lbq/2rK0mS/TRNj9cWNAKPYIJII7gIxCcQ51cvqID+GIEX8ASG4B1bK5gIZFeQfoJdEXOfgX4QAQg7kH2A65yQ87lyxb27sggkAzAuFhbbg1K2kgCkB1bVwyIR9m2L7PRPIhDUIXgGtyKw575yz3lTNs6X4JXnjV+LKM/m3MydnTbtOKIjtz6VhCBq4vSm3ncdrD2lk0VgUXSVKjVDJXJzijW1RQdsU7F77He8u68koNZTz8Oz5yGa6J3H3lZ0xYgXBK2QymlWWA+RWnYhskLBv2vmE+hBMCtbA7KX5drWyRT/2JsqZ2IvfB9Y4bWDNMFbJRFmC9E74SoS0CqulwjkC0+5bpcV1CZ8NMej4pjy0U+doDQsGyo1hzVJttIjhQ7GnBtRFN1UarUlH8F3xict+HY07rEzoUGPlWcjRFRr4/gChZgc3ZL2d8oAAAAASUVORK5CYII=") !important; + } + + /*overrides*/ + &.toast-top-center > div, + &.toast-bottom-center > div { + width: 300px; + margin-left: auto; + margin-right: auto; + } + + &.toast-top-full-width > div, + &.toast-bottom-full-width > div { + width: 96%; + margin-left: auto; + margin-right: auto; + } +} + +.toast { + background-color: @near-black; +} + +.toast-success { + background-color: @green; +} + +.toast-error { + background-color: @red; +} + +.toast-info { + background-color: @blue; +} + +.toast-warning { + background-color: @orange; +} + +.toast-progress { + position: absolute; + left: 0; + bottom: 0; + height: 4px; + background-color: @black; + .opacity(0.4); +} + +/*Responsive Design*/ + +@media all and (max-width: 240px) { + #toast-container { + + > div { + padding: 8px 8px 8px 50px; + width: 11em; + } + + & .toast-close-button { + right: -0.2em; + top: -0.2em; + } + } +} + +@media all and (min-width: 241px) and (max-width: 480px) { + #toast-container { + > div { + padding: 8px 8px 8px 50px; + width: 18em; + } + + & .toast-close-button { + right: -0.2em; + top: -0.2em; + } + } +} + +@media all and (min-width: 481px) and (max-width: 768px) { + #toast-container { + > div { + padding: 15px 15px 15px 50px; + width: 25em; + } + } +} diff --git a/common/app/App.jsx b/common/app/App.jsx index 847542cce4..69e7d8b082 100644 --- a/common/app/App.jsx +++ b/common/app/App.jsx @@ -1,16 +1,33 @@ import React, { PropTypes } from 'react'; -import { contain } from 'thundercats-react'; import { Row } from 'react-bootstrap'; +import { ToastMessage, ToastContainer } from 'react-toastr'; +import { contain } from 'thundercats-react'; import { Nav } from './components/Nav'; +const toastMessageFactory = React.createFactory(ToastMessage.animation); + export default contain( { + actions: ['appActions'], store: 'appStore', fetchAction: 'appActions.getUser', isPrimed({ username }) { return !!username; }, + map({ + username, + points, + picture, + toast + }) { + return { + username, + points, + picture, + toast + }; + }, getPayload(props) { return { isPrimed: !!props.username @@ -21,11 +38,31 @@ export default contain( displayName: 'FreeCodeCamp', propTypes: { + appActions: PropTypes.object, children: PropTypes.node, + username: PropTypes.string, points: PropTypes.number, picture: PropTypes.string, - title: PropTypes.string, - username: PropTypes.string + toast: PropTypes.object + }, + + componentWillReceiveProps({ toast: nextToast }) { + const { toast = {} } = this.props; + if ( + toast && + nextToast && + toast.id !== nextToast.id + ) { + + this.refs.toaster[nextToast.type || 'success']( + nextToast.message, + nextToast.title, + { + closeButton: true, + timeOut: 10000 + } + ); + } }, render() { @@ -38,6 +75,10 @@ export default contain( { this.props.children } + ); } diff --git a/common/app/flux/Actions.js b/common/app/flux/Actions.js index 3594be6c8e..3a0915be93 100644 --- a/common/app/flux/Actions.js +++ b/common/app/flux/Actions.js @@ -33,8 +33,24 @@ export default Actions({ }, // routing + // goTo(path: String) => path goTo: null, + + // goBack(arg?) => arg? goBack: null, + + // toast(args: { type?: String, message: String, title: String }) => args + toast(args) { + return { + transform(state) { + const id = state.toast && state.toast.id ? state.toast.id : 0; + const toast = { ...args, id: id + 1 }; + return { ...state, toast }; + } + }; + }, + + // updateLocation(location: { pathname: String }) => location updateLocation(location) { return { transform(state) { diff --git a/common/app/flux/Store.js b/common/app/flux/Store.js index 36116ad7b0..2914bc268c 100644 --- a/common/app/flux/Store.js +++ b/common/app/flux/Store.js @@ -28,7 +28,8 @@ export default Store({ const { updateLocation, getUser, - setTitle + setTitle, + toast } = cat.getActions('appActions'); register( @@ -39,7 +40,8 @@ export default Store({ setTitle ) ), - updateLocation + updateLocation, + toast ) ); diff --git a/common/app/routes/Hikes/flux/Actions.js b/common/app/routes/Hikes/flux/Actions.js index 5bf63fe56d..580ff0e074 100644 --- a/common/app/routes/Hikes/flux/Actions.js +++ b/common/app/routes/Hikes/flux/Actions.js @@ -242,7 +242,13 @@ export default Actions({ return { ...state, points: username ? state.points + 1 : state.points, - hikesApp + hikesApp, + toast: { + title: 'Congratulations!', + message: 'Hike completed', + id: state.toast && state.toast.id ? state.toast.id + 1 : 0, + type: 'success' + } }; }, optimistic: optimisticSave diff --git a/package.json b/package.json index f4de269e23..89925a45c3 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "express-session": "^1.12.1", "express-state": "^1.2.0", "express-validator": "^2.18.0", + "fbjs": "^0.6.0", "fetchr": "~0.5.12", "forever": "~0.15.1", "frameguard": "~0.2.2", @@ -109,6 +110,7 @@ "react-motion": "~0.3.1", "react-router": "^1.0.0", "react-router-bootstrap": "https://github.com/FreeCodeCamp/react-router-bootstrap.git#freecodecamp", + "react-toastr": "^2.3.0", "react-vimeo": "~0.0.3", "request": "^2.65.0", "rev-del": "^1.0.5",