├── .gitignore
├── README.md
├── bundle.js
├── css
└── styles.css
├── index.html
├── package.json
└── src
├── actions
├── auth.js
├── feedback.js
├── index.js
└── quotes.js
├── constants.js
├── index.js
├── pages
├── components
│ ├── authpanel.js
│ ├── feedbackpanel.js
│ └── quote.js
├── quoteslist.js
└── wrapper.js
├── routes.js
├── store
├── index.js
├── initialstate.js
└── reducers
│ ├── auth.js
│ ├── feedback.js
│ ├── index.js
│ └── quotes.js
└── utils
└── index.js
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | *.log
3 | node_modules
4 | dist
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | To play with this locally, download the files then run `npm install` followed by `npm run build`, and open up `index.html` in a browser! You must run a local webserver or allow outside origins.
2 |
3 | The app is published online [here](http://blog.krawaller.se/reduxfirebasedemo/), and there is a blog post walking through the code [here](http://blog.krawaller.se/posts/a-react-redux-firebase-app-with-authentication/).
4 |
--------------------------------------------------------------------------------
/css/styles.css:
--------------------------------------------------------------------------------
1 | body {
2 | padding-top: 1em;
3 | }
4 |
5 | input {
6 | min-width: 300px;
7 | margin-right: 1em;
8 | }
9 |
10 | .wrapper {
11 | margin: 0 auto;
12 | max-width: 600px;
13 | }
14 |
15 | .authpanel, .feedback, .newquoteform, .quote {
16 | margin-bottom: 1em;
17 | padding: 3px;
18 | }
19 |
20 | .authpanel {
21 | border-bottom: 1px solid #CCC;
22 | }
23 |
24 | .feedback {
25 | border: 1px solid #555;
26 | padding: 0.5em;
27 | background-color: #BFE6C5;
28 | border-radius: 5px;
29 | }
30 |
31 | .feedback button {
32 | float: right;
33 | }
34 |
35 | .feedback.error {
36 | background-color: #E6C8C9;
37 | }
38 |
39 | .author {
40 | font-size: 0.85em;
41 | color: #555;
42 | }
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "devDependencies": {
3 | "browserify": "=12.0.1",
4 | "firebase": "=2.3.1",
5 | "lodash": "^3.10.1",
6 | "react": "=0.14.0",
7 | "react-dom": "=0.14.0",
8 | "react-redux": "=4.0.0",
9 | "react-router": "=1.0.0-rc3",
10 | "reactify": "=1.1.1",
11 | "redux": "=3.0.4",
12 | "redux-thunk": "=1.0.0",
13 | "lodash": "=3.10.1"
14 | },
15 | "scripts": {
16 | "build": "browserify --debug -t [reactify --es6] src/index.js > bundle.js"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/actions/auth.js:
--------------------------------------------------------------------------------
1 | /*
2 | This module contains action creators dealing with `appState.auth`
3 | */
4 |
5 | var C = require("../constants"),
6 | Firebase = require("firebase"),
7 | fireRef = new Firebase(C.FIREBASE);
8 |
9 | module.exports = {
10 | // called at app start
11 | startListeningToAuth: function(){
12 | return function(dispatch,getState){
13 | fireRef.onAuth(function(authData){
14 | if (authData){
15 | dispatch({
16 | type: C.LOGIN_USER,
17 | uid: authData.uid,
18 | username: authData.github.displayName || authData.github.username
19 | });
20 | } else {
21 | if (getState().auth.currently !== C.ANONYMOUS){ // log out if not already logged out
22 | dispatch({type:C.LOGOUT});
23 | }
24 | }
25 | });
26 | }
27 | },
28 | attemptLogin: function(){
29 | return function(dispatch,getState){
30 | dispatch({type:C.ATTEMPTING_LOGIN});
31 | fireRef.authWithOAuthPopup("github", function(error, authData) {
32 | if (error) {
33 | dispatch({type:C.DISPLAY_ERROR,error:"Login failed! "+error});
34 | dispatch({type:C.LOGOUT});
35 | } else {
36 | // no need to do anything here, startListeningToAuth have already made sure that we update on changes
37 | }
38 | });
39 | }
40 | },
41 | logoutUser: function(){
42 | return function(dispatch,getState){
43 | dispatch({type:C.LOGOUT}); // don't really need to do this, but nice to get immediate feedback
44 | fireRef.unauth();
45 | }
46 | }
47 | };
48 |
--------------------------------------------------------------------------------
/src/actions/feedback.js:
--------------------------------------------------------------------------------
1 | /*
2 | This module contains action creators dealing with `appState.feedback`
3 | The only thing that can happen here is that user dismisses feedback.
4 | */
5 |
6 | var C = require("../constants");
7 |
8 | module.exports = {
9 | dismissFeedback: function(num){
10 | return {type:C.DISMISS_FEEDBACK,num:num};
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/src/actions/index.js:
--------------------------------------------------------------------------------
1 | var authActions = require("./auth"),
2 | quotesActions = require("./quotes"),
3 | feedbackActions = require("./feedback");
4 |
5 | module.exports = Object.assign({},authActions,quotesActions,feedbackActions);
--------------------------------------------------------------------------------
/src/actions/quotes.js:
--------------------------------------------------------------------------------
1 | /*
2 | This module contains action creators dealing with `appState.quotes`
3 | */
4 |
5 | var C = require("../constants"),
6 | Firebase = require("firebase"),
7 | quotesRef = new Firebase(C.FIREBASE).child("quotes"),
8 | utils = require("../utils");
9 |
10 | module.exports = {
11 | // called when the app starts. this means we immediately download all quotes, and
12 | // then receive all quotes again as soon as anyone changes anything.
13 | startListeningToQuotes: function(){
14 | return function(dispatch,getState){
15 | quotesRef.on("value",function(snapshot){
16 | dispatch({ type: C.RECEIVE_QUOTES_DATA, data: snapshot.val() });
17 | });
18 | }
19 | },
20 | startQuoteEdit: function(qid){
21 | return {type:C.START_QUOTE_EDIT,qid};
22 | },
23 | cancelQuoteEdit: function(qid){
24 | return {type:C.FINISH_QUOTE_EDIT,qid};
25 | },
26 | deleteQuote: function(qid){
27 | return function(dispatch,getState){
28 | dispatch({type:C.SUBMIT_QUOTE_EDIT,qid});
29 | quotesRef.child(qid).remove(function(error){
30 | dispatch({type:C.FINISH_QUOTE_EDIT,qid});
31 | if (error){
32 | dispatch({type:C.DISPLAY_ERROR,error:"Deletion failed! "+error});
33 | } else {
34 | dispatch({type:C.DISPLAY_MESSAGE,message:"Quote successfully deleted!"});
35 | }
36 | });
37 | };
38 | },
39 | submitQuoteEdit: function(qid,content){
40 | return function(dispatch,getState){
41 | var state = getState(),
42 | username = state.auth.username,
43 | uid = state.auth.uid,
44 | error = utils.validateQuote(content);
45 | if (error){
46 | dispatch({type:C.DISPLAY_ERROR,error});
47 | } else {
48 | dispatch({type:C.SUBMIT_QUOTE_EDIT,qid});
49 | quotesRef.child(qid).set({content,username,uid},function(error){
50 | dispatch({type:C.FINISH_QUOTE_EDIT,qid});
51 | if (error){
52 | dispatch({type:C.DISPLAY_ERROR,error:"Update failed! "+error});
53 | } else {
54 | dispatch({type:C.DISPLAY_MESSAGE,message:"Update successfully saved!"});
55 | }
56 | });
57 | }
58 | };
59 | },
60 | submitNewQuote: function(content){
61 | return function(dispatch,getState){
62 | var state = getState(),
63 | username = state.auth.username,
64 | uid = state.auth.uid,
65 | error = utils.validateQuote(content);
66 | if (error){
67 | dispatch({type:C.DISPLAY_ERROR,error});
68 | } else {
69 | dispatch({type:C.AWAIT_NEW_QUOTE_RESPONSE});
70 | quotesRef.push({content,username,uid},function(error){
71 | dispatch({type:C.RECEIVE_NEW_QUOTE_RESPONSE});
72 | if (error){
73 | dispatch({type:C.DISPLAY_ERROR,error:"Submission failed! "+error});
74 | } else {
75 | dispatch({type:C.DISPLAY_MESSAGE,message:"Submission successfully saved!"});
76 | }
77 | });
78 | }
79 | }
80 | }
81 | };
82 |
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | /*
2 | This file contains all would-be-magic-strings in the app.
3 | */
4 |
5 | module.exports = {
6 | // MISC
7 | FIREBASE: "https://reduxfirebase.firebaseio.com/",
8 |
9 | // UI FEEDBACK ACTIONS
10 | DISPLAY_ERROR: "DISPLAY_ERROR",
11 | DISPLAY_MESSAGE: "DISPLAY_MESSAGE",
12 | DISMISS_FEEDBACK: "DISMISS_FEEDBACK",
13 |
14 | // AUTH ACTIONS
15 | ATTEMPTING_LOGIN: "ATTEMPTING_LOGIN",
16 | LOGIN_USER: "LOGIN_USER",
17 | LOGOUT: "LOGOUT",
18 |
19 | // AUTH STATES
20 | LOGGED_IN: "LOGGED_IN",
21 | ANONYMOUS: "ANONYMOUS",
22 | AWAITING_AUTH_RESPONSE: "AWAITING_AUTH_RESPONSE",
23 |
24 | // QUOTE ACTIONS
25 | RECEIVE_QUOTES_DATA: "RECEIVE_QUOTES_DATA",
26 | AWAIT_NEW_QUOTE_RESPONSE: "AWAIT_NEW_QUOTE_RESPONSE",
27 | RECEIVE_NEW_QUOTE_RESPONSE: "RECEIVE_NEW_QUOTE_RESPONSE",
28 | START_QUOTE_EDIT: "START_QUOTE_EDIT",
29 | FINISH_QUOTE_EDIT: "FINISH_QUOTE_EDIT",
30 | SUBMIT_QUOTE_EDIT: "SUBMIT_QUOTE_EDIT",
31 |
32 | // QUOTE STATES
33 | EDITING_QUOTE: "EDITING_QUOTE",
34 | SUBMITTING_QUOTE: "SUBMITTING_QUOTE"
35 | };
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | /*
2 | This is the entry point for the app! From here we merely import our routes definitions,
3 | then use React and React-DOM to render it.
4 | */
5 |
6 | var React = require('react'),
7 | ReactDOM = require('react-dom'),
8 | Router = require('react-router').Router,
9 | Provider = require('react-redux').Provider,
10 | store = require('./store'),
11 | routes = require('./routes'),
12 | actions = require('./actions')
13 |
14 | ReactDOM.render(
15 | // The top-level Provider is what allows us to `connect` components to the store
16 | // using ReactRedux.connect
17 |
18 |
19 | ,
20 | document.getElementById("root")
21 | );
22 |
23 | // setup Firebase listeners
24 | setTimeout(function(){
25 | store.dispatch( actions.startListeningToAuth() );
26 | store.dispatch( actions.startListeningToQuotes() );
27 | });
--------------------------------------------------------------------------------
/src/pages/components/authpanel.js:
--------------------------------------------------------------------------------
1 | var React = require("react"),
2 | ReactRedux = require("react-redux"),
3 | actions = require("../../actions"),
4 | C = require("../../constants"),
5 | Link = require("react-router").Link;
6 |
7 | var Authpanel = React.createClass({
8 | render: function(){
9 | var p = this.props, auth = p.auth;
10 | switch(auth.currently){
11 | case C.LOGGED_IN: return (
12 |
13 | Logged in as {auth.username}.
14 | {' '}
15 |
16 | );
17 | case C.AWAITING_AUTH_RESPONSE: return (
18 |
19 |
20 |
21 | );
22 | default: return (
23 |
24 |
25 |
26 | );
27 | }
28 | }
29 | });
30 |
31 | // now we connect the component to the Redux store:
32 |
33 | var mapStateToProps = function(appState){
34 | // This component will have access to `appState.auth` through `this.props.auth`
35 | return {auth:appState.auth};
36 | };
37 |
38 | var mapDispatchToProps = function(dispatch){
39 | return {
40 | attemptLogin: function(){ dispatch(actions.attemptLogin()); },
41 | logoutUser: function(){ dispatch(actions.logoutUser()); }
42 | }
43 | };
44 |
45 | module.exports = ReactRedux.connect(mapStateToProps,mapDispatchToProps)(Authpanel);
46 |
--------------------------------------------------------------------------------
/src/pages/components/feedbackpanel.js:
--------------------------------------------------------------------------------
1 | var React = require("react"),
2 | ReactRedux = require("react-redux"),
3 | actions = require("../../actions"),
4 | C = require("../../constants");
5 |
6 | var Feedbackpanel = React.createClass({
7 | render: function(){
8 | var p = this.props, rows = p.feedback.map(function(f,n){
9 | return (
10 | {f.msg}
11 |
12 |
);
13 | });
14 | return (
15 | {rows}
16 |
);
17 | }
18 | });
19 |
20 | // now we connect the component to the Redux store:
21 |
22 | var mapStateToProps = function(appState){
23 | // This component will have access to `appState.feedback` through `this.props.feedback`
24 | return {feedback:appState.feedback};
25 | };
26 |
27 | var mapDispatchToProps = function(dispatch){
28 | return {
29 | dismissFeedback: function(n){ dispatch(actions.dismissFeedback(n)); }
30 | }
31 | };
32 |
33 | module.exports = ReactRedux.connect(mapStateToProps,mapDispatchToProps)(Feedbackpanel);
34 |
--------------------------------------------------------------------------------
/src/pages/components/quote.js:
--------------------------------------------------------------------------------
1 | var React = require("react"),
2 | C = require("../../constants");
3 |
4 | var Quote = React.createClass({
5 | submit: function(e){
6 | var p = this.props,
7 | field = this.refs.field;
8 | p.submit(field.value);
9 | field.value = "";
10 | e.preventDefault();
11 | },
12 | render: function(){
13 | var p = this.props,
14 | q = p.quote,
15 | button;
16 | if (p.state === C.EDITING_QUOTE){
17 | return ();
22 | }
23 | if (!p.mayedit){
24 | button = ""
25 | } else if (p.state === C.SUBMITTING_QUOTE) {
26 | button = ;
27 | } else {
28 | button = ;
29 | }
30 | return {q.username+" said: "}{q.content} {button}
;
31 | }
32 | });
33 |
34 | module.exports = Quote
35 |
--------------------------------------------------------------------------------
/src/pages/quoteslist.js:
--------------------------------------------------------------------------------
1 | var React = require("react"),
2 | ptypes = React.PropTypes,
3 | ReactRedux = require("react-redux"),
4 | C = require("../constants"),
5 | _ = require("lodash"),
6 | actions = require("../actions/"),
7 | Quote = require("./components/quote");
8 |
9 | var Quoteslist = React.createClass({
10 | newQuote: function(e){
11 | if (!this.props.quotes.submitting){
12 | e.preventDefault();
13 | this.props.submitNewQuote(this.refs.newquote.value);
14 | this.refs.newquote.value = '';
15 | }
16 | },
17 | render: function(){
18 | var p = this.props, rows = _.map(p.quotes.data,function(quote,qid){
19 | var quotestate = p.quotes.states[qid];
20 | return
;
31 | }).reverse();
32 | return (
33 | {p.auth.uid ?
:
Log in to add a new quote of your own!
}
37 | {p.quotes.hasreceiveddata ? rows : "Loading quotes..."}
38 |
);
39 | }
40 | });
41 |
42 | // now we connect the component to the Redux store:
43 |
44 | var mapStateToProps = function(appState){
45 | return {
46 | quotes: appState.quotes,
47 | auth: appState.auth
48 | };
49 | };
50 |
51 | var mapDispatchToProps = function(dispatch){
52 | return {
53 | submitNewQuote: function(content){ dispatch(actions.submitNewQuote(content)); },
54 | startEdit: function(qid){ dispatch(actions.startQuoteEdit(qid)); },
55 | cancelEdit: function(qid){ dispatch(actions.cancelQuoteEdit(qid)); },
56 | submitEdit: function(qid,content){ dispatch(actions.submitQuoteEdit(qid,content)); },
57 | deleteQuote: function(qid){ dispatch(actions.deleteQuote(qid)); }
58 | }
59 | };
60 |
61 | module.exports = ReactRedux.connect(mapStateToProps,mapDispatchToProps)(Quoteslist);
62 |
--------------------------------------------------------------------------------
/src/pages/wrapper.js:
--------------------------------------------------------------------------------
1 | /*
2 | This is our top-level component. Sub-components matching specific routes will be
3 | contained in `this.props.children` and rendered out.
4 | */
5 |
6 | var React = require('react'),
7 | Authpanel = require('./components/authpanel'),
8 | Feedbackpanel = require('./components/feedbackpanel');
9 |
10 | var Wrapper = React.createClass({
11 | render: function() {
12 | return (
13 |
14 |
15 |
16 |
17 | {this.props.children}
18 |
19 |
20 | );
21 | }
22 | });
23 |
24 | module.exports = Wrapper;
--------------------------------------------------------------------------------
/src/routes.js:
--------------------------------------------------------------------------------
1 | /*
2 | This is the "sitemap" of our app!
3 | */
4 |
5 | var React = require('react'),
6 | ReactRouter = require('react-router'),
7 | Route = ReactRouter.Route,
8 | IndexRoute = ReactRouter.IndexRoute,
9 | Wrapper = require('./pages/wrapper'),
10 | Quoteslist = require('./pages/quoteslist');
11 |
12 | module.exports = (
13 |
14 |
15 |
16 | );
17 |
--------------------------------------------------------------------------------
/src/store/index.js:
--------------------------------------------------------------------------------
1 | /*
2 | This file defines the main Redux Store. It is used by the app index.js file where it is given to
3 | the Provider element from ReactRedux, which allows smart components to `connect` to the store
4 | */
5 |
6 | var Redux = require("redux"),
7 | rootReducer = require("./reducers"),
8 | initialState = require("./initialstate"),
9 | thunk = require('redux-thunk'); // allows us to use asynchronous actions
10 |
11 |
12 | // A super-simple logger
13 | var logger = store => next => action => {
14 | console.log('dispatching', action.type,action)
15 | var result = next(action)
16 | console.log('next state', store.getState())
17 | return result
18 | }
19 |
20 |
21 | module.exports = Redux.applyMiddleware(thunk,logger)(Redux.createStore)(rootReducer,initialState);
22 |
23 |
--------------------------------------------------------------------------------
/src/store/initialstate.js:
--------------------------------------------------------------------------------
1 | /*
2 | This is the initial state of the Redux Store.
3 | */
4 |
5 | var C = require("../constants");
6 |
7 | module.exports = {
8 | feedback: [
9 | {msg:"Welcome to this little demo! It is meant to demonstrate three things:",error:false},
10 | {msg:"1) How to use Redux + Firebase",error:false},
11 | {msg:"2) How to use authentication in a Redux app",error:false},
12 | {msg:"3) How to have all UI state in Redux and none in the components",error:false}
13 | ],
14 | auth: {
15 | currently: C.ANONYMOUS,
16 | username: null,
17 | uid: null
18 | },
19 | quotes: {
20 | hasreceiveddata: false,
21 | submittingnew: false,
22 | states: {}, // this will store per quote id if we're reading, editing or awaiting DB response
23 | data: {} // this will contain firebase data
24 | }
25 | };
--------------------------------------------------------------------------------
/src/store/reducers/auth.js:
--------------------------------------------------------------------------------
1 | var C = require("../../constants"),
2 | initialState = require("../initialstate");
3 |
4 | /*
5 | A reducer is a function that takes the current state and an action, and then returns a
6 | new state. This reducer is responsible for appState.auth data.
7 | See `initialstate.js` for a clear view of what it looks like!
8 | */
9 |
10 | module.exports = function(currentstate,action){
11 | switch(action.type){
12 | case C.ATTEMPTING_LOGIN:
13 | return {
14 | currently: C.AWAITING_AUTH_RESPONSE,
15 | username: "guest",
16 | uid: null
17 | };
18 | case C.LOGOUT:
19 | return {
20 | currently: C.ANONYMOUS,
21 | username: "guest",
22 | uid: null
23 | };
24 | case C.LOGIN_USER:
25 | return {
26 | currently: C.LOGGED_IN,
27 | username: action.username,
28 | uid: action.uid
29 | };
30 | default: return currentstate || initialState.auth;
31 | }
32 | };
--------------------------------------------------------------------------------
/src/store/reducers/feedback.js:
--------------------------------------------------------------------------------
1 | var C = require("../../constants"),
2 | initialState = require("../initialstate");
3 |
4 | /*
5 | A reducer is a function that takes the current state and an action, and then returns a
6 | new state. This reducer is responsible for appState.feedback data, which is an array of messages.
7 | See `initialstate.js` for a clear view of what it looks like!
8 | */
9 |
10 | module.exports = function(currentfeedback,action){
11 | switch(action.type){
12 | case C.DISMISS_FEEDBACK:
13 | return currentfeedback.filter((i,n)=>n!==action.num);
14 | case C.DISPLAY_ERROR:
15 | return currentfeedback.concat({msg:action.error,error:true});
16 | case C.DISPLAY_MESSAGE:
17 | return currentfeedback.concat({msg:action.message,error:false});
18 | default: return currentfeedback || initialState.feedback;
19 | }
20 | };
--------------------------------------------------------------------------------
/src/store/reducers/index.js:
--------------------------------------------------------------------------------
1 | var Redux = require("redux"),
2 | authReducer = require("./auth"),
3 | quotesReducer = require("./quotes"),
4 | feedbackReducer = require("./feedback");
5 |
6 | var rootReducer = Redux.combineReducers({
7 | auth: authReducer,
8 | quotes: quotesReducer,
9 | feedback: feedbackReducer
10 | });
11 |
12 | module.exports = rootReducer;
--------------------------------------------------------------------------------
/src/store/reducers/quotes.js:
--------------------------------------------------------------------------------
1 | var C = require("../../constants"),
2 | initialState = require("../initialstate"),
3 | _ = require("lodash");
4 |
5 | /*
6 | A reducer is a function that takes the current state and an action, and then returns a
7 | new state. This reducer is responsible for appState.quotes data.
8 | See `initialstate.js` for a clear view of what it looks like!
9 | */
10 |
11 | module.exports = function(currentstate,action){
12 | var newstate;
13 | switch(action.type){
14 | case C.RECEIVE_QUOTES_DATA:
15 | return Object.assign({},currentstate,{
16 | hasreceiveddata: true,
17 | data: action.data
18 | });
19 | case C.AWAIT_NEW_QUOTE_RESPONSE:
20 | return Object.assign({},currentstate,{
21 | submittingnew: true
22 | });
23 | case C.RECEIVE_NEW_QUOTE_RESPONSE:
24 | return Object.assign({},currentstate,{
25 | submittingnew: false
26 | });
27 | case C.START_QUOTE_EDIT:
28 | newstate = _.cloneDeep(currentstate);
29 | newstate.states[action.qid] = C.EDITING_QUOTE;
30 | return newstate;
31 | case C.FINISH_QUOTE_EDIT:
32 | newstate = _.cloneDeep(currentstate);
33 | delete newstate.states[action.qid];
34 | return newstate;
35 | case C.SUBMIT_QUOTE_EDIT:
36 | newstate = _.cloneDeep(currentstate);
37 | newstate.states[action.qid] = C.SUBMITTING_QUOTE;
38 | return newstate;
39 | default: return currentstate || initialState.quotes;
40 | }
41 | };
--------------------------------------------------------------------------------
/src/utils/index.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | validateQuote: function(content){
3 | if (!content || content.length < 10){
4 | return "A quote needs at least 10 characters to be worthy of sharing with the world!";
5 | }
6 | }
7 | };
--------------------------------------------------------------------------------