├── .babelrc
├── src
├── main.js
├── alt
│ └── index.js
├── main.scss
├── components
│ ├── Message.jsx
│ ├── Channel.jsx
│ ├── Chat.jsx
│ ├── App.jsx
│ ├── Login.jsx
│ ├── MessageBox.jsx
│ ├── MessageList.jsx
│ └── ChannelList.jsx
├── routes
│ └── index.js
├── sources
│ ├── ChannelSource.js
│ └── MessageSource.js
├── actions
│ └── index.js
└── stores
│ └── ChatStore.js
├── index.html
├── server.js
├── README.md
├── package.json
├── webpack.config.js
└── data
└── messages.json
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "stage": 0
3 | }
4 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | require('./main.scss');
2 | require('./routes');
3 |
--------------------------------------------------------------------------------
/src/alt/index.js:
--------------------------------------------------------------------------------
1 | import Alt from 'alt';
2 | export default new Alt();
3 |
--------------------------------------------------------------------------------
/src/main.scss:
--------------------------------------------------------------------------------
1 | $color: blue;
2 |
3 | body {
4 | //color: $color;
5 | margin: 0;
6 | background-color: #F9F9F9;
7 | }
8 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | var webpack = require('webpack');
2 | var WebPackDevServer = require('webpack-dev-server');
3 | var config = require('./webpack.config');
4 |
5 | new WebPackDevServer(webpack(config), {
6 | publicPath: config.output.publicPath,
7 | hot: true,
8 | historyApiFallback: true
9 | }).listen(8080, 'localhost', function(err, result){
10 | if(err){
11 | return console.log(err);
12 | }
13 |
14 | console.log('Listening on localhost:8080')
15 | });
16 |
--------------------------------------------------------------------------------
/src/components/Message.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import mui from 'material-ui';
3 |
4 | var {ListItem, Avatar} = mui;
5 |
6 | class Message extends React.Component {
7 | constructor(props){
8 | super(props);
9 | }
10 |
11 | render(){
12 | return (
13 | }
15 | >{this.props.message.message}
16 | );
17 | }
18 | }
19 |
20 | export default Message;
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-stack
2 | Code used for a Pluralsight.com course about a real-time React stack with Flux, Webpack, and Firebase
3 |
4 | Go check out the course at https://app.pluralsight.com/library/courses/build-isomorphic-app-react-flux-webpack-firebase!
5 |
6 | # Get up and running
7 |
8 | * From the command line, clone this repo: `git clone https://github.com/hendrikswan/react-stack && cd react-stack`
9 | * Run `npm install` to install all the modules (pegged to specific NPM modules to ensure that it works for you)
10 | * Run `node server` to run the webpack dev server
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/routes/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import App from '../components/App.jsx';
3 | import Chat from '../components/Chat.jsx';
4 | import Login from '../components/Login.jsx';
5 | import Router from 'react-router';
6 | let Route = Router.Route;
7 | let DefaultRoute = Router.DefaultRoute;
8 |
9 | let routes = (
10 |
11 |
12 |
13 |
14 |
15 |
16 | );
17 |
18 | Router.run(routes, Router.HashLocation, (Root)=> {
19 | React.render(, document.getElementById('container'));
20 | });
21 |
--------------------------------------------------------------------------------
/src/components/Channel.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import mui from 'material-ui';
3 | import Actions from '../actions';
4 |
5 | var {ListItem} = mui;
6 |
7 | class Channel extends React.Component {
8 | constructor(props){
9 | super(props);
10 | }
11 |
12 | onClick(){
13 | Actions.channelOpened(this.props.channel);
14 | }
15 |
16 | render(){
17 | let style = {};
18 |
19 | if(this.props.channel.selected){
20 | style.backgroundColor = '#f0f0f0';
21 | }
22 |
23 | return (
24 | {this.props.channel.name}
29 | );
30 | }
31 | }
32 |
33 | export default Channel;
34 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-stack",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "author": "",
10 | "license": "ISC",
11 | "dependencies": {
12 | "alt": "0.17.1",
13 | "babel-core": "5.8.14",
14 | "babel-loader": "5.3.2",
15 | "css-loader": "0.15.6",
16 | "firebase": "2.2.9",
17 | "lodash": "3.10.1",
18 | "material-ui": "0.10.2",
19 | "node-sass": "3.4.1",
20 | "react": "0.13.3",
21 | "react-hot-loader": "1.2.8",
22 | "react-router": "0.13.3",
23 | "react-tap-event-plugin": "0.1.7",
24 | "sass-loader": "1.0.3",
25 | "style-loader": "0.12.3",
26 | "trim": "0.0.1",
27 | "webpack": "1.10.5",
28 | "webpack-dev-server": "1.10.1"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/sources/ChannelSource.js:
--------------------------------------------------------------------------------
1 | import Actions from '../actions';
2 | import Firebase from 'firebase';
3 |
4 | let firebaseRef = new Firebase('https://react-stack.firebaseio.com/channels');
5 |
6 | let ChannelSource = {
7 | getChannels: {
8 | remote(state, selectedChannelKey){
9 | return new Promise((resolve, reject) => {
10 | firebaseRef.once("value", (dataSnapshot)=> {
11 | var channels = dataSnapshot.val();
12 | selectedChannelKey = selectedChannelKey || _.keys(channels)[0];
13 | var selectedChannel = channels[selectedChannelKey];
14 | if(selectedChannel){
15 | selectedChannel.selected = true;
16 | }
17 | resolve(channels);
18 | });
19 | });
20 | },
21 | success: Actions.channelsReceived,
22 | error: Actions.channelsFailed
23 | }
24 | }
25 |
26 | export default ChannelSource;
27 |
--------------------------------------------------------------------------------
/src/actions/index.js:
--------------------------------------------------------------------------------
1 | import alt from '../alt';
2 | import Firebase from 'firebase';
3 |
4 | class Actions {
5 | constructor(){
6 | this.generateActions(
7 | 'channelsReceived',
8 | 'channelsFailed',
9 | 'messagesReceived',
10 | 'messagesFailed',
11 | 'channelOpened',
12 | 'messagesLoading',
13 | 'sendMessage',
14 | 'messageSendSuccess',
15 | 'messageSendError',
16 | 'messageReceived'
17 | );
18 | }
19 |
20 | login(router){
21 | return (dispatch) => {
22 | var firebaseRef = new Firebase('https://react-stack.firebaseio.com');
23 | firebaseRef.authWithOAuthPopup("google", (error, user)=> {
24 | if(error){
25 | return;
26 | }
27 |
28 | dispatch(user);
29 |
30 | router.transitionTo('/chat');
31 | });
32 | }
33 | }
34 | }
35 |
36 | export default alt.createActions(Actions);
37 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require('path');
2 | var webpack = require('webpack');
3 |
4 | module.exports = {
5 | devtool: 'source-map',
6 | entry: {
7 | main: [
8 | 'webpack-dev-server/client?http://localhost:8080',
9 | 'webpack/hot/only-dev-server',
10 | './src/main.js'
11 | ]
12 | },
13 | output: {
14 | filename: '[name].js',
15 | path: path.join(__dirname, 'public'),
16 | publicPath: '/public/'
17 | },
18 | plugins: [
19 | new webpack.HotModuleReplacementPlugin(),
20 | new webpack.NoErrorsPlugin()
21 | ],
22 | module: {
23 | loaders: [
24 | {
25 | test: /\.jsx?$/,
26 | include: path.join(__dirname, 'src'),
27 | loader: 'react-hot!babel'
28 | },
29 | {
30 | test: /\.scss$/,
31 | include: path.join(__dirname, 'src'),
32 | loader: 'style!css!sass'
33 | }
34 | ]
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/Chat.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MessageList from './MessageList.jsx';
3 | import ChannelList from './ChannelList.jsx';
4 | import MessageBox from './MessageBox.jsx';
5 | import ChatStore from '../stores/ChatStore';
6 |
7 | class Chat extends React.Component {
8 | render(){
9 | return (
10 |
11 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
26 | static willTransitionTo(transition){
27 | var state = ChatStore.getState();
28 | if(!state.user){
29 | transition.redirect('/login');
30 | }
31 | }
32 | }
33 |
34 | export default Chat;
35 |
--------------------------------------------------------------------------------
/src/components/App.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import mui from 'material-ui';
3 | import {RouteHandler} from 'react-router';
4 |
5 | var ThemeManager = new mui.Styles.ThemeManager();
6 | var Colors = mui.Styles.Colors;
7 | var AppBar = mui.AppBar;
8 |
9 | class App extends React.Component {
10 | constructor(){
11 | super();
12 |
13 | ThemeManager.setPalette({
14 | primary1Color: Colors.blue500,
15 | primary2Color: Colors.blue700,
16 | primary3Color: Colors.blue100,
17 | accent1Color: Colors.pink400
18 | });
19 | }
20 |
21 |
22 | static childContextTypes = {
23 | muiTheme: React.PropTypes.object
24 | }
25 |
26 | getChildContext(){
27 | return {
28 | muiTheme: ThemeManager.getCurrentTheme()
29 | };
30 | }
31 |
32 | render(){
33 |
34 | return (
35 |
39 | );
40 | }
41 | }
42 |
43 | export default App;
44 |
--------------------------------------------------------------------------------
/src/components/Login.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import mui from 'material-ui';
3 | import Actions from '../actions';
4 |
5 | var {
6 | Card,
7 | CardText,
8 | RaisedButton
9 | } = mui;
10 |
11 |
12 | class Login extends React.Component {
13 |
14 | onClick(){
15 | Actions.login(this.context.router);
16 | }
17 |
18 | static contextTypes = {
19 | router: React.PropTypes.func.isRequired
20 | }
21 |
22 | render(){
23 |
24 | return (
25 |
30 |
33 | To start chatting away, please log in with your Google account.
34 |
35 |
36 |
40 |
41 |
42 | );
43 | }
44 | }
45 |
46 |
47 | module.exports = Login;
48 |
--------------------------------------------------------------------------------
/src/components/MessageBox.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import mui from 'material-ui';
3 | import trim from 'trim';
4 | import Actions from '../actions';
5 |
6 | var {Card} = mui;
7 |
8 | class MessageBox extends React.Component {
9 | constructor(props){
10 | super(props);
11 | this.state = {
12 | message: ''
13 | }
14 | }
15 |
16 | onChange(evt){
17 | this.setState({
18 | message: evt.target.value
19 | });
20 | }
21 |
22 | onKeyUp(evt){
23 | if(evt.keyCode === 13 && trim(evt.target.value) != ''){
24 | evt.preventDefault();
25 |
26 | Actions.sendMessage(this.state.message);
27 |
28 | this.setState({
29 | message: ''
30 | });
31 |
32 | }
33 | }
34 |
35 | render(){
36 | return (
37 |
42 |
56 |
57 | );
58 | }
59 | }
60 |
61 | export default MessageBox;
62 |
--------------------------------------------------------------------------------
/src/components/MessageList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Message from './Message.jsx';
3 | import mui from 'material-ui';
4 | import Firebase from 'firebase';
5 | import _ from 'lodash';
6 | import connectToStores from 'alt/utils/connectToStores';
7 | import ChatStore from '../stores/ChatStore';
8 |
9 | var {Card, List, CircularProgress} = mui;
10 |
11 | @connectToStores
12 | class MessageList extends React.Component {
13 | constructor(props){
14 | super(props);
15 | }
16 |
17 | static getStores(){
18 | return [ChatStore];
19 | }
20 |
21 | static getPropsFromStores(){
22 | return ChatStore.getState();
23 | }
24 |
25 | render(){
26 | let messageNodes = null;
27 |
28 | if(!this.props.messagesLoading){
29 | messageNodes = _.values(this.props.messages).map((message, i)=> {
30 | return (
31 |
32 | );
33 | });
34 | }else{
35 | messageNodes = ;
43 | }
44 |
45 |
46 | return (
47 |
51 |
52 | {messageNodes}
53 |
54 |
55 | );
56 | }
57 | }
58 |
59 | export default MessageList;
60 |
--------------------------------------------------------------------------------
/src/sources/MessageSource.js:
--------------------------------------------------------------------------------
1 | import Actions from '../actions';
2 | import Firebase from 'firebase';
3 |
4 | let firebaseRef = null;
5 |
6 | let MessageSource = {
7 | getMessages: {
8 | remote(state){
9 |
10 | if(firebaseRef){
11 | firebaseRef.off();
12 | }
13 |
14 | firebaseRef = new Firebase('https://react-stack.firebaseio.com/messages/' +
15 | state.selectedChannel.key);
16 |
17 | return new Promise((resolve, reject) => {
18 | firebaseRef.once("value", (dataSnapshot) => {
19 | var messages = dataSnapshot.val();
20 | resolve(messages);
21 |
22 |
23 | setTimeout(()=> {
24 | firebaseRef.on("child_added", ((msg) => {
25 | let msgVal = msg.val();
26 | msgVal.key = msg.key();
27 | Actions.messageReceived(msgVal);
28 | }));
29 | }, 10);
30 |
31 | })
32 | });
33 | },
34 | success: Actions.messagesReceived,
35 | error: Actions.messagesFailed,
36 | loading: Actions.messagesLoading
37 | },
38 | sendMessage: {
39 | remote(state){
40 | return new Promise((resolve, reject)=> {
41 | if(!firebaseRef){
42 | return resolve();
43 | }
44 |
45 | firebaseRef.push({
46 | "message": state.message,
47 | "date": new Date().toUTCString(),
48 | "author": state.user.google.displayName,
49 | "userId": state.user.uid,
50 | "profilePic": state.user.google.profileImageURL
51 | });
52 | resolve();
53 | });
54 | },
55 | success: Actions.messageSendSuccess,
56 | error: Actions.messageSendError
57 | },
58 | }
59 |
60 | export default MessageSource;
61 |
--------------------------------------------------------------------------------
/src/components/ChannelList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Channel from './Channel.jsx';
3 | import mui from 'material-ui';
4 | import connectToStores from 'alt/utils/connectToStores';
5 | import ChatStore from '../stores/ChatStore';
6 |
7 | var {Card, List, CircularProgress} = mui;
8 |
9 | @connectToStores
10 | class ChannelList extends React.Component {
11 | constructor(props){
12 | super(props);
13 | this.state = {channels: null};
14 | }
15 |
16 | componentDidMount(){
17 | this.state.selectedChannel = this.props.params.channel;
18 | ChatStore.getChannels(this.state.selectedChannel);
19 | }
20 |
21 | componentWillReceiveProps(nextProps){
22 | if(this.state.selectedChannel != nextProps.params.channel){
23 | this.state.selectedChannel = nextProps.params.channel;
24 | ChatStore.getChannels(this.state.selectedChannel);
25 | }
26 | }
27 |
28 | static getStores(){
29 | return [ChatStore];
30 | }
31 |
32 | static getPropsFromStores(){
33 | return ChatStore.getState();
34 | }
35 |
36 | render(){
37 | if(!this.props.channels){
38 | return (
39 |
42 |
52 |
53 | );
54 | }
55 |
56 |
57 | var channelNodes = _(this.props.channels)
58 | .keys()
59 | .map((k, i)=> {
60 | let channel = this.props.channels[k];
61 | return (
62 |
63 | );
64 | })
65 | .value();
66 |
67 | return (
68 |
71 |
72 | {channelNodes}
73 |
74 |
75 | );
76 | }
77 | }
78 |
79 | export default ChannelList;
80 |
--------------------------------------------------------------------------------
/src/stores/ChatStore.js:
--------------------------------------------------------------------------------
1 | import alt from '../alt';
2 | import Actions from '../actions';
3 | import {decorate, bind, datasource} from 'alt/utils/decorators';
4 | import ChannelSource from '../sources/ChannelSource';
5 | import MessageSource from '../sources/MessageSource';
6 | import _ from 'lodash';
7 |
8 | @datasource(ChannelSource, MessageSource)
9 | @decorate(alt)
10 | class ChatStore {
11 | constructor(){
12 | this.state = {
13 | user: null,
14 | messages: null,
15 | messagesLoading: true
16 | };
17 | }
18 |
19 | @bind(Actions.messagesLoading)
20 | messagesLoading(){
21 | this.setState({
22 | messagesLoading: true
23 | });
24 | }
25 |
26 | @bind(Actions.messagesReceived)
27 | receivedMessages(messages){
28 | _(messages)
29 | .keys()
30 | .each((k)=> {
31 | messages[k].key = k;
32 | })
33 | .value();
34 |
35 | this.setState({
36 | messages,
37 | messagesLoading: false
38 | });
39 | }
40 |
41 |
42 | @bind(Actions.sendMessage)
43 | sendMessage(message){
44 | this.state.message = message;
45 | setTimeout(this.getInstance().sendMessage, 10);
46 | }
47 |
48 | @bind(Actions.messageReceived)
49 | messageReceived(msg){
50 | if(this.state.messages[msg.key]){
51 | return;
52 | }
53 |
54 | this.state.messages[msg.key] = msg;
55 |
56 | this.setState({
57 | messages: this.state.messages
58 | });
59 | }
60 |
61 | @bind(Actions.channelOpened)
62 | channelOpened(selectedChannel){
63 | _(this.state.channels)
64 | .values()
65 | .each((channel)=> {
66 | channel.selected = false;
67 | })
68 | .value();
69 |
70 | selectedChannel.selected = true;
71 |
72 | this.setState({
73 | selectedChannel,
74 | channels: this.state.channels,
75 | messagesDirty: true
76 | });
77 |
78 | setTimeout(this.getInstance().getMessages, 100);
79 | }
80 |
81 | @bind(Actions.channelsReceived)
82 | receivedChannels(channels){
83 | let selectedChannel;
84 | _(channels)
85 | .keys()
86 | .each((key, index) => {
87 | channels[key].key = key;
88 | if(channels[key].selected){
89 | selectedChannel = channels[key];
90 | }
91 | })
92 | .value();
93 |
94 | this.setState({
95 | channels,
96 | selectedChannel,
97 | messagesDirty: true
98 | });
99 |
100 | setTimeout(this.getInstance().getMessages, 100);
101 | }
102 |
103 | @bind(Actions.login)
104 | login(user){
105 | this.setState({user: user});
106 | }
107 | }
108 |
109 | export default alt.createStore(ChatStore);
110 |
--------------------------------------------------------------------------------
/data/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "messages": {
3 | "firebase": {
4 | "fb_first": {
5 | "message": "First message in firebase",
6 | "date": "Wed, 08 Jul 2015 18:14:27 GMT",
7 | "author": "Hendrik Swanepoel",
8 | "profilePic": "http://www.gravatar.com/avatar/a424e1b0ab3a8dee82c25ae0f0804107?s=48&d=identicon"
9 | },
10 | "fb_second": {
11 | "message": "Second message in firebase",
12 | "date": "Wed, 08 Jul 2015 18:14:32 GMT",
13 | "author": "Stewart Scott",
14 | "profilePic": "http://www.gravatar.com/avatar/f544faf10969339692d6d0474050f8fe?s=48&d=identicon"
15 | }
16 | },
17 | "flux": {
18 | "flux_first": {
19 | "message": "First message in flux",
20 | "date": "Wed, 08 Jul 2015 18:14:27 GMT",
21 | "author": "Hendrik Swanepoel",
22 | "profilePic": "http://www.gravatar.com/avatar/a424e1b0ab3a8dee82c25ae0f0804107?s=48&d=identicon"
23 | },
24 | "flux_second": {
25 | "message": "Second message in flux",
26 | "date": "Wed, 08 Jul 2015 18:14:32 GMT",
27 | "author": "Stewart Scott",
28 | "profilePic": "http://www.gravatar.com/avatar/f544faf10969339692d6d0474050f8fe?s=48&d=identicon"
29 | }
30 | },
31 | "react": {
32 | "react_first": {
33 | "message": "First message in react",
34 | "date": "Wed, 08 Jul 2015 18:14:27 GMT",
35 | "author": "Hendrik Swanepoel",
36 | "profilePic": "http://www.gravatar.com/avatar/a424e1b0ab3a8dee82c25ae0f0804107?s=48&d=identicon"
37 | },
38 | "react_second": {
39 | "message": "Second message in react",
40 | "date": "Wed, 08 Jul 2015 18:14:32 GMT",
41 | "author": "Stewart Scott",
42 | "profilePic": "http://www.gravatar.com/avatar/f544faf10969339692d6d0474050f8fe?s=48&d=identicon"
43 | }
44 | },
45 | "webpack": {
46 | "wp_first": {
47 | "message": "First message in webpack",
48 | "date": "Wed, 08 Jul 2015 18:14:27 GMT",
49 | "author": "Hendrik Swanepoel",
50 | "profilePic": "http://www.gravatar.com/avatar/a424e1b0ab3a8dee82c25ae0f0804107?s=48&d=identicon"
51 | },
52 | "wp_second": {
53 | "message": "Second message in webpack",
54 | "date": "Wed, 08 Jul 2015 18:14:32 GMT",
55 | "author": "Stewart Scott",
56 | "profilePic": "http://www.gravatar.com/avatar/f544faf10969339692d6d0474050f8fe?s=48&d=identicon"
57 | }
58 | }
59 | },
60 | "channels": {
61 | "flux": {
62 | "name": "flux"
63 | },
64 | "react": {
65 | "name": "react"
66 | },
67 | "firebase": {
68 | "name": "firebase"
69 | },
70 | "webpack": {
71 | "name": "webpack"
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------