├── .babelrc
├── .editorconfig
├── .eslintrc
├── .gitignore
├── README.md
├── index.js
├── package.json
├── server
├── index.js
└── render.js
├── src
├── actions
│ ├── AppActions.js
│ ├── ForumActions.js
│ ├── PostActions.js
│ └── apiUtils
│ │ ├── ForumAPIs.js
│ │ └── PostAPIs.js
├── client.js
├── components
│ ├── HtmlDocument.js
│ └── common
│ │ ├── CircularProgress.js
│ │ ├── Header.js
│ │ └── PostList.js
├── constants.js
├── decorators
│ ├── createEnterTransitionHook.js
│ ├── index.js
│ └── propSliceWillChange.js
├── middleware
│ └── logger.js
├── reducers
│ ├── AppReducer.js
│ ├── ForumReducer.js
│ ├── PostReducer.js
│ └── index.js
├── routes
│ ├── index.js
│ └── views
│ │ ├── App.js
│ │ ├── ForumListPageView.js
│ │ ├── ForumPostPageView.js
│ │ ├── HomePageView.js
│ │ ├── PostPageView.js
│ │ └── SearchPostPageView.js
├── server.js
├── styles
│ ├── common.styl
│ ├── common
│ │ ├── Header.styl
│ │ └── PostList.styl
│ └── views
│ │ ├── App.styl
│ │ ├── ForumListPageView.styl
│ │ ├── ForumPostPageView.styl
│ │ ├── HomePageView.styl
│ │ ├── PostPageView.styl
│ │ └── SearchPostPageView.styl
└── utils
│ ├── createReducer.js
│ └── shouldImmutableComponentUpdate.js
├── webpack.config.js
└── webpack
├── config.js
├── dev.config.js
├── index.js
├── prod.config.js
└── utils
├── fake-data-provider.js
├── mkdirs.js
├── notify-stats.js
└── write-stats.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "stage": 0
3 | }
4 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: http://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | # Unix-style newlines with a newline ending every file
7 | [*]
8 | end_of_line = lf
9 | insert_final_newline = true
10 |
11 | # Matches multiple files with brace expansion notation
12 | # Set default charset
13 | [*.{js,py}]
14 | charset = utf-8
15 |
16 | # 4 space indentation
17 | [*.py]
18 | indent_style = space
19 | indent_size = 4
20 |
21 | # Tab indentation (no size specified)
22 | [Makefile]
23 | indent_style = tab
24 |
25 | # Indentation override for all JS under lib directory
26 | [*.js]
27 | indent_style = space
28 | indent_size = 2
29 |
30 | # Matches the exact files either package.json or .travis.yml
31 | [{package.json,.travis.yml}]
32 | indent_style = space
33 | indent_size = 2
34 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "env": {
4 | "browser": true,
5 | "node": true,
6 | },
7 | "ecmaFeatures": {
8 | "arrowFunctions": true,
9 | "classes": true,
10 | "modules": true,
11 | "jsx": true
12 | },
13 | "plugins": [
14 | "react"
15 | ],
16 | "rules": {
17 | // Possible Errors
18 | "comma-dangle": [2, "always-multiline"],
19 | // Best Practices
20 | "consistent-return": [0],
21 | "yoda": [2, "always"],
22 | // Variables
23 | "no-use-before-define": [2, "nofunc"],
24 | // Stylistic Issues
25 | "camelcase": [0],
26 | "no-underscore-dangle": [0],
27 | },
28 | }
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by http://www.gitignore.io
2 |
3 | ### Editor
4 | *.swp
5 |
6 | ### Node ###
7 | # Logs
8 | logs
9 | *.log
10 |
11 | # Runtime data
12 | pids
13 | *.pid
14 | *.seed
15 |
16 | # OSX
17 | .DS_Store
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 |
25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
26 | .grunt
27 |
28 | # Compiled binary addons (http://nodejs.org/api/addons.html)
29 | build/Release
30 |
31 | # Dependency directory
32 | # Commenting this out is preferred by some people, see
33 | # https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git
34 | node_modules
35 |
36 | # Users Environment Variables
37 | .lock-wscript
38 |
39 | ### Project ###
40 | public/build/
41 | server/build/
42 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Redux-Universal
2 |
3 |
4 | ## Features
5 |
6 | - [Universal JavaScript]
7 | - [React-Router]
8 | - Reactive Programming
9 | - Webpack Server Bundling
10 | - Async Data
11 | - [Immutable] Data Structure
12 | - An of course, integration with [Redux]
13 |
14 |
15 | ## Medium Post
16 |
17 | I'd like to share some ideas found in the repo, here you go:
18 |
19 | * [Redux-Universal](https://medium.com/@tomchentw/redux-universal-576fb9475b5b)
20 |
21 |
22 | ## Usage
23 |
24 | ```sh
25 | npm install
26 | ```
27 |
28 | ### Development
29 |
30 | ```sh
31 | npm run dev
32 | ```
33 |
34 | It will start:
35 |
36 | - [express] server at port 3000
37 | - [webpack-dev-server] at port 8080
38 |
39 | Please visit [http://localhost:3000/] to get the page.
40 |
41 | ### Production
42 |
43 | Into two steps, first is build webpack assets:
44 |
45 | ```sh
46 | npm run build
47 | ```
48 |
49 | Then, start [express] server:
50 |
51 | ```sh
52 | npm start
53 | ```
54 |
55 |
56 | ## Credits
57 |
58 | - [@tommy351]: Original creator of this repo
59 | - [@emmenko]: for decorator idea in [redux-react-router-async-example]
60 |
61 |
62 | [Universal JavaScript]: https://medium.com/@mjackson/universal-javascript-4761051b7ae9
63 | [React-Router]: https://github.com/rackt/react-router
64 | [Immutable]: http://facebook.github.io/immutable-js/
65 | [Redux]: https://github.com/gaearon/redux
66 | [express]: http://expressjs.com/
67 | [webpack-dev-server]: http://webpack.github.io/docs/webpack-dev-server.html
68 | [@tommy351]: https://github.com/tommy351
69 | [@emmenko]: https://github.com/emmenko
70 | [redux-react-router-async-example]: https://github.com/emmenko/redux-react-router-async-example
71 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | require("babel-core/register");
2 |
3 | require("./server");
4 |
5 | if ("production" !== process.env.NODE_ENV) {
6 | // In development, serve the static files from the webpack dev server.
7 | require("./webpack");
8 | }
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-universal",
3 | "version": "1.0.0",
4 | "description": "Redux for universal app makes awesome",
5 | "main": "index.js",
6 | "scripts": {
7 | "clean": "rm -rf public/build server/build",
8 | "dev": "NODE_ENV=development nodev --ignore server/build --ignore public/build --ignore src index.js",
9 | "build": "NODE_ENV=production webpack --stats --progress",
10 | "start": "NODE_ENV=production node index",
11 | "eslint": "eslint . --ext .js,.jsx",
12 | "test": "npm run eslint"
13 | },
14 | "repository": {
15 | "type": "git",
16 | "url": "git+https://github.com/tomchentw/redux-universal.git"
17 | },
18 | "keywords": [
19 | "react",
20 | "redux",
21 | "universal"
22 | ],
23 | "author": {
24 | "name": "tomchentw",
25 | "email": "developer@tomchentw.com",
26 | "url": "https://github.com/tomchentw"
27 | },
28 | "license": "MIT",
29 | "bugs": {
30 | "url": "https://github.com/tomchentw/redux-universal/issues"
31 | },
32 | "homepage": "https://github.com/tomchentw/redux-universal#readme",
33 | "devDependencies": {
34 | "autoprefixer-core": "^5.2.1",
35 | "babel-core": "<=5.6.20 || >5.8.12",
36 | "babel-eslint": "^3.1.1",
37 | "babel-loader": "^5.1.0",
38 | "css-loader": "^0.12.1",
39 | "cssnano": "^1.2.0",
40 | "eslint": "^0.21.0",
41 | "eslint-loader": "^0.11.2",
42 | "eslint-plugin-react": "^2.6.3",
43 | "extract-text-webpack-plugin": "^0.8.0",
44 | "json-loader": "^0.5.2",
45 | "nib": "^1.1.0",
46 | "node-libs-browser": "^0.5.2",
47 | "nodev": "^0.8.7",
48 | "null-loader": "^0.1.1",
49 | "postcss-loader": "^0.4.3",
50 | "react-hot-loader": "^1.2.7",
51 | "style-loader": "^0.12.2",
52 | "stylus-loader": "^1.2.1",
53 | "webpack": "^1.9.5",
54 | "webpack-dev-server": "^1.8.2"
55 | },
56 | "dependencies": {
57 | "babel-core": "^5.8.14",
58 | "body-parser": "^1.12.4",
59 | "classnames": "^2.1.1",
60 | "express": "^4.12.4",
61 | "faker": "^3.0.0",
62 | "immutable": "^3.7.2",
63 | "isomorphic-fetch": "^2.1.1",
64 | "keymirror": "^0.1.1",
65 | "lodash": "^3.8.0",
66 | "normalize.css": "^3.0.3",
67 | "object-path": "^0.9.2",
68 | "react": "^0.13.3",
69 | "react-immutable-render-mixin": "^0.8.1",
70 | "react-redux": "^0.2.2",
71 | "react-router": "1.0.0-beta3",
72 | "redux": "^1.0.0-rc",
73 | "redux-thunk": "^0.1.0",
74 | "require-uncached": "^1.0.2",
75 | "serialize-javascript": "^1.0.0",
76 | "serve-static": "^1.10.0",
77 | "thenify": "^3.1.0",
78 | "warning": "^2.0.0"
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/server/index.js:
--------------------------------------------------------------------------------
1 | import {join as joinPath} from "path";
2 | import express from "express";
3 | import bodyParser from "body-parser";
4 | import serveStatic from "serve-static";
5 |
6 | const server = express();
7 | const IS_PRODUCTION = "production" === process.env.NODE_ENV;
8 |
9 | server.use(bodyParser.json());
10 |
11 | if (IS_PRODUCTION) {
12 | // On production, use the public directory for static files
13 | // This directory is created by webpack on build time.
14 | server.use(serveStatic(joinPath(__dirname, "../public")));
15 | }
16 |
17 | // Render the app server-side and send it as response.
18 | server.get("/*", require("./render"));
19 |
20 | // Catch server error
21 | server.use((err, req, res, next) => {
22 | console.error("Error on request %s %s", req.method, req.url);
23 | console.error(err.stack);
24 | res.status(500).send("Server error");
25 | });
26 |
27 | server.listen(3000, () => {
28 | console.log("Server listening on port 3000");
29 | });
30 |
--------------------------------------------------------------------------------
/server/render.js:
--------------------------------------------------------------------------------
1 | import requireUncached from "require-uncached";
2 |
3 | const possibleUncachedRequire = ("production" !== process.env.NODE_ENV ? requireUncached : require);
4 |
5 | export default function(req, res, next) {
6 | const webpackStats = possibleUncachedRequire("../public/build/webpack-stats.json");
7 | const {createHtmlResponse} = possibleUncachedRequire("./build/main");
8 |
9 | return createHtmlResponse({
10 | webpackStats,
11 | request: {
12 | path: req.path,
13 | query: req.query,
14 | },
15 | })
16 | .then(({status, pathname, body}) => {
17 | if (302 === status) {
18 | res.redirect(pathname);
19 | } else {
20 | res.status(status).send(body);
21 | }
22 | }, next);
23 | }
24 |
--------------------------------------------------------------------------------
/src/actions/AppActions.js:
--------------------------------------------------------------------------------
1 | import {ActionTypes} from "../constants";
2 |
3 | export function setTitle(title, appendSitename = true) {
4 | return {
5 | type: ActionTypes.SET_TITLE,
6 | title,
7 | appendSitename,
8 | };
9 | }
10 |
11 | export function setStatus(status) {
12 | return {
13 | type: ActionTypes.SET_STATUS,
14 | status,
15 | };
16 | }
17 |
18 | export function toggleLoginModal(active) {
19 | return {
20 | type: ActionTypes.TOGGLE_LOGIN_MODAL,
21 | active,
22 | };
23 | }
24 |
--------------------------------------------------------------------------------
/src/actions/ForumActions.js:
--------------------------------------------------------------------------------
1 | import {ActionTypes} from "../constants";
2 | import {fetchForumList} from "./apiUtils/ForumAPIs";
3 |
4 | export function getForumList() {
5 | // This is an asynchronous action and we need to dispatch again when respond
6 | // comes in. So we return a factory function to accept a dispatch function.
7 | // This is handled by redux-thunk
8 | return dispatch => {
9 | return fetchForumList()
10 | .then(({data}) => {
11 | dispatch({
12 | type: ActionTypes.UPDATE_FORUM_LIST_SUCCESS,
13 | list: data,
14 | });
15 |
16 | return data;
17 | });
18 | };
19 | }
20 |
--------------------------------------------------------------------------------
/src/actions/PostActions.js:
--------------------------------------------------------------------------------
1 | import {ActionTypes} from "../constants";
2 | import {fetchPostList, fetchPostListBySearchTerm, fetchPost} from "./apiUtils/PostAPIs";
3 |
4 | export function getPostList(forumId) {
5 | return dispatch => {
6 | dispatch({
7 | type: ActionTypes.UPDATE_POST_LIST_START,
8 | forumId,
9 | });
10 |
11 | return fetchPostList(forumId)
12 | .then(({data}) => {
13 | dispatch({
14 | type: ActionTypes.UPDATE_POST_LIST_SUCCESS,
15 | forumId,
16 | list: data,
17 | });
18 |
19 | return data;
20 | });
21 | };
22 | }
23 |
24 | export function getPostListBySearchTerm(searchTerm) {
25 | return dispatch => {
26 | dispatch({
27 | type: ActionTypes.UPDATE_POST_SEARCH_LIST_START,
28 | searchTerm,
29 | });
30 |
31 | return fetchPostListBySearchTerm(searchTerm)
32 | .then(({data}) => {
33 | dispatch({
34 | type: ActionTypes.UPDATE_POST_SEARCH_LIST_SUCCESS,
35 | searchTerm,
36 | list: data,
37 | });
38 |
39 | return data;
40 | });
41 | };
42 | }
43 |
44 | export function getPost(postId) {
45 | return dispatch => {
46 | dispatch({
47 | type: ActionTypes.UPDATE_POST_START,
48 | postId,
49 | });
50 |
51 | return fetchPost(postId)
52 | .then(({data}) => {
53 | dispatch({
54 | type: ActionTypes.UPDATE_POST_SUCCESS,
55 | item: data,
56 | });
57 |
58 | return data;
59 | });
60 | };
61 | }
62 |
--------------------------------------------------------------------------------
/src/actions/apiUtils/ForumAPIs.js:
--------------------------------------------------------------------------------
1 | // import fetch from "isomorphic-fetch";
2 |
3 | export function fetchForumList() {
4 | // Normally you should just return fetch("/api/forums/") ...
5 | return new Promise((resolve) => {
6 | setTimeout(() => {
7 | resolve({
8 | data: global.fakeForumList,
9 | });
10 | }, 800);
11 | });
12 | }
13 |
--------------------------------------------------------------------------------
/src/actions/apiUtils/PostAPIs.js:
--------------------------------------------------------------------------------
1 | // import fetch from "isomorphic-fetch";
2 | import faker from "faker";
3 |
4 | export function fetchPostList(forumId) {
5 | // Normally you should just return fetch(`/api/posts/`) ...
6 | return new Promise((resolve) => {
7 | setTimeout(() => {
8 | resolve({
9 | data: global.fakePostList,
10 | });
11 | }, 800);
12 | });
13 | }
14 |
15 | export function fetchPostListBySearchTerm(term) {
16 | // Normally you should just return fetch(`/api/searchPosts/`) ...
17 | return new Promise((resolve) => {
18 | setTimeout(() => {
19 | const fakeSearchList = global.fakePostSearchList.filter(() => 0.33 > Math.random());
20 |
21 | resolve({
22 | data: fakeSearchList,
23 | });
24 | }, 800);
25 | });
26 | }
27 |
28 | export function fetchPost(postId) {
29 | // Normally you should just return fetch(`/api/posts/:postId`) ...
30 | return new Promise((resolve) => {
31 | setTimeout(() => {
32 | resolve({
33 | data: {
34 | ...([...global.fakePostList, ...global.fakePostSearchList].filter(it => postId === it.id)[0]),
35 | content: faker.lorem.paragraphs(),
36 | },
37 | });
38 | }, 800);
39 | });
40 | }
41 |
--------------------------------------------------------------------------------
/src/client.js:
--------------------------------------------------------------------------------
1 | require("babel-core/polyfill");
2 |
3 | import { createStore, combineReducers, applyMiddleware } from "redux";
4 | import thunkMiddleware from "redux-thunk";
5 | import React from "react";
6 | import {Provider} from "react-redux";
7 | import {Router} from "react-router";
8 | import {history} from "react-router/lib/BrowserHistory";
9 |
10 | import {ActionTypes} from "./constants";
11 | import * as reducers from "./reducers";
12 | import loggerMiddleware from "./middleware/logger";
13 |
14 | const initialState = window.$STATE;
15 |
16 | const reducer = combineReducers(reducers);
17 | const finalCreateStore = applyMiddleware(
18 | thunkMiddleware,
19 | loggerMiddleware
20 | )(createStore);
21 | const store = finalCreateStore(reducer, initialState);
22 |
23 | store.dispatch({ type: ActionTypes.REHYDRATE });
24 |
25 | const childrenRoutes = require("./routes")(store);
26 |
27 | const JAVASCRIPT_IS_ENABLED = true; // Change this to false to see how it works?
28 |
29 | if (JAVASCRIPT_IS_ENABLED) {
30 | React.render((
31 |
32 | {() => }
33 |
34 | ), document.getElementById("root"));
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/HtmlDocument.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from "react";
2 | import serialize from "serialize-javascript";
3 |
4 | export default class HtmlDocument extends React.Component {
5 |
6 | static propTypes = {
7 | state: PropTypes.object.isRequired,
8 | markup: PropTypes.string.isRequired,
9 | webpackStats: PropTypes.object.isRequired,
10 | }
11 |
12 | render() {
13 | const {state, markup, webpackStats} = this.props;
14 | const dehydratedState = "window.$STATE=" + serialize(state);
15 |
16 | const style = [].concat(
17 | webpackStats.main.css
18 | );
19 |
20 | const script = [].concat(
21 | webpackStats.vendor.js,
22 | webpackStats.main.js
23 | );
24 |
25 | return (
26 |
27 |
28 |
29 | {state.AppReducer.fullTitle}
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | {style.map((href, key) => )}
40 |
41 |
42 |
43 |
44 | {script.map((src, key) => )}
45 |
46 |
47 | );
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/common/CircularProgress.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import shouldImmutableComponentUpdate from "../../utils/shouldImmutableComponentUpdate";
4 |
5 | class CircularProgress extends React.Component {
6 | shouldComponentUpdate = shouldImmutableComponentUpdate;
7 |
8 | render() {
9 | return (
10 |
11 | Loading ...
12 |
13 | );
14 | }
15 | }
16 |
17 | export default CircularProgress;
18 |
--------------------------------------------------------------------------------
/src/components/common/Header.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {Link} from "react-router";
3 |
4 | if ("undefined" !== typeof window) {
5 | require("../../styles/common/Header.styl");
6 | }
7 |
8 | class Header extends React.Component {
9 | render() {
10 | return (
11 |
12 |
13 | Home
14 |
15 |
16 | Forums
17 |
18 |
19 | Search
20 |
21 |
22 | );
23 | }
24 | }
25 |
26 | export default Header;
27 |
--------------------------------------------------------------------------------
/src/components/common/PostList.js:
--------------------------------------------------------------------------------
1 | import React, {PropTypes} from "react";
2 | import {Link} from "react-router";
3 |
4 | import shouldImmutableComponentUpdate from "../../utils/shouldImmutableComponentUpdate";
5 |
6 | if ("undefined" !== typeof window) {
7 | require("../../styles/common/PostList.styl");
8 | }
9 |
10 | export default class PostList extends React.Component {
11 | static propTypes = {
12 | forumAlias: PropTypes.string.isRequired,
13 | forumId: PropTypes.string.isRequired,
14 | posts: PropTypes.object.isRequired,
15 | }
16 |
17 | shouldComponentUpdate = shouldImmutableComponentUpdate;
18 |
19 | render() {
20 | const {posts} = this.props;
21 |
22 | return (
23 |
24 | {posts.map(this.renderPost)}
25 |
26 | );
27 | }
28 |
29 | renderPost = (item) => {
30 | return (
31 |
32 |
33 | {item.get("title")}
34 |
35 |
36 | {item.get("shortContent")}
37 |
38 |
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | import keyMirror from "keymirror";
2 |
3 | export const ActionTypes = keyMirror({
4 | DEHYDRATE: null,
5 | REHYDRATE: null,
6 |
7 | SET_STATUS: null,
8 | SET_TITLE: null,
9 |
10 | UPDATE_FORUM_LIST_SUCCESS: null,
11 |
12 | UPDATE_POST_LIST_START: null,
13 | UPDATE_POST_LIST_SUCCESS: null,
14 | UPDATE_POST_LIST_FAILURE: null,
15 |
16 | UPDATE_POST_START: null,
17 | UPDATE_POST_SUCCESS: null,
18 | UPDATE_POST_FAILURE: null,
19 |
20 | UPDATE_POST_SEARCH_LIST_START: null,
21 | UPDATE_POST_SEARCH_LIST_SUCCESS: null,
22 | UPDATE_POST_SEARCH_LIST_FAILURE: null,
23 | });
24 |
--------------------------------------------------------------------------------
/src/decorators/createEnterTransitionHook.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function createEnterTransitionHook (
4 | transitionHookCreator
5 | ): (DecoratedComponent: React.Component) => React.Component {
6 |
7 | return DecoratedComponent =>
8 | class OnEnterDecorator extends React.Component {
9 |
10 | static displayName = `OnEnterDecorator(${DecoratedComponent.displayName || DecoratedComponent.name || "Component"})`;
11 | static DecoratedComponent = DecoratedComponent;
12 |
13 | static onEnterCreator = (store) => {
14 | const hook = transitionHookCreator(store);
15 | return (state, transition, callback) => {
16 | const promise = hook(state, transition);
17 | if (promise && promise.then) {
18 | promise.then(() => callback(), callback);
19 | } else {
20 | callback();
21 | }
22 | };
23 | }
24 |
25 | render () {
26 | return (
27 |
28 | );
29 | }
30 | };
31 | }
32 |
--------------------------------------------------------------------------------
/src/decorators/index.js:
--------------------------------------------------------------------------------
1 | export { connect } from "react-redux";
2 |
3 | export { default as createEnterTransitionHook } from "./createEnterTransitionHook";
4 | export { default as propSliceWillChange } from "./propSliceWillChange";
5 |
--------------------------------------------------------------------------------
/src/decorators/propSliceWillChange.js:
--------------------------------------------------------------------------------
1 | import {get as getValue} from "object-path";
2 | import Immutable from "immutable";
3 | import React from "react";
4 |
5 | export default function propSliceWillChange (propKeys, fn/*, options */) {
6 | const {
7 | compareFunction,
8 | fireOnWillMount
9 | } = {
10 | compareFunction: Immutable.is,
11 | fireOnWillMount: true,
12 | ...arguments[2],
13 | };
14 |
15 | return DecoratedComponent =>
16 | class PropSliceWillChangeDecorator extends React.Component {
17 |
18 | static displayName = `PropSliceWillChangeDecorator(${DecoratedComponent.displayName || DecoratedComponent.name || "Component"})`;
19 | static DecoratedComponent = DecoratedComponent;
20 |
21 | componentWillMount () {
22 | if (fireOnWillMount) {
23 | fn(this.props);
24 | }
25 | }
26 |
27 | componentWillUpdate (nextProps) {
28 | const hasChanges = propKeys.some(key => {
29 | const value = getValue(this.props, key);
30 | const nextValue = getValue(nextProps, key);
31 | const changed = !compareFunction(value, nextValue);
32 |
33 | if (changed) {
34 | console.log(`${ key } changed from ${ value } to ${ nextValue }`);
35 | }
36 | return changed;
37 | });
38 |
39 | if (hasChanges) {
40 | fn(nextProps);
41 | }
42 | }
43 |
44 | render () {
45 | return (
46 |
47 | );
48 | }
49 | };
50 | }
51 |
--------------------------------------------------------------------------------
/src/middleware/logger.js:
--------------------------------------------------------------------------------
1 | export default function loggerMiddleware ({ getState }) {
2 | return next => action => {
3 | if ("production" !== process.env.NODE_ENV) {
4 | console.log("action", action);
5 | console.log("state", getState());
6 | }
7 | next(action);
8 | };
9 | }
10 |
--------------------------------------------------------------------------------
/src/reducers/AppReducer.js:
--------------------------------------------------------------------------------
1 | import {ActionTypes} from "../constants";
2 | import createReducer from "../utils/createReducer";
3 |
4 | const TITLE_POSTFIX = " - Redux-Universal";
5 |
6 | function setTitle(state, action) {
7 | return {
8 | ...state,
9 | title: action.title,
10 | fullTitle: action.title + (action.appendSitename ? TITLE_POSTFIX : ""),
11 | };
12 | }
13 |
14 | function setStatus(state, action) {
15 | return {
16 | ...state,
17 | status: action.status,
18 | };
19 | }
20 |
21 | function dehydrate(state/*, action*/) {
22 | return {
23 | ...state,
24 | fetchForServerRendering: false,
25 | };
26 | }
27 |
28 | function rehydrate(state/*, action*/) {
29 | return state;
30 | }
31 |
32 | const actionHandlers = {
33 | [ActionTypes.SET_TITLE]: setTitle,
34 | [ActionTypes.SET_STATUS]: setStatus,
35 |
36 | [ActionTypes.DEHYDRATE]: dehydrate,
37 | [ActionTypes.REHYDRATE]: rehydrate,
38 | };
39 |
40 | export default createReducer(null, actionHandlers);
41 |
--------------------------------------------------------------------------------
/src/reducers/ForumReducer.js:
--------------------------------------------------------------------------------
1 | import Immutable, {Map, List} from "immutable";
2 |
3 | import {ActionTypes} from "../constants";
4 | import createReducer from "../utils/createReducer";
5 |
6 | const initialState = new Map({
7 | forumList: new List(),
8 | });
9 |
10 | function updateList(state, action) {
11 | const list = Immutable.fromJS(action.list);
12 |
13 | return state.withMutations(mutableState => {
14 |
15 | mutableState = mutableState.set("forumList", list);
16 |
17 | return mutableState;
18 | });
19 | }
20 |
21 | function dehydrate(state/*, action*/) {
22 | return state.toJS();
23 | }
24 |
25 | function rehydrate(state/*, action*/) {
26 | return Immutable.fromJS(state);
27 | }
28 |
29 | const actionHandlers = {
30 | [ActionTypes.UPDATE_FORUM_LIST_SUCCESS]: updateList,
31 |
32 | [ActionTypes.DEHYDRATE]: dehydrate,
33 | [ActionTypes.REHYDRATE]: rehydrate,
34 | };
35 |
36 | export default createReducer(initialState, actionHandlers);
37 |
--------------------------------------------------------------------------------
/src/reducers/PostReducer.js:
--------------------------------------------------------------------------------
1 | import Immutable, {OrderedSet, Map} from "immutable";
2 |
3 | import {ActionTypes} from "../constants";
4 | import createReducer from "../utils/createReducer";
5 |
6 | const initialState = new Map({
7 | postIdsByForumId: new Map(),
8 | postIdsBySearchTerm: new Map(),
9 | postById: new Map(),
10 | });
11 |
12 | function startUpdateList (state, action) {
13 | const {forumId} = action;
14 |
15 | return state.withMutations(mutableState => {
16 |
17 | mutableState = mutableState.setIn(["postIdsByForumId", forumId], new OrderedSet());
18 |
19 | return mutableState;
20 | });
21 | }
22 |
23 | function updateList(state, action) {
24 | const {forumId} = action;
25 | const list = Immutable.fromJS(action.list);
26 |
27 | return state.withMutations(mutableState => {
28 | list.forEach(post => {
29 | const id = post.get("id");
30 |
31 | mutableState = mutableState.updateIn(["postIdsByForumId", forumId], set => set.add(id));
32 |
33 | mutableState = mutableState.mergeDeepIn(["postById", id], post);
34 | });
35 |
36 | return mutableState;
37 | });
38 | }
39 |
40 | function updatePost(state, action) {
41 | const post = Immutable.fromJS(action.item);
42 |
43 | return state.withMutations(mutableState => {
44 | const id = post.get("id");
45 |
46 | mutableState = mutableState.mergeDeepIn(["postById", id], post);
47 |
48 | return mutableState;
49 | });
50 | }
51 |
52 | function updateSearchList(state, action) {
53 | const list = Immutable.fromJS(action.list);
54 | const {searchTerm} = action;
55 |
56 | return state.withMutations(mutableState => {
57 | list.forEach(post => {
58 | const id = post.get("id");
59 |
60 | mutableState = mutableState.updateIn(["postIdsBySearchTerm", searchTerm],
61 | new OrderedSet(), set => set.add(id)
62 | );
63 |
64 | mutableState = mutableState.mergeIn(["postById", id], post);
65 | });
66 | return mutableState;
67 | });
68 | }
69 |
70 | function dehydrate(state) {
71 | return state.toJS();
72 | }
73 |
74 | function rehydrate(state) {
75 | return Immutable.fromJS(state).withMutations(mutableState => {
76 | ["postIdsByForumId", "postIdsBySearchTerm"].forEach(orderedValuesKey => {
77 |
78 | mutableState = mutableState.set(
79 | orderedValuesKey,
80 | mutableState
81 | .get(orderedValuesKey)
82 | .map((valueSet) => new OrderedSet(valueSet))
83 | );
84 |
85 | });
86 |
87 | return mutableState;
88 | });
89 | }
90 |
91 |
92 | const actionHandlers = {
93 | [ActionTypes.UPDATE_POST_LIST_START]: startUpdateList,
94 | [ActionTypes.UPDATE_POST_LIST_SUCCESS]: updateList,
95 |
96 | [ActionTypes.UPDATE_POST_SUCCESS]: updatePost,
97 |
98 | [ActionTypes.UPDATE_POST_SEARCH_LIST_SUCCESS]: updateSearchList,
99 |
100 | [ActionTypes.DEHYDRATE]: dehydrate,
101 | [ActionTypes.REHYDRATE]: rehydrate,
102 | };
103 |
104 | export default createReducer(initialState, actionHandlers);
105 |
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | export { default as AppReducer } from "./AppReducer";
2 | export { default as ForumReducer } from "./ForumReducer";
3 | export { default as PostReducer } from "./PostReducer";
4 |
--------------------------------------------------------------------------------
/src/routes/index.js:
--------------------------------------------------------------------------------
1 | import App from "./views/App";
2 | import HomePageView from "./views/HomePageView";
3 | import ForumListPageView from "./views/ForumListPageView";
4 | import ForumPostPageView from "./views/ForumPostPageView";
5 | import PostPageView from "./views/PostPageView";
6 | import SearchPostPageView from "./views/SearchPostPageView";
7 |
8 | export default function routes(store) {
9 | return {
10 | onEnter: App.onEnterCreator(store),
11 | component: App,
12 | childRoutes: [
13 | {
14 | component: HomePageView,
15 | path: "/",
16 | },
17 | {
18 | component: ForumListPageView,
19 | path: "/forums",
20 | },
21 | {
22 | onEnter: ForumPostPageView.onEnterCreator(store),
23 | component: ForumPostPageView,
24 | path: "/forums/:forumId",
25 | },
26 | {
27 | onEnter: PostPageView.onEnterCreator(store),
28 | component: PostPageView,
29 | path: "/posts/:postId",
30 | },
31 | {
32 | onEnter: SearchPostPageView.onEnterCreator(store),
33 | component: SearchPostPageView,
34 | path: "/search",
35 | },
36 | {
37 | path: "*",
38 | onEnter(state, transition) {
39 | // You may choose to render a 404 PageView here.
40 | transition.to("/");
41 | },
42 | },
43 | ],
44 | };
45 | }
46 |
--------------------------------------------------------------------------------
/src/routes/views/App.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from "react";
2 |
3 | import {connect, createEnterTransitionHook} from "../../decorators";
4 | import * as ForumActions from "../../actions/ForumActions";
5 | import CircularProgress from "../../components/common/CircularProgress";
6 | import Header from "../../components/common/Header";
7 |
8 | if ("undefined" !== typeof window) {
9 | require("../../styles/views/App.styl");
10 | }
11 |
12 | function fetchData (dispatch) {
13 | return dispatch(ForumActions.getForumList());
14 | }
15 |
16 | @createEnterTransitionHook(store => (/*state, transition */) => {
17 | const { AppReducer, ForumReducer } = store.getState();
18 |
19 | if (AppReducer.fetchForServerRendering) {
20 | return fetchData(store.dispatch);
21 | }
22 | if (ForumReducer.get("forumList").isEmpty()) {
23 | return fetchData(store.dispatch);
24 | }
25 | })
26 | @connect(state => {
27 | const { AppReducer } = state;
28 |
29 | return {
30 | fullTitle: AppReducer.fullTitle,
31 | isLoginModalActive: AppReducer.isLoginModalActive,
32 | };
33 | })
34 | export default class App extends React.Component {
35 |
36 | static contextTypes = {
37 | store: PropTypes.object.isRequired,
38 | }
39 |
40 | static propTypes = {
41 | fullTitle: PropTypes.string.isRequired, // Provide by @connect decorator (The return value of the function)
42 | dispatch: PropTypes.func.isRequired, // Provide by @connect decorator
43 | isTransitioning: PropTypes.bool.isRequired, // Provide by Route component (who renders this component)
44 | }
45 |
46 | componentDidUpdate() {
47 | document.title = this.props.fullTitle;
48 | }
49 |
50 | render () {
51 | const {dispatch, isTransitioning} = this.props;
52 |
53 | return (
54 |
55 |
56 | {this.props.children && (
57 | React.cloneElement(this.props.children, { dispatch })
58 | )}
59 | {isTransitioning && (
60 |
61 |
62 |
63 | )}
64 |
65 | );
66 | }
67 | }
68 |
69 | export default App;
70 |
--------------------------------------------------------------------------------
/src/routes/views/ForumListPageView.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from "react";
2 | import {Link} from "react-router";
3 |
4 | import {connect} from "../../decorators";
5 | import * as AppActions from "../../actions/AppActions";
6 |
7 | if ("undefined" !== typeof window) {
8 | require("../../styles/views/ForumListPageView.styl");
9 | }
10 |
11 | @connect(state => ({
12 | forumList: state.ForumReducer.get("forumList"),
13 | }))
14 | export default class ForumListPageView extends React.Component {
15 |
16 | static contextTypes = {
17 | store: PropTypes.object.isRequired,
18 | }
19 |
20 | static propTypes = {
21 | forumList: PropTypes.object.isRequired,
22 | }
23 |
24 | componentWillMount () {
25 | const { dispatch } = this.props;
26 | dispatch(AppActions.setTitle("Forum List Page View"));
27 | }
28 |
29 | render() {
30 | const { forumList } = this.props;
31 |
32 | return (
33 |
34 | {forumList.map((forum) => (
35 | -
36 |
39 | {forum.get("title")}
40 |
41 |
42 | // .toArray is still needed due to parent and owner context different in React 0.13
43 | )).toArray()}
44 |
45 | );
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/routes/views/ForumPostPageView.js:
--------------------------------------------------------------------------------
1 | import {OrderedSet} from "immutable";
2 | import React, {PropTypes} from "react";
3 |
4 | import {connect, createEnterTransitionHook} from "../../decorators";
5 | import * as AppActions from "../../actions/AppActions";
6 | import * as PostActions from "../../actions/PostActions";
7 | import PostList from "../../components/common/PostList";
8 |
9 | if ("undefined" !== typeof window) {
10 | require("../../styles/views/ForumPostPageView.styl");
11 | }
12 |
13 | function fetchData (dispatch, forumId) {
14 | return dispatch(PostActions.getPostList(forumId));
15 | }
16 |
17 | @createEnterTransitionHook(store => (state/*, transition */) => {
18 | const { AppReducer, PostReducer } = store.getState();
19 | const { forumId } = state.params;
20 |
21 | if (AppReducer.fetchForServerRendering) {
22 | return fetchData(store.dispatch, forumId);
23 | }
24 | if (!PostReducer.hasIn(["postIdsByForumId", forumId])) {
25 | return fetchData(store.dispatch, forumId);
26 | }
27 | })
28 | @connect((state, props) => {
29 | const { ForumReducer, PostReducer } = state;
30 | const { params: { forumId } } = props;
31 |
32 | const forum = ForumReducer.get("forumList").find(item => forumId === item.get("id"));
33 | const posts = PostReducer.getIn(["postIdsByForumId", forumId], new OrderedSet())
34 | .map(id => PostReducer.getIn(["postById", id]));
35 |
36 | return {
37 | forum,
38 | posts,
39 | };
40 | })
41 | export default class ForumPostPageView extends React.Component {
42 |
43 | static contextTypes = {
44 | store: PropTypes.object.isRequired,
45 | router: PropTypes.object.isRequired,
46 | }
47 |
48 | static propTypes = {
49 | dispatch: PropTypes.func.isRequired,
50 | forum: PropTypes.object,
51 | posts: PropTypes.object,
52 | }
53 |
54 | componentWillMount () {
55 | const { dispatch, forum } = this.props;
56 |
57 | dispatch(AppActions.setTitle(`${ forum.get("title") } | ForumPostPageView`));
58 | }
59 |
60 | render () {
61 | const {params, forum, posts} = this.props;
62 |
63 | return (
64 |
65 |
66 | {forum.get("title")}
67 |
68 |
72 |
73 | );
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/routes/views/HomePageView.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from "react";
2 |
3 | import * as AppActions from "../../actions/AppActions";
4 |
5 | if ("undefined" !== typeof window) {
6 | require("../../styles/views/HomePageView.styl");
7 | }
8 |
9 | export default class HomePageView extends React.Component {
10 |
11 | static propTypes = {
12 | dispatch: PropTypes.func.isRequired,
13 | }
14 |
15 | componentWillMount () {
16 | const { dispatch } = this.props;
17 | dispatch(AppActions.setTitle("HomePageView", false));
18 | }
19 |
20 | render() {
21 | return (
22 |
23 | Content of HomePageView
24 |
25 | );
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/routes/views/PostPageView.js:
--------------------------------------------------------------------------------
1 | import React, {PropTypes} from "react";
2 |
3 | import {connect, createEnterTransitionHook, propSliceWillChange} from "../../decorators";
4 |
5 | import * as AppActions from "../../actions/AppActions";
6 | import * as PostActions from "../../actions/PostActions";
7 |
8 | if ("undefined" !== typeof window) {
9 | require("../../styles/views/PostPageView.styl");
10 | }
11 |
12 | function fetchData (dispatch, postId) {
13 | return dispatch(PostActions.getPost(postId));
14 | }
15 |
16 | @createEnterTransitionHook(store => (state/*, transition */) => {
17 | const { AppReducer, PostReducer } = store.getState();
18 | const { params: { postId } } = state;
19 |
20 | if (AppReducer.fetchForServerRendering) {
21 | return fetchData(store.dispatch, postId);
22 | }
23 | if (!PostReducer.hasIn(["postById", postId, "content"])) {
24 | return fetchData(store.dispatch, postId);
25 | }
26 | })
27 | @connect((state, props) => {
28 | const { PostReducer } = state;
29 | const { params: { postId } } = props;
30 |
31 | const post = PostReducer.getIn(["postById", postId]);
32 |
33 | return {
34 | post,
35 | };
36 | })
37 | @propSliceWillChange(["post"], (props) => {
38 | const { dispatch, post } = props;
39 | if (post) {
40 |
41 | dispatch(AppActions.setTitle(`${ post.get("title") } | PostPageView`));
42 | }
43 | })
44 | export default class PostPageView extends React.Component {
45 |
46 | static propTypes = {
47 | dispatch: PropTypes.func.isRequired,
48 | post: PropTypes.object.isRequired,
49 | }
50 |
51 | static contextTypes = {
52 | store: PropTypes.object.isRequired,
53 | router: PropTypes.object.isRequired,
54 | }
55 |
56 | render() {
57 | const {post} = this.props;
58 |
59 | return (
60 |
61 |
62 | {post.get("title")}
63 |
64 |
{post.get("content")}
65 |
66 | );
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/routes/views/SearchPostPageView.js:
--------------------------------------------------------------------------------
1 | import {OrderedSet} from "immutable";
2 | import React, { PropTypes } from "react";
3 |
4 | import {connect, createEnterTransitionHook, propSliceWillChange} from "../../decorators";
5 | import * as PostActions from "../../actions/PostActions";
6 | import PostList from "../../components/common/PostList";
7 |
8 | if ("undefined" !== typeof window) {
9 | require("../../styles/views/SearchPostPageView.styl");
10 | }
11 |
12 | function fetchData (dispatch, term) {
13 | return dispatch(PostActions.getPostListBySearchTerm(term));
14 | }
15 |
16 | @createEnterTransitionHook(store => (state/*, transition*/) => {
17 | const { AppReducer } = store.getState();
18 | const { location: { query } } = state;
19 |
20 | if (!query || !query.q) {
21 | return;
22 | }
23 |
24 | if (AppReducer.fetchForServerRendering) {
25 | return fetchData(store.dispatch, query.q);
26 | }
27 | })
28 | @propSliceWillChange(["location.query.q"], (props) => {
29 | const { dispatch, location: { query } } = props;
30 |
31 | if (query && query.q) {
32 | fetchData(dispatch, query.q);
33 | }
34 | })
35 | @connect((state, props) => {
36 | const { PostReducer } = state;
37 | const { location: { query } } = props;
38 |
39 | let posts = new OrderedSet();
40 |
41 | if (query && query.q) {
42 | posts = PostReducer.getIn(["postIdsBySearchTerm", query.q], posts)
43 | .map(id => PostReducer.getIn(["postById", id]));
44 | }
45 |
46 | return {
47 | posts,
48 | };
49 | })
50 | export default class SearchPostPageView extends React.Component {
51 |
52 | static contextTypes = {
53 | store: PropTypes.object.isRequired,
54 | router: PropTypes.object.isRequired,
55 | }
56 |
57 | static propTypes = {
58 | posts: PropTypes.object.isRequired,
59 | }
60 |
61 | constructor(props, context) {
62 | super(props, context);
63 |
64 | const { location: { query } } = props;
65 |
66 | this.state = {
67 | term: query ? query.q : null,
68 | };
69 | }
70 |
71 | render() {
72 | return (
73 |
74 | {this.renderHeader()}
75 | {this.renderContent()}
76 |
77 | );
78 | }
79 |
80 | renderHeader() {
81 | return (
82 |
93 | );
94 | }
95 |
96 | renderContent() {
97 | const {location: {query}, posts} = this.props;
98 | const {term} = this.state;
99 |
100 | if (query && query.q === term) {
101 | return (
102 |
103 | );
104 | } else {
105 | return (
106 |
107 | );
108 | }
109 | }
110 |
111 | getInputField() {
112 | return React.findDOMNode(this.refs.input);
113 | }
114 |
115 | handleSubmit = (e) => {
116 | e.preventDefault();
117 |
118 | const {term} = this.state;
119 |
120 | // Blur the input field when the form is submittted
121 | this.getInputField().blur();
122 |
123 | this.context.router.transitionTo("/search", {
124 | q: term,
125 | });
126 | }
127 |
128 | handleInputChange = (e) => {
129 | this.setState({
130 | term: e.target.value,
131 | });
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/src/server.js:
--------------------------------------------------------------------------------
1 | import thenify from "thenify";
2 | import { createStore, combineReducers, applyMiddleware } from "redux";
3 | import thunkMiddleware from "redux-thunk";
4 | import React from "react";
5 | import {Provider} from "react-redux";
6 | import Router from "react-router";
7 | import Location from "react-router/lib/Location";
8 |
9 | import {ActionTypes} from "./constants";
10 | import * as reducers from "./reducers";
11 | import loggerMiddleware from "./middleware/logger";
12 | import HtmlDocument from "./components/HtmlDocument";
13 |
14 | const runRouter = thenify(Router.run);
15 |
16 | const extraMiddlewares = [
17 | ];
18 | if (process.env.DEBUG) {
19 | extraMiddlewares.push(loggerMiddleware);
20 | }
21 |
22 | export function createHtmlResponse ({
23 | webpackStats,
24 | request,
25 | }) {
26 | const initialState = {
27 | AppReducer: {
28 | status: 200,
29 | //
30 | title: "Redux-Universal",
31 | fullTitle: "Redux-Universal",
32 | // For server-rendering, we should load data so set it to true.
33 | // The trick is only set it to false when ActionTypes.DEHYDRATE is triggered.
34 | fetchForServerRendering: true,
35 | },
36 | };
37 |
38 | const reducer = combineReducers(reducers);
39 | const finalCreateStore = applyMiddleware(thunkMiddleware, ...extraMiddlewares)(createStore);
40 | const store = finalCreateStore(reducer, initialState);
41 |
42 | const routes = require("./routes")(store);
43 | const location = new Location(request.path, request.query);
44 |
45 | return runRouter(routes, location).then(([routerState, transition]) => {
46 | if (transition.isCancelled) {
47 | if (transition.redirectInfo) {
48 | return {
49 | status: 302,
50 | pathname: transition.redirectInfo.pathname,
51 | };
52 | } else {
53 | return Promise.reject(transition.abortReason);
54 | }
55 | }
56 |
57 | const markup = React.renderToString(
58 |
59 | {() => }
60 |
61 | );
62 |
63 | store.dispatch({ type: ActionTypes.DEHYDRATE });
64 | const state = store.getState();
65 |
66 | const html = React.renderToStaticMarkup(
67 |
71 | );
72 |
73 | return {
74 | status: state.AppReducer.status,
75 | body: `${ html }`,
76 | };
77 | });
78 | }
79 |
--------------------------------------------------------------------------------
/src/styles/common.styl:
--------------------------------------------------------------------------------
1 | // Config
2 | support-for-ie = false
3 | vendor-prefixes = official
4 |
5 | // Color
6 | color-text = #444
7 | color-background = #fafafa
8 | color-accent = #1378BB
9 | color-divider = #ddd
10 | color-gray = #999
11 | color-highlight-background = #FFFBE6
12 | color-red = #F34235
13 |
14 | // Typography
15 | font-sans = "Helvetica Neue", Helvetica, Arial, sans-serif
16 | font-size-base = 15px
17 | line-height = 1.6
18 |
19 | // Layout
20 | padding-small = 8px
21 | padding-medium = 16px
22 | padding-large = 32px
23 | header-height = 52px
24 |
--------------------------------------------------------------------------------
/src/styles/common/Header.styl:
--------------------------------------------------------------------------------
1 | @import "nib"
2 | @import "../common"
3 |
4 | logo-width = 65px
5 | logo-height = 25px
6 |
7 | .header
8 | background: color-gray
9 | position: fixed
10 | top: 0
11 | left: 0
12 | right: 0
13 | height: header-height
14 | z-index: 1
15 | color: #fff
16 |
17 | .header__btn
18 | height: header-height
19 | width: 100px
20 | font-size: 24px
21 | line-height: @height
22 | text-align: center
23 | border: none
24 | background: none
25 | padding: 0
26 | outline: none
27 | color: #fff
28 | float: left
29 |
--------------------------------------------------------------------------------
/src/styles/common/PostList.styl:
--------------------------------------------------------------------------------
1 | @import "nib"
2 | @import "../common"
3 |
4 | post-like-size = 52px
5 | pin-icon-size = 16px
6 |
7 | .post-list__item
8 | display: block
9 | border-top: 1px solid color-divider
10 | text-decoration: none
11 | color: inherit
12 | padding: padding-medium
13 |
14 | .post-list__header
15 | clearfix()
16 | .post-list__item--pinned &
17 | margin-right: pin-icon-size
18 | padding-right: padding-small
19 |
20 | .post-list__title
21 | font-weight: bold
22 | white-space: nowrap
23 | overflow: hidden
24 | text-overflow: ellipsis
25 | height: 1em
26 | line-height: 1em
27 | display: block
28 | color: color-accent
29 | float: left
30 | width: 100%
31 | .post-list__item:visited &
32 | color: color-text
33 |
34 | .post-list__pin-icon
35 | color: color-red
36 | float: right
37 | width: pin-icon-size
38 | height: @width
39 | text-align: center
40 | font-size: pin-icon-size
41 | margin-right: (pin-icon-size + padding-small) * -1
42 |
43 | .post-list__content
44 | position: relative
45 | padding-left: post-like-size + padding-medium
46 | min-height: post-like-size
47 |
48 | .post-list__left
49 | width: post-like-size
50 | height: @width
51 | line-height: @height
52 | position: absolute
53 | top: 0
54 | left: 0
55 |
56 | .post-list__like
57 | border-radius: 50%
58 | text-align: center
59 | color: #fff
60 | font-weight: 300
61 | font-size: 16px
62 | position: absolute
63 | top: 0
64 | left: 0
65 | right: 0
66 | bottom: 0
67 | white-space: nowrap
68 |
69 | .post-list__avatar
70 | width: 24px
71 | height: @width
72 | position: absolute
73 | right: -6px
74 | bottom: @right
75 |
76 | .post-list__excerpt
77 | color: color-gray
78 | font-size: 0.9em
79 | line-height: line-height
80 | margin: 0.5em 0
81 |
82 | .post-list__footer
83 | line-height: 1
84 | position: relative
85 |
86 | .post-list__author
87 | color: color-gray
88 | font-size: 0.9em
89 | line-height: 1
90 | height: 1em
91 | overflow: hidden
92 | white-space: nowrap
93 | text-overflow: ellipsis
94 | width: 75%
95 |
96 | .post-list__forum
97 | &:after
98 | content: "/"
99 | padding: 0 0.5em
100 | font-weight: normal
101 |
102 | .post-list__comment
103 | font-size: 0.9em
104 | font-weight: bold
105 | align-self: flex-end
106 | white-space: nowrap
107 | position: absolute
108 | top: 0
109 | right: 0
110 | .fa
111 | margin-right: 0.5em
112 |
--------------------------------------------------------------------------------
/src/styles/views/App.styl:
--------------------------------------------------------------------------------
1 | @import "nib"
2 | @import "~normalize.css"
3 | @import "../common"
4 |
5 | html
6 | box-sizing: border-box
7 |
8 | *, *::before, *::after, input[type="search"]
9 | box-sizing: inherit
10 |
11 | body
12 | font-size: font-size-base
13 | font-family: font-sans
14 | background: color-background
15 | color: color-text
16 | text-rendering: optimizeLegibility
17 | -webkit-font-smoothing: antialiased
18 | -moz-osx-font-smoothing: grayscale
19 |
20 | html, body, #root
21 | height: 100%
22 |
23 | .app
24 | padding-top: header-height
25 |
26 | .app__loading
27 | position: fixed
28 | top: 0
29 | left: 0
30 | width: 100%
31 | height: 100%
32 | background: #fff
33 |
34 | .app__loading-progress
35 | position: absolute
36 | top: 0
37 | left: 0
38 | right: 0
39 | bottom: 0
40 | margin: auto
41 | width: 15%
42 | height: 15%
43 |
--------------------------------------------------------------------------------
/src/styles/views/ForumListPageView.styl:
--------------------------------------------------------------------------------
1 | @import "nib"
2 | @import "../common"
3 |
4 | .forum-list
5 | margin: 0
6 | list-style: none
7 | padding: padding-small 0
8 |
9 | .forum-list__link
10 | padding: padding-small padding-medium
11 | color: color-text
12 | font-weight: bold
13 | text-decoration: none
14 | display: block
15 |
--------------------------------------------------------------------------------
/src/styles/views/ForumPostPageView.styl:
--------------------------------------------------------------------------------
1 | @import "nib"
2 | @import "../common"
3 |
4 | .forum__header
5 | clearfix()
6 | padding: padding-medium
7 |
8 | .forum__title
9 | margin: 0
10 | float: left
11 |
--------------------------------------------------------------------------------
/src/styles/views/HomePageView.styl:
--------------------------------------------------------------------------------
1 | @import "nib"
2 | @import "../common"
3 |
4 | .home__section-top
5 | background: #212F3A
6 | text-align: center
7 | color: #fff
8 | font-size: 1.2em
9 | padding: padding-medium 0
10 |
--------------------------------------------------------------------------------
/src/styles/views/PostPageView.styl:
--------------------------------------------------------------------------------
1 | @import "nib"
2 | @import "../common"
3 |
4 | .post
5 | padding-bottom: 40px
6 |
7 | .post__header
8 | padding: padding-medium
9 |
10 | .post__title
11 | margin: 0
12 | font-size: 1.5em
13 |
14 | .post__content
15 | padding: 0 padding-medium
16 |
--------------------------------------------------------------------------------
/src/styles/views/SearchPostPageView.styl:
--------------------------------------------------------------------------------
1 | @import "nib"
2 | @import "../common"
3 |
4 | .search-container__input
5 | background: none
6 | width: 100%
7 | border: none
8 | outline: none
9 | padding: padding-medium
10 | font-size: 1.5em
11 | border-bottom: 1px solid color-divider
12 | color: inherit
13 |
--------------------------------------------------------------------------------
/src/utils/createReducer.js:
--------------------------------------------------------------------------------
1 | export default function createReducer (initialState, actionHandlers) {
2 | return (state = initialState, action) => {
3 | const reduceFn = actionHandlers[action.type];
4 | if (reduceFn) {
5 | return reduceFn(state, action);
6 | } else {
7 | return state;
8 | }
9 | };
10 | }
11 |
--------------------------------------------------------------------------------
/src/utils/shouldImmutableComponentUpdate.js:
--------------------------------------------------------------------------------
1 | import shallowEqualImmutable from "react-immutable-render-mixin/shallowEqualImmutable";
2 | import warning from "warning";
3 |
4 | export default function shouldImmutableComponentUpdate(nextProps, nextState) {
5 | const shouldUpdate = !shallowEqualImmutable(this.props, nextProps) ||
6 | !shallowEqualImmutable(this.state, nextState);
7 |
8 | warning(!shouldUpdate, `${this.constructor.name}#shouldImmutableComponentUpdate fails to shortcut the changes.`);
9 |
10 | return shouldUpdate;
11 | }
12 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | require("babel-core/register");
2 |
3 | module.exports = require("./webpack/prod.config").default;
4 |
--------------------------------------------------------------------------------
/webpack/config.js:
--------------------------------------------------------------------------------
1 | import {join as joinPath} from "path";
2 | import webpack from "webpack";
3 |
4 | import fakeDataProvider from "./utils/fake-data-provider";
5 |
6 | const resolve = {
7 | extensions: ["", ".js", ".jsx", ".json"],
8 | };
9 |
10 | const modulePreLoaders = [
11 | {
12 | test: /\.jsx?$/,
13 | exclude: /node_modules/,
14 | loaders: ["eslint"],
15 | },
16 | ];
17 |
18 | export const client = {
19 | entry: {
20 | main: [
21 | "./src/client",
22 | ],
23 | vendor: ["react", "react-router", "immutable"],
24 | },
25 | output: {
26 | path: joinPath(__dirname, "../public/build"),
27 | filename: "[name].js",
28 | chunkFilename: "[name].js",
29 | publicPath: "/build/",
30 | },
31 | resolve: resolve,
32 | module: {
33 | preLoaders: modulePreLoaders,
34 | loaders: [
35 | {
36 | test: /\.json$/,
37 | loader: "json",
38 | },
39 | ],
40 | },
41 | plugins: [
42 | new webpack.EnvironmentPlugin([
43 | "NODE_ENV",
44 | ]),
45 | new webpack.DefinePlugin({
46 | "typeof window": JSON.stringify("object"),
47 | }),
48 | fakeDataProvider,
49 | ],
50 | progress: true,
51 | stylus: {
52 | use: [
53 | require("nib")(),
54 | ],
55 | },
56 | postcss: [
57 | require("autoprefixer-core")({
58 | browsers: "> 5%",
59 | }),
60 | require("cssnano")(),
61 | ],
62 | };
63 |
64 | const externals = Object.keys(
65 | require("../package.json").dependencies
66 | )
67 | .map(key => new RegExp(`^${ key }`));
68 |
69 | export const server = {
70 | entry: {
71 | main: [
72 | "./src/server",
73 | ],
74 | },
75 | target: "node",
76 | output: {
77 | library: true,
78 | libraryTarget: "commonjs2",
79 | path: joinPath(__dirname, "../server/build"),
80 | filename: "[name].js",
81 | chunkFilename: "[name].js",
82 | },
83 | resolve: resolve,
84 | externals: externals,
85 | module: {
86 | preLoaders: modulePreLoaders,
87 | loaders: [
88 | {
89 | test: /\.woff2?(\?v=\d+\.\d+\.\d+)?$/,
90 | loader: "null",
91 | },
92 | {
93 | test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/,
94 | loader: "null",
95 | },
96 | {
97 | test: /\.eot(\?v=\d+\.\d+\.\d+)?$/,
98 | loader: "null",
99 | },
100 | {
101 | test: /\.svg(\?v=\d+\.\d+\.\d+)?$/,
102 | loader: "null",
103 | },
104 | {
105 | test: /\.json$/,
106 | loader: "json",
107 | },
108 | ],
109 | },
110 | plugins: [
111 | new webpack.EnvironmentPlugin([
112 | "NODE_ENV",
113 | ]),
114 | new webpack.DefinePlugin({
115 | "typeof window": JSON.stringify(undefined),
116 | }),
117 | fakeDataProvider,
118 | ],
119 | };
120 |
121 |
122 |
--------------------------------------------------------------------------------
/webpack/dev.config.js:
--------------------------------------------------------------------------------
1 | import merge from "lodash/object/merge";
2 | import webpack from "webpack";
3 | import * as config from "./config";
4 | import writeStats from "./utils/write-stats";
5 | import notifyStats from "./utils/notify-stats";
6 |
7 | export const client = merge({}, config.client, {
8 | devtool: "eval",
9 | entry: {
10 | main: [].concat([
11 | `webpack-dev-server/client?http://localhost:8080`,
12 | "webpack/hot/only-dev-server",
13 | ], config.client.entry.main),
14 | },
15 | output: {
16 | pathinfo: true,
17 | publicPath: `http://localhost:8080/build/`,
18 | },
19 | module: {
20 | loaders: config.client.module.loaders.concat([
21 | {
22 | test: /\.jsx?$/,
23 | loader: "react-hot!babel",
24 | exclude: /node_modules/,
25 | },
26 | {
27 | test: /\.css$/,
28 | loader: "style!css!postcss",
29 | },
30 | {
31 | test: /\.styl$/,
32 | loader: "style!css!postcss!stylus",
33 | },
34 | ]),
35 | },
36 | plugins: config.client.plugins.concat([
37 | // hot reload
38 | new webpack.HotModuleReplacementPlugin(),
39 | new webpack.NoErrorsPlugin(),
40 |
41 | // optimize
42 | new webpack.optimize.CommonsChunkPlugin("vendor", "vendor.js"),
43 | new webpack.optimize.DedupePlugin(),
44 | new webpack.optimize.OccurenceOrderPlugin(),
45 |
46 | // stats
47 | function() {
48 | this.plugin("done", writeStats);
49 | this.plugin("done", notifyStats);
50 | },
51 | ]),
52 | });
53 |
54 | export const server = merge({}, config.server, {
55 | devtool: "sourcemap",
56 | output: {
57 | pathinfo: true,
58 | },
59 | module: {
60 | loaders: config.server.module.loaders.concat([
61 | {
62 | test: /\.jsx?$/,
63 | loader: "babel",
64 | exclude: /node_modules/,
65 | },
66 | {
67 | test: /\.css$/,
68 | loader: "null",
69 | },
70 | {
71 | test: /\.styl$/,
72 | loader: "null",
73 | },
74 | ]),
75 | },
76 | plugins: config.server.plugins.concat([
77 | new webpack.NoErrorsPlugin(),
78 |
79 | // optimize
80 | new webpack.optimize.DedupePlugin(),
81 | new webpack.optimize.OccurenceOrderPlugin(),
82 |
83 | // stats
84 | function() {
85 | this.plugin("done", notifyStats);
86 | },
87 | ]),
88 | });
89 |
--------------------------------------------------------------------------------
/webpack/index.js:
--------------------------------------------------------------------------------
1 | import webpack from "webpack";
2 | import WebpackDevServer from "webpack-dev-server";
3 | import path from "path";
4 | import {client, server} from "./dev.config";
5 |
6 | new WebpackDevServer(webpack(client), {
7 | contentBase: path.join(__dirname, "../public"),
8 | publicPath: client.output.publicPath,
9 | hot: true,
10 | historyApiFallback: true,
11 | quiet: true,
12 | stats: {
13 | colors: true,
14 | },
15 | headers: {
16 | "Access-Control-Allow-Origin": "*",
17 | },
18 | }).listen(8080, (err) => {
19 | if (err) { throw err; }
20 | console.log("webpack-dev-server listening at port 8080");
21 | });
22 |
23 | webpack(server).watch({
24 | }, (err) => {
25 | if (err) { throw err; }
26 | console.log("webpack for server bundle is watching");
27 | });
28 |
--------------------------------------------------------------------------------
/webpack/prod.config.js:
--------------------------------------------------------------------------------
1 | import merge from "lodash/object/merge";
2 | import webpack from "webpack";
3 | import ExtractTextPlugin from "extract-text-webpack-plugin";
4 | import * as config from "./config";
5 | import writeStats from "./utils/write-stats";
6 |
7 | export const client = merge({}, config.client, {
8 | devtool: "source-map",
9 | output: {
10 | filename: "[name]-[hash:8].js",
11 | chunkFilename: "[name]-[chunkhash:8].js",
12 | },
13 | module: {
14 | loaders: config.client.module.loaders.concat([
15 | {
16 | test: /\.jsx?$/,
17 | loaders: ["babel"],
18 | exclude: /node_modules/,
19 | },
20 | {
21 | test: /\.css$/,
22 | loader: ExtractTextPlugin.extract("style", "css!postcss"),
23 | },
24 | {
25 | test: /\.styl$/,
26 | loader: ExtractTextPlugin.extract("style", "css!postcss!stylus"),
27 | },
28 | ]),
29 | },
30 | plugins: config.client.plugins.concat([
31 | // extract css files
32 | new ExtractTextPlugin("[name]-[contenthash:8].css", {
33 | allChunks: true,
34 | }),
35 |
36 | // optimize
37 | new webpack.optimize.CommonsChunkPlugin("vendor", "vendor-[hash:8].js"),
38 | new webpack.optimize.DedupePlugin(),
39 | new webpack.optimize.OccurenceOrderPlugin(),
40 | new webpack.optimize.UglifyJsPlugin({
41 | compress: {
42 | warnings: false,
43 | },
44 | }),
45 | // stats
46 | function() {
47 | this.plugin("done", writeStats);
48 | },
49 | ]),
50 | });
51 |
52 | export const server = merge({}, config.server, {
53 | output: {
54 | pathinfo: true,
55 | },
56 | module: {
57 | loaders: config.server.module.loaders.concat([
58 | {
59 | test: /\.jsx?$/,
60 | loader: "babel",
61 | exclude: /node_modules/,
62 | },
63 | {
64 | test: /\.css$/,
65 | loader: "null",
66 | },
67 | {
68 | test: /\.styl$/,
69 | loader: "null",
70 | },
71 | ]),
72 | },
73 | plugins: config.server.plugins.concat([
74 | new webpack.NoErrorsPlugin(),
75 |
76 | // optimize
77 | new webpack.optimize.DedupePlugin(),
78 | new webpack.optimize.OccurenceOrderPlugin(),
79 | new webpack.optimize.UglifyJsPlugin({
80 | compress: {
81 | warnings: false,
82 | },
83 | }),
84 | ]),
85 | });
86 |
87 | export default [
88 | client,
89 | server,
90 | ];
91 |
--------------------------------------------------------------------------------
/webpack/utils/fake-data-provider.js:
--------------------------------------------------------------------------------
1 | import webpack from "webpack";
2 | import faker from "faker";
3 |
4 | const fakeForumList = [
5 | {
6 | id: faker.random.uuid(),
7 | title: faker.name.title(),
8 | },
9 | {
10 | id: faker.random.uuid(),
11 | title: faker.name.title(),
12 | },
13 | {
14 | id: faker.random.uuid(),
15 | title: faker.name.title(),
16 | },
17 | {
18 | id: faker.random.uuid(),
19 | title: faker.name.title(),
20 | },
21 | {
22 | id: faker.random.uuid(),
23 | title: faker.name.title(),
24 | },
25 | {
26 | id: faker.random.uuid(),
27 | title: faker.name.title(),
28 | },
29 | {
30 | id: faker.random.uuid(),
31 | title: faker.name.title(),
32 | },
33 | {
34 | id: faker.random.uuid(),
35 | title: faker.name.title(),
36 | },
37 | {
38 | id: faker.random.uuid(),
39 | title: faker.name.title(),
40 | },
41 | ];
42 |
43 | const fakePostList = [
44 | {
45 | id: faker.random.uuid(),
46 | title: faker.name.title(),
47 | shortContent: faker.lorem.paragraph(),
48 | },
49 | {
50 | id: faker.random.uuid(),
51 | title: faker.name.title(),
52 | shortContent: faker.lorem.paragraph(),
53 | },
54 | {
55 | id: faker.random.uuid(),
56 | title: faker.name.title(),
57 | shortContent: faker.lorem.paragraph(),
58 | },
59 | {
60 | id: faker.random.uuid(),
61 | title: faker.name.title(),
62 | shortContent: faker.lorem.paragraph(),
63 | },
64 | {
65 | id: faker.random.uuid(),
66 | title: faker.name.title(),
67 | shortContent: faker.lorem.paragraph(),
68 | },
69 | ];
70 |
71 | const fakePostSearchList = [
72 | {
73 | id: faker.random.uuid(),
74 | title: faker.name.title(),
75 | shortContent: faker.lorem.paragraph(),
76 | },
77 | {
78 | id: faker.random.uuid(),
79 | title: faker.name.title(),
80 | shortContent: faker.lorem.paragraph(),
81 | },
82 | {
83 | id: faker.random.uuid(),
84 | title: faker.name.title(),
85 | shortContent: faker.lorem.paragraph(),
86 | },
87 | {
88 | id: faker.random.uuid(),
89 | title: faker.name.title(),
90 | shortContent: faker.lorem.paragraph(),
91 | },
92 | {
93 | id: faker.random.uuid(),
94 | title: faker.name.title(),
95 | shortContent: faker.lorem.paragraph(),
96 | },
97 | {
98 | id: faker.random.uuid(),
99 | title: faker.name.title(),
100 | shortContent: faker.lorem.paragraph(),
101 | },
102 | {
103 | id: faker.random.uuid(),
104 | title: faker.name.title(),
105 | shortContent: faker.lorem.paragraph(),
106 | },
107 | {
108 | id: faker.random.uuid(),
109 | title: faker.name.title(),
110 | shortContent: faker.lorem.paragraph(),
111 | },
112 | {
113 | id: faker.random.uuid(),
114 | title: faker.name.title(),
115 | shortContent: faker.lorem.paragraph(),
116 | },
117 | {
118 | id: faker.random.uuid(),
119 | title: faker.name.title(),
120 | shortContent: faker.lorem.paragraph(),
121 | },
122 | {
123 | id: faker.random.uuid(),
124 | title: faker.name.title(),
125 | shortContent: faker.lorem.paragraph(),
126 | },
127 | {
128 | id: faker.random.uuid(),
129 | title: faker.name.title(),
130 | shortContent: faker.lorem.paragraph(),
131 | },
132 | {
133 | id: faker.random.uuid(),
134 | title: faker.name.title(),
135 | shortContent: faker.lorem.paragraph(),
136 | },
137 | {
138 | id: faker.random.uuid(),
139 | title: faker.name.title(),
140 | shortContent: faker.lorem.paragraph(),
141 | },
142 | {
143 | id: faker.random.uuid(),
144 | title: faker.name.title(),
145 | shortContent: faker.lorem.paragraph(),
146 | },
147 | ];
148 |
149 | // This is a hack so that in both server side and client side,
150 | // we will have the same data
151 | export default new webpack.DefinePlugin({
152 | "global.fakeForumList": JSON.stringify(fakeForumList),
153 | "global.fakePostList": JSON.stringify(fakePostList),
154 | "global.fakePostSearchList": JSON.stringify(fakePostSearchList),
155 | });
156 |
--------------------------------------------------------------------------------
/webpack/utils/mkdirs.js:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import pathFn from "path";
3 |
4 | function mkdirs(path) {
5 | if (fs.existsSync(path)) { return; }
6 |
7 | let parent = pathFn.dirname(path);
8 |
9 | if (!fs.existsSync(parent)) {
10 | mkdirs(parent);
11 | }
12 |
13 | fs.mkdirSync(path);
14 | }
15 |
16 | export default mkdirs;
17 |
--------------------------------------------------------------------------------
/webpack/utils/notify-stats.js:
--------------------------------------------------------------------------------
1 | function notifyError(err) {
2 | console.log("\x07" + err);
3 | }
4 |
5 | function notifyWarning(warn) {
6 | console.log(warn);
7 | }
8 |
9 | function notifyStats(stats) {
10 | var json = stats.toJson();
11 |
12 | if (json.errors.length) {
13 | notifyError(json.errors[0]);
14 | } else if (json.warnings.length) {
15 | json.warnings.forEach(notifyWarning);
16 | } else {
17 | console.log(stats.toString({
18 | chunks: false,
19 | colors: true,
20 | }));
21 | }
22 | }
23 |
24 | export default notifyStats;
25 |
--------------------------------------------------------------------------------
/webpack/utils/write-stats.js:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import pathFn from "path";
3 | import mkdirs from "./mkdirs";
4 |
5 | const STATS_FILENAME = "webpack-stats.json";
6 |
7 | function writeStats(stats) {
8 | let publicPath = this.options.output.publicPath;
9 | let json = stats.toJson();
10 | let chunks = json.assetsByChunkName;
11 | let content = {};
12 |
13 | Object.keys(chunks).forEach(key => {
14 | let assets = chunks[key];
15 | if (!Array.isArray(assets)) { assets = [assets]; }
16 |
17 | let chunkContent = {};
18 |
19 | assets.forEach(asset => {
20 | let extname = pathFn.extname(asset).substring(1);
21 |
22 | if (!chunkContent.hasOwnProperty(extname)) {
23 | chunkContent[extname] = [];
24 | }
25 |
26 | chunkContent[extname].push(publicPath + asset);
27 | });
28 |
29 | content[key] = chunkContent;
30 | });
31 |
32 | mkdirs(this.options.output.path);
33 | fs.writeFileSync(pathFn.join(this.options.output.path, STATS_FILENAME), JSON.stringify(content));
34 | }
35 |
36 | module.exports = writeStats;
37 |
--------------------------------------------------------------------------------