├── .eslintrc ├── .vscode └── settings.json ├── client ├── views │ ├── user │ │ ├── styles │ │ │ ├── bg.jpg │ │ │ ├── login-style.js │ │ │ ├── user-info-style.js │ │ │ └── user-style.js │ │ ├── user.jsx │ │ ├── login.jsx │ │ └── info.jsx │ ├── topic-create │ │ ├── styles.js │ │ └── index.jsx │ ├── App.jsx │ ├── layout │ │ ├── container.jsx │ │ └── app-bar.jsx │ ├── topic-detail │ │ ├── reply.jsx │ │ ├── styles.js │ │ └── index.jsx │ └── topic-list │ │ ├── styles.js │ │ └── index.jsx ├── util │ ├── date-format.js │ ├── variable-define.js │ └── http.js ├── store │ ├── store.js │ ├── app-state.js │ └── topic-store.js ├── template.html ├── .eslintrc ├── server.template.ejs ├── server-entry.js ├── config │ └── router.jsx └── app.js ├── .babelrc ├── process.yml ├── .editorconfig ├── app.json ├── nodemon.json ├── server ├── util │ ├── handle-login.js │ ├── proxy.js │ ├── server-render.js │ └── dev-static.js └── server.js ├── .gitignore ├── package.json └── README.md /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard" 3 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "git.ignoreLimitWarning": true 3 | } -------------------------------------------------------------------------------- /client/views/user/styles/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fridaydream/react-cnode-ssr/HEAD/client/views/user/styles/bg.jpg -------------------------------------------------------------------------------- /client/util/date-format.js: -------------------------------------------------------------------------------- 1 | import dateFormat from 'dateformat' 2 | 3 | export default (date, format) => { 4 | return dateFormat(date, format) 5 | } 6 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["es2015", { "loose": true}], 4 | "stage-1", 5 | "react" 6 | ], 7 | "plugins": [ 8 | "transform-decorators-legacy" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /process.yml: -------------------------------------------------------------------------------- 1 | apps: 2 | - script: ./server/server.js 3 | name: cpnode 4 | env: 5 | NODE_ENV: production 6 | env_production: 7 | NODE_ENV: production 8 | HOST: localhost 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "my_web", 5 | "script": "./server/server.js", 6 | "instances": 1, 7 | "exec_mode": "cluster", 8 | "env": { 9 | "NODE_ENV": "production" 10 | } 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "restartable": "rs", 3 | "ignore": [ 4 | ".git", 5 | "node_modules/**/node_modules", 6 | ".eslintrc", 7 | "client", 8 | "build" 9 | ], 10 | "env": { 11 | "NODE_ENV": "development" 12 | }, 13 | "verbose": true, 14 | "ext": ".js" 15 | } 16 | -------------------------------------------------------------------------------- /client/store/store.js: -------------------------------------------------------------------------------- 1 | import AppState from './app-state' 2 | import TopicStore from './topic-store' 3 | 4 | export { AppState, TopicStore } 5 | 6 | export default { 7 | AppState, 8 | TopicStore 9 | } 10 | 11 | export const createStoreMap = () => ({ 12 | appState: new AppState(), 13 | topicStore: new TopicStore() 14 | }) 15 | -------------------------------------------------------------------------------- /client/views/user/styles/login-style.js: -------------------------------------------------------------------------------- 1 | const inputWidth = 300 2 | 3 | export default () => { 4 | return { 5 | root: { 6 | padding: '60px 20px', 7 | display: 'flex', 8 | flexDirection: 'column', 9 | alignItems: 'center' 10 | }, 11 | input: { 12 | width: inputWidth, 13 | marginBottom: 20 14 | }, 15 | loginButton: { 16 | width: inputWidth 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /client/views/topic-create/styles.js: -------------------------------------------------------------------------------- 1 | export default { 2 | root: { 3 | padding: 20, 4 | position: 'relative' 5 | }, 6 | title: { 7 | marginBottom: 20 8 | }, 9 | selectItem: { 10 | display: 'inline-flex', 11 | alignItems: 'center' 12 | }, 13 | replyButton: { 14 | position: 'absolute', 15 | right: 30, 16 | bottom: 20, 17 | opacity: '.3', 18 | '&:hover': { 19 | opacity: '1' 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/views/user/styles/user-info-style.js: -------------------------------------------------------------------------------- 1 | export default (theme) => { 2 | return { 3 | root: { 4 | padding: 16, 5 | minHeight: 400, 6 | }, 7 | gridContainer: { 8 | height: '100%', 9 | }, 10 | paper: { 11 | height: '100%', 12 | }, 13 | partTitle: { 14 | lineHeight: '40px', 15 | paddingLeft: 20, 16 | backgroundColor: theme.palette.primary[700], 17 | color: '#fff', 18 | }, 19 | '@media screen and (max-width: 480px)': { 20 | root: { 21 | padding: 10, 22 | minHeight: 300, 23 | }, 24 | }, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/views/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | withRouter, 4 | } from 'react-router-dom' 5 | import PropTypes from 'prop-types' 6 | import Routes from '../config/router' 7 | import AppBar from './layout/app-bar' 8 | 9 | class App extends React.Component { 10 | componentDidMount() { 11 | // do something here 12 | 13 | } 14 | 15 | render() { 16 | return [ 17 | , 18 | 19 | ] 20 | } 21 | } 22 | 23 | App.propTypes = { 24 | location: PropTypes.object.isRequired, 25 | } 26 | 27 | export default withRouter(App) 28 | -------------------------------------------------------------------------------- /client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "es6": true, 6 | "node": true 7 | }, 8 | "parserOptions": { 9 | "ecmaVersion": 6, 10 | "sourceType": "module" 11 | }, 12 | "extends": "airbnb", 13 | "rules": { 14 | "semi": [0], 15 | "react/jsx-filename-extension": [0], 16 | "comma-dangle": [0], 17 | "react/destructuring-assignment": [0], 18 | "react/require-default-props": [0], 19 | "react/forbid-prop-types": [0], 20 | "no-param-reassign": [0], 21 | "arrow-body-style": [0], 22 | "react/sort-comp": [0], 23 | "react/no-danger": [0] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/server.template.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%%- meta %> 8 | <%%- title %> 9 | 10 | <%%- link %> 11 | <%%- style %> 12 | 13 | 16 | 17 | 18 |
19 | <%%- appString %> 20 |
21 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /client/views/layout/container.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Paper from '@material-ui/core/Paper' 4 | import { withStyles } from '@material-ui/core/styles' 5 | 6 | const styles = { 7 | root: { 8 | margin: 24, 9 | marginTop: 80 10 | } 11 | } 12 | const Container = ({ classes, children }) => ( 13 | 14 | {children} 15 | 16 | ) 17 | 18 | Container.propTypes = { 19 | classes: PropTypes.object.isRequired, 20 | children: PropTypes.oneOfType([ 21 | PropTypes.arrayOf(PropTypes.element), 22 | PropTypes.element 23 | ]) 24 | } 25 | 26 | export default withStyles(styles)(Container) 27 | -------------------------------------------------------------------------------- /client/util/variable-define.js: -------------------------------------------------------------------------------- 1 | export const tabs = { 2 | all: '全部', 3 | share: '分享', 4 | job: '工作', 5 | ask: '问答', 6 | good: '精品', 7 | dev: '测试', 8 | } 9 | 10 | export const topicSchema = { 11 | id: '', 12 | author_id: '', 13 | tab: '', 14 | content: '', 15 | title: '', 16 | last_reply_at: '', 17 | good: false, 18 | top: false, 19 | reply_count: 0, 20 | visit_count: 0, 21 | create_at: '', 22 | is_collect: '', 23 | author: { 24 | loginname: '', 25 | avatar_url: '' 26 | }, 27 | replies: [] 28 | } 29 | 30 | export const replySchema = { 31 | id: '', 32 | author: { 33 | loginname: '', 34 | avatar_url: '' 35 | }, 36 | content: '', 37 | ups: [], 38 | create_at: '', 39 | reply_id: null, 40 | is_uped: false 41 | } 42 | -------------------------------------------------------------------------------- /client/views/topic-detail/reply.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types'; 3 | import Avatar from '@material-ui/core/Avatar' 4 | 5 | import { withStyles } from '@material-ui/core/styles' 6 | import marked from 'marked' 7 | import dateFormat from 'dateformat' 8 | import { replyStyle } from './styles' 9 | 10 | 11 | const Reply = ({ reply, classes }) => ( 12 |
13 |
14 | 15 |
16 |
17 | {`${reply.author.loginname} ${dateFormat(reply.create_at, 'yy-mm-dd')}`} 18 |

19 |

20 |
21 | ) 22 | 23 | Reply.propTypes = { 24 | reply: PropTypes.object.isRequired, 25 | classes: PropTypes.object.isRequired 26 | } 27 | 28 | export default withStyles(replyStyle)(Reply) 29 | -------------------------------------------------------------------------------- /client/views/user/styles/user-style.js: -------------------------------------------------------------------------------- 1 | import avatarBg from './bg.jpg' 2 | 3 | export default () => { 4 | return { 5 | root: {}, 6 | avatar: { 7 | position: 'relative', 8 | display: 'flex', 9 | flexDirection: 'column', 10 | alignItems: 'center', 11 | justifyContent: 'space-between', 12 | padding: 20, 13 | paddingTop: 60, 14 | paddingBottom: 40 15 | }, 16 | avatarImg: { 17 | width: 80, 18 | height: 80, 19 | marginBottom: 20 20 | }, 21 | userName: { 22 | color: '#fff', 23 | zIndex: '1' 24 | }, 25 | bg: { 26 | backgroundImage: `url(${avatarBg})`, 27 | backgroundSize: 'cover', 28 | position: 'absolute', 29 | left: 0, 30 | right: 0, 31 | top: 0, 32 | bottom: 0, 33 | '&::after': { 34 | content: '\' \'', 35 | position: 'absolute', 36 | left: 0, 37 | right: 0, 38 | top: 0, 39 | bottom: 0, 40 | backgroundColor: 'rgba(0,0,0,.6)' 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /server/util/handle-login.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const baseUrl = 'https://cnodejs.org/api/v1' //https,经测试 http 会报404, 坑 3 | const qs = require('qs') 4 | module.exports = async (ctx, next) => { 5 | try { 6 | console.log('url', `${baseUrl}/accesstoken`,qs.stringify(ctx.request.body.accessToken)) 7 | const resp = await axios.post(`${baseUrl}/accesstoken`, qs.stringify({ 8 | accesstoken: ctx.request.body.accessToken 9 | }), { 10 | headers: { 11 | 'Content-Type': 'application/x-www-form-urlencoded' 12 | } 13 | }) 14 | if (resp.status === 200 && resp.data.success) { 15 | console.log('okkkk', ctx.request.body.accessToken) 16 | ctx.session = { 17 | user: { 18 | accesstoken: ctx.request.body.accessToken, 19 | loginname: resp.data.loginname, 20 | id: resp.data.id, 21 | avatarurl: resp.data.avatar_url 22 | } 23 | } 24 | ctx.body = { 25 | success: true, 26 | data: resp.data 27 | } 28 | } 29 | }catch(err) { 30 | ctx.body = { 31 | success: false, 32 | data: err.response.data 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/util/http.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const baseUrl = process.env.API_BASE || '' 4 | 5 | const parseUrl = (url, params) => { 6 | params = params || {} 7 | const str = Object.keys(params).reduce((result, key) => { 8 | result += `${key}=${params[key]}&` 9 | return result 10 | }, '') 11 | return `${baseUrl}/api${url}?${str.substr(0, str.length - 1)}` 12 | } 13 | 14 | export const get = (url, params) => ( 15 | new Promise((resolve, reject) => { 16 | axios.get(parseUrl(url, params)) 17 | .then((resp) => { 18 | const { data } = resp 19 | if (data && data.success === true) { 20 | resolve(data) 21 | } else { 22 | reject(data) 23 | } 24 | }).catch(reject) 25 | }) 26 | ) 27 | 28 | export const post = (url, params, requestData) => ( 29 | new Promise((resolve, reject) => { 30 | axios.post(parseUrl(url, params), requestData) 31 | .then((resp) => { 32 | const { data } = resp 33 | if (data && data.success === true) { 34 | resolve(data) 35 | } else { 36 | reject(data) 37 | } 38 | }).catch(reject) 39 | }) 40 | ) 41 | -------------------------------------------------------------------------------- /client/server-entry.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { StaticRouter } from 'react-router-dom' 3 | import { Provider, useStaticRendering } from 'mobx-react' 4 | 5 | import { JssProvider } from 'react-jss' 6 | import { 7 | MuiThemeProvider, 8 | } from '@material-ui/core/styles'; 9 | import App from './views/App' 10 | 11 | import { createStoreMap } from './store/store' 12 | 13 | // 让mobx在服务端渲染的时候不会重复数据变换 14 | useStaticRendering(true) // 使用静态的渲染 15 | 16 | // {appStore: xxx} 17 | // stores, routerContext, sheetsRegistry, generateClassName, theme, ctx.request.url 18 | export default (stores, routerContext, sheetsRegistry, generateClassName, theme, url) => { 19 | // jss.options.createGenerateClassName = createGenerateClassName 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ) 31 | } 32 | 33 | export { createStoreMap } 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | .idea 61 | package-lock.json 62 | 63 | ghpages 64 | .DS_Store 65 | 66 | dist 67 | -------------------------------------------------------------------------------- /client/views/topic-list/styles.js: -------------------------------------------------------------------------------- 1 | export const topicPrimaryStyle = (theme) => { 2 | return { 3 | root: { 4 | display: 'flex', 5 | alignItems: 'center', 6 | }, 7 | title: { 8 | textDecoration: 'none', 9 | color: '#555', 10 | }, 11 | tab: { 12 | backgroundColor: theme.palette.primary[500], 13 | textAlign: 'center', 14 | display: 'inline-block', 15 | padding: '0 6px', 16 | color: '#fff', 17 | borderRadius: 3, 18 | marginRight: 10, 19 | fontSize: '12px', 20 | flexShrink: 0, 21 | }, 22 | good: { 23 | backgroundColor: theme.palette.accent[600], 24 | }, 25 | top: { 26 | backgroundColor: theme.palette.accent[200], 27 | }, 28 | } 29 | } 30 | 31 | export const topicSecondaryStyle = (theme) => { 32 | return { 33 | root: { 34 | display: 'flex', 35 | alignItems: 'center', 36 | paddingTop: 3, 37 | flexWrap: 'wrap', 38 | }, 39 | count: { 40 | textAlign: 'center', 41 | marginRight: 20, 42 | }, 43 | userName: { 44 | marginRight: 20, 45 | color: '#9e9e9e', 46 | }, 47 | accentColor: { 48 | color: theme.palette.accent[500], 49 | }, 50 | } 51 | } 52 | 53 | export const topicListStyle = () => { 54 | return { 55 | root: { 56 | margin: 24, 57 | marginTop: 80, 58 | }, 59 | loading: { 60 | display: 'flex', 61 | justifyContent: 'space-around', 62 | }, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /client/config/router.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | Route, 4 | Redirect, 5 | withRouter 6 | } from 'react-router-dom' 7 | 8 | import PropTypes from 'prop-types' 9 | 10 | import { 11 | inject, 12 | observer, 13 | } from 'mobx-react' 14 | import TopicList from '../views/topic-list' 15 | import TopicDetail from '../views/topic-detail' 16 | import UserLogin from '../views/user/login' 17 | import UserInfo from '../views/user/info' 18 | import TopicCreate from '../views/topic-create' 19 | 20 | const PrivateRoute = ({ isLogin, component: Component, ...rest }) => ( 21 | ( 24 | isLogin ? ( 25 | 26 | ) : ( 27 | 30 | ) 31 | )} 32 | /> 33 | ) 34 | 35 | const InjectedPrivateRoute = withRouter(inject((stores) => { 36 | return { 37 | isLogin: stores.appState.user.isLogin 38 | } 39 | })(observer(PrivateRoute))) 40 | 41 | PrivateRoute.propTypes = { 42 | isLogin: PropTypes.bool, 43 | component: PropTypes.element.isRequired 44 | } 45 | 46 | PrivateRoute.defaultProps = { 47 | isLogin: false 48 | } 49 | 50 | export default () => [ 51 | } key="home" />, 52 | , 53 | , 54 | , 55 | , 56 | 57 | ] 58 | -------------------------------------------------------------------------------- /client/views/user/user.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { 4 | inject, 5 | observer, 6 | } from 'mobx-react' 7 | 8 | import Avatar from '@material-ui/core/Avatar' 9 | import { withStyles } from '@material-ui/core/styles' 10 | 11 | import UserIcon from '@material-ui/icons/AccountCircle' 12 | 13 | import Container from '../layout/container' 14 | import userStyles from './styles/user-style' 15 | 16 | @inject((stores) => { 17 | return { 18 | user: stores.appState.user, 19 | } 20 | }) @observer 21 | class User extends React.Component { 22 | componentDidMount() { 23 | // do someting here 24 | } 25 | 26 | render() { 27 | const { classes } = this.props 28 | const { 29 | // isLogin, 30 | info 31 | } = this.props.user 32 | return ( 33 | 34 |
35 |
36 | { 37 | info.avatar_url 38 | ? ( 39 | 43 | ) 44 | : ( 45 | 46 | 47 | 48 | ) 49 | } 50 | {info.loginname || '未登录'} 51 |
52 | {this.props.children} 53 | 54 | ) 55 | } 56 | } 57 | 58 | User.wrappedComponent.propTypes = { 59 | user: PropTypes.object.isRequired, 60 | } 61 | 62 | User.propTypes = { 63 | classes: PropTypes.object.isRequired, 64 | children: PropTypes.element.isRequired, 65 | } 66 | 67 | export default withStyles(userStyles)(User) 68 | -------------------------------------------------------------------------------- /client/views/topic-detail/styles.js: -------------------------------------------------------------------------------- 1 | export const topicDetailStyle = (theme) => { 2 | return { 3 | header: { 4 | padding: 20, 5 | borderBottom: '1px solid #dfdfdf', 6 | '& h3': { 7 | margin: 0 8 | } 9 | }, 10 | body: { 11 | padding: 20, 12 | '& img': { 13 | maxWidth: '100%' 14 | }, 15 | '& ul, & ol': { 16 | paddingLeft: 30, 17 | '& li': { 18 | marginBottom: 7 19 | } 20 | } 21 | }, 22 | replyHeader: { 23 | padding: '10px 20px', 24 | backgroundColor: theme.palette.primary[500], 25 | color: '#fff', 26 | display: 'flex', 27 | justifyContent: 'space-between' 28 | }, 29 | replyBody: { 30 | padding: 20 31 | }, 32 | replies: { 33 | margin: '0 24px', 34 | marginBottom: 24 35 | }, 36 | notLoginButton: { 37 | textAlign: 'center', 38 | padding: '20px 0' 39 | }, 40 | '@media screen and (max-width: 480px)': { 41 | replies: { 42 | margin: '0 10px', 43 | marginBottom: 24 44 | } 45 | }, 46 | replyEditor: { 47 | position: 'relative', 48 | padding: 24, 49 | borderBottom: '1px solid #dfdfdf', 50 | '& .CodeMirror': { 51 | height: 150, 52 | minHeight: 'auto', 53 | '& .CodeMirror-scroll': { 54 | minHeight: 'auto' 55 | } 56 | } 57 | }, 58 | replyButton: { 59 | position: 'absolute', 60 | right: 40, 61 | bottom: 65, 62 | zIndex: 101 63 | } 64 | } 65 | } 66 | 67 | export const replyStyle = { 68 | root: { 69 | display: 'flex', 70 | alignItems: 'flex-start', 71 | padding: 20, 72 | paddingBottom: 0, 73 | borderBottom: '1px solid #dfdfdf' 74 | }, 75 | left: { 76 | marginRight: 20 77 | }, 78 | right: { 79 | '& img': { 80 | maxWidth: '100%', 81 | display: 'block' 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /server/util/proxy.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const qs = require('qs') 3 | const baseUrl = 'https://cnodejs.org/api/v1' 4 | 5 | function getRequest(baseUrl, path, ctx, query, data) { 6 | return new Promise((resolve, reject) => { 7 | axios(`${baseUrl}${path}`, { 8 | method: ctx.request.method, 9 | params: query, 10 | data: qs.stringify(data), 11 | headers: { 12 | 'Content-Type': 'application/x-www-form-urlencoded' 13 | } 14 | }).then((resp) => { 15 | resolve(resp) 16 | }).catch(reject) 17 | }) 18 | } 19 | module.exports = async (ctx, next) => { 20 | const path = ctx.request.path.slice(4) 21 | const user = ctx.session.user || {} 22 | console.log('user', user) 23 | const needAccessToken = ctx.request.query.needAccessToken 24 | if(needAccessToken && !user.accesstoken) { 25 | ctx.status = 401 26 | ctx.body = { 27 | success: false, 28 | msg: 'need login' 29 | } 30 | return 31 | } 32 | 33 | const query = Object.assign({}, ctx.request.query,{ 34 | accesstoken: (needAccessToken && ctx.request.method === 'GET') ? user.accesstoken : '' 35 | }) 36 | const data = Object.assign({}, ctx.request.body, { 37 | accesstoken: (needAccessToken && ctx.request.method === 'POST')? user.accesstoken : '' 38 | }) 39 | if (query.needAccessToken) delete query.needAccessToken; 40 | try { 41 | console.log(`${baseUrl}${path}`) 42 | let resp = await axios(`${baseUrl}${path}`, { 43 | method: ctx.request.method, 44 | params: query, 45 | data: qs.stringify(data), 46 | headers: { 47 | 'Content-Type': 'application/x-www-form-urlencoded' 48 | } 49 | }) 50 | if(resp.status === 200) { 51 | ctx.body = resp.data 52 | } else { 53 | ctx.status = resp.status 54 | ctx.body = resp.data 55 | } 56 | }catch(err){ 57 | if (err.response) { 58 | ctx.status = 500 59 | ctx.body = err.response.data 60 | } else { 61 | ctx.status = 500 62 | ctx.body = { 63 | success: false, 64 | msg: '未知错误' 65 | } 66 | } 67 | } 68 | } 69 | 70 | -------------------------------------------------------------------------------- /client/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { BrowserRouter } from 'react-router-dom' 4 | import { Provider } from 'mobx-react' 5 | // import JssProvider from 'react-jss/lib/JssProvider'; 6 | import { AppContainer } from 'react-hot-loader' // eslint-disable-line 7 | import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles' 8 | import { lightBlue, pink } from '@material-ui/core/colors' 9 | import App from './views/App' 10 | import { AppState, TopicStore } from './store/store' 11 | 12 | const theme = createMuiTheme({ 13 | palette: { 14 | primary: lightBlue, 15 | accent: pink, 16 | type: 'light' 17 | } 18 | }) 19 | 20 | const initialState = window.__INITIAL_STATE__ || {} // eslint-disable-line 21 | 22 | const createApp = (TheApp) => { 23 | class Main extends React.Component { 24 | // Remove the server-side injected CSS. 25 | componentDidMount() { 26 | const jssStyles = document.getElementById('jss-server-side'); 27 | if (jssStyles && jssStyles.parentNode) { 28 | console.log('remove') // eslint-disable-line 29 | jssStyles.parentNode.removeChild(jssStyles); 30 | } 31 | } 32 | 33 | render() { 34 | return 35 | } 36 | } 37 | return Main 38 | } 39 | 40 | const appState = new AppState() 41 | appState.init(initialState.appState) 42 | 43 | const topicStore = new TopicStore(initialState.topicStore) 44 | const root = document.getElementById('root') 45 | const render = (Component) => { 46 | // const hot = !!module.hot 47 | // const renderMethod = hot ? ReactDOM.render : ReactDOM.hydrate 48 | const renderMethod = ReactDOM.render // 因为服务端渲染 dangerouslySetInnerHTML 不显示 49 | renderMethod( 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | , 59 | root 60 | ) 61 | } 62 | render(createApp(App)) 63 | if (module.hot) { 64 | module.hot.accept('./views/App', () => { 65 | const NextApp = require('./views/App').default // eslint-disable-line 66 | render(createApp(NextApp)) 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /server/util/server-render.js: -------------------------------------------------------------------------------- 1 | const ejs = require('ejs') 2 | const serialize = require('serialize-javascript') 3 | // 引入异步处理包 4 | const bootstrapper = require('react-async-bootstrapper') 5 | const ReactDomServer = require('react-dom/server') 6 | const Helmet = require('react-helmet').default 7 | const SheetsRegistry = require('react-jss').SheetsRegistry; 8 | 9 | const createGenerateClassName = require('@material-ui/core/styles').createGenerateClassName 10 | const createMuiTheme = require('@material-ui/core/styles').createMuiTheme 11 | const colors = require('@material-ui/core/colors') 12 | 13 | const getStoreState = (stores) => { 14 | return Object.keys(stores).reduce((result, storeName) => { 15 | result[storeName] = stores[storeName].toJson() 16 | return result 17 | }, {}) 18 | } 19 | 20 | module.exports = async (bundle, template, ctx) => { 21 | const createStoreMap = bundle.createStoreMap 22 | const createApp = bundle.default 23 | const routerContext = {} 24 | const stores = createStoreMap() 25 | const user = ctx.session.user 26 | if (user) { 27 | stores.appState.user.info = user 28 | stores.appState.user.isLogin = true 29 | } 30 | // Create a sheetsRegistry instance. 31 | const sheetsRegistry = new SheetsRegistry(); 32 | // Create a theme instance. 33 | const theme = createMuiTheme({ 34 | palette: { 35 | primary: colors.lightBlue, 36 | accent: colors.pink, 37 | type: 'light', 38 | }, 39 | }); 40 | // Create a new class name generator. 41 | const generateClassName = createGenerateClassName(); 42 | const app = createApp(stores, routerContext, sheetsRegistry, generateClassName, theme, ctx.request.url) 43 | await bootstrapper(app) 44 | if(routerContext.url) { 45 | ctx.redirect(routerContext.url) 46 | return 47 | } 48 | const helmet = Helmet.rewind() 49 | const state = getStoreState(stores) 50 | const content = ReactDomServer.renderToString(app) 51 | const html = ejs.render(template, { 52 | appString: content, 53 | initialState: serialize(state), 54 | meta: helmet.meta.toString(), 55 | title: helmet.title.toString(), 56 | style: helmet.style.toString(), 57 | link: helmet.link.toString(), 58 | materialCss: sheetsRegistry.toString() 59 | }) 60 | ctx.body = html 61 | } 62 | 63 | -------------------------------------------------------------------------------- /client/store/app-state.js: -------------------------------------------------------------------------------- 1 | import { 2 | observable, 3 | action, 4 | toJS 5 | } from 'mobx' 6 | 7 | import { post, get } from '../util/http' 8 | 9 | export default class AppState { 10 | @observable user = { 11 | isLogin: false, 12 | info: {}, 13 | detail: { 14 | recentTopics: [], 15 | recentReplies: [], 16 | syncing: false 17 | }, 18 | collections: { 19 | syncing: false, 20 | list: [] 21 | } 22 | } 23 | 24 | init(user = {}) { 25 | if (user.user) { 26 | this.user = user 27 | } 28 | } 29 | 30 | @action login(accessToken) { 31 | return new Promise((resolve, reject) => { 32 | post('/user/login', {}, { 33 | accessToken 34 | }).then((resp) => { 35 | if (resp.success) { 36 | this.user.info = resp.data 37 | this.user.isLogin = true 38 | resolve() 39 | } else { 40 | reject(resp) 41 | } 42 | }).catch(reject) 43 | }) 44 | } 45 | 46 | @action getUserDetail() { 47 | this.user.detail.syncing = true 48 | return new Promise((resolve, reject) => { 49 | get(`/user/${this.user.info.loginname}`) 50 | .then((resp) => { 51 | if (resp.success) { 52 | this.user.detail.recentReplies = resp.data.recent_replies 53 | this.user.detail.recentTopics = resp.data.recent_topics 54 | resolve() 55 | } else { 56 | reject() 57 | } 58 | this.user.detail.syncing = false 59 | }).catch((err) => { 60 | this.user.detail.syncing = false 61 | reject(err) 62 | }) 63 | }) 64 | } 65 | 66 | @action getUserCollection() { 67 | this.user.collections.syncing = true 68 | return new Promise((resolve, reject) => { 69 | get(`/topic_collect/${this.user.info.loginname}`) 70 | .then((resp) => { 71 | if (resp.success) { 72 | this.user.collections.list = resp.data 73 | resolve() 74 | } else { 75 | reject() 76 | } 77 | this.user.collections.syncing = false 78 | }).catch((err) => { 79 | this.user.collections.syncing = false 80 | reject(err) 81 | }) 82 | }) 83 | } 84 | 85 | toJson() { 86 | return { 87 | user: toJS(this.user) 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /server/util/dev-static.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const webpack = require('webpack') 3 | const path = require('path') 4 | const MemoryFs = require('memory-fs') 5 | const proxy = require('http-proxy-middleware') 6 | const serverRender = require('./server-render') 7 | 8 | const serverConfig = require('../../build/webpack.config.server') 9 | 10 | const getTemplate = () => { 11 | return new Promise((resolve,reject) => { 12 | axios.get('http://localhost:8889/public/server.ejs') 13 | .then(res => { 14 | resolve(res.data) 15 | }) 16 | .catch(reject) 17 | }) 18 | } 19 | const mfs = new MemoryFs 20 | const NativeModule = require('module') 21 | const vm = require('vm') 22 | const getModuleFromString = (bundle, filename) => { 23 | const m = { exports: {} } 24 | const wrapper = NativeModule.wrap(bundle) 25 | const script = new vm.Script(wrapper, { 26 | filename, 27 | displayErrors: true 28 | }) 29 | const result = script.runInThisContext() 30 | result.call(m.exports, m.exports, require, m) 31 | return m 32 | } 33 | 34 | const serverCompiler = webpack(serverConfig) 35 | serverCompiler.outputFileSystem = mfs 36 | let serverBundle 37 | serverCompiler.watch({}, (err, stats) => { 38 | if(err) throw err; 39 | stats = stats.toJson() 40 | stats.errors.forEach(err => console.log(err)) 41 | stats.warnings.forEach(warn => console.log(warn)) 42 | const bundlePath = path.join( 43 | serverConfig.output.path, 44 | serverConfig.output.filename 45 | ) 46 | const bundle = mfs.readFileSync(bundlePath, 'utf8') 47 | 48 | const m = getModuleFromString(bundle, 'server-entry.js') 49 | serverBundle = m.exports 50 | }) 51 | 52 | module.exports = (app) => { 53 | app.use(async (ctx, next) => { 54 | if(ctx.url.startsWith('/public')) { // 以public开头的异步请求接口都会被转发 55 | ctx.respond = false // 必须 56 | return proxy({ 57 | target: 'http://localhost:8889/', // 服务器地址 58 | changeOrigin: true, 59 | secure: false 60 | })(ctx.req, ctx.res, next) 61 | } 62 | return next() 63 | }) 64 | 65 | app.use(async (ctx, next) => { 66 | try{ 67 | if (!serverBundle) { 68 | return ctx.body = 'waiting for compile, refresh later' 69 | } 70 | const template = await getTemplate() 71 | await serverRender(serverBundle, template, ctx) 72 | }catch(e) { 73 | await next() 74 | } 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /client/views/layout/app-bar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { withStyles } from '@material-ui/core/styles' 4 | import { 5 | inject, 6 | observer 7 | } from 'mobx-react' 8 | 9 | import AppBar from '@material-ui/core/AppBar' 10 | import ToolBar from '@material-ui/core/Toolbar' 11 | import Button from '@material-ui/core/Button' 12 | import Typography from '@material-ui/core/Typography' 13 | import IconButton from '@material-ui/core/IconButton' 14 | import HomeIcon from '@material-ui/icons/Home' 15 | 16 | const styles = { 17 | root: { 18 | width: '100%' 19 | }, 20 | flex: { 21 | flex: 1 22 | } 23 | } 24 | 25 | @inject((stores) => { 26 | return { 27 | appState: stores.appState 28 | } 29 | }) @observer 30 | class MainAppBar extends React.Component { 31 | static contextTypes = { 32 | router: PropTypes.object 33 | } 34 | 35 | constructor() { 36 | super() 37 | this.onHomeIconClick = this.onHomeIconClick.bind(this) 38 | this.createButtonClick = this.createButtonClick.bind(this) 39 | this.loginButtonClick = this.loginButtonClick.bind(this) 40 | } 41 | 42 | onHomeIconClick() { 43 | this.context.router.history.push('/list?tab=all') 44 | } 45 | 46 | createButtonClick() { 47 | this.context.router.history.push('/topic/create') 48 | } 49 | 50 | loginButtonClick() { 51 | if (this.props.appState.user.isLogin) { 52 | this.context.router.history.push('/user/info') 53 | } else { 54 | this.context.router.history.push('/user/login') 55 | } 56 | } 57 | 58 | render() { 59 | const { classes } = this.props 60 | const { 61 | user 62 | } = this.props.appState 63 | return ( 64 |
65 | 66 | 67 | 68 | 69 | 70 | 71 | CPNODE 72 | 73 | 76 | 81 | 82 | 83 |
84 | ) 85 | } 86 | } 87 | 88 | MainAppBar.wrappedComponent.propTypes = { 89 | appState: PropTypes.object.isRequired 90 | } 91 | 92 | MainAppBar.propTypes = { 93 | classes: PropTypes.object.isRequired 94 | } 95 | 96 | export default withStyles(styles)(MainAppBar) 97 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa') 2 | const fs = require('fs') 3 | const path = require('path') 4 | const mount = require('koa-mount'); 5 | const bodyParser = require('koa-bodyparser') 6 | const session = require('koa-session'); 7 | const serverRender = require('./util/server-render') 8 | const Router = require('koa-router'); 9 | const isDev = process.env.NODE_ENV === 'development' 10 | const app = new Koa() 11 | 12 | app.keys = ['some secret hurr']; 13 | const CONFIG = { 14 | key: 'koa:sess', /** (string) cookie key (default is koa:sess) */ 15 | /** (number || 'session') maxAge in ms (default is 1 days) */ 16 | /** 'session' will result in a cookie that expires when session/browser is closed */ 17 | /** Warning: If a session cookie is stolen, this cookie will never expire */ 18 | maxAge: 86400000, 19 | overwrite: true, /** (boolean) can overwrite or not (default true) */ 20 | httpOnly: true, /** (boolean) httpOnly or not (default true) */ 21 | signed: true, /** (boolean) signed or not (default true) */ 22 | rolling: false, /** (boolean) Force a session identifier cookie to be set on every response. The expiration is reset to the original maxAge, resetting the expiration countdown. (default is false) */ 23 | renew: false, /** (boolean) renew session when session is nearly expired, so we can always keep user logged in. (default is false)*/ 24 | }; 25 | 26 | app.use(session(CONFIG, app)); 27 | 28 | app.use(bodyParser()) 29 | const router = new Router({ 30 | prefix: '/api' 31 | }); 32 | 33 | // router.post('/user/login', require('./util/handle-login')) 34 | // // router.all('/user/:id?', require('./util/handle-login')) 35 | // router.all('/:t/:id?/:reply?', require('./util/proxy')) 36 | 37 | router.post('/user/login', require('./util/handle-login')) 38 | router.get('/topics', require('./util/proxy')) 39 | router.post('/topics', require('./util/proxy')) 40 | router.get('/topic/:id', require('./util/proxy')) 41 | router.get('/user/:id', require('./util/proxy')) 42 | router.get('/topic_collect/:id', require('./util/proxy')) 43 | router.post('/topic/:id/replies', require('./util/proxy')) 44 | app 45 | .use(router.routes()) 46 | .use(router.allowedMethods()); 47 | 48 | if(!isDev) { 49 | const serverEntry = require('../dist/server-entry') 50 | const template = fs.readFileSync(path.join(__dirname, '../dist/server.ejs'),'utf8') 51 | app.use(mount('/public', async (ctx,next) => { 52 | ctx.body = fs.readFileSync(path.join(__dirname, '../dist'+ctx.request.path)) 53 | })); 54 | app.use(async (ctx, next) => { 55 | await serverRender(serverEntry, template, ctx) 56 | }) 57 | } else { 58 | const devStatic = require('./util/dev-static') 59 | devStatic(app) 60 | } 61 | 62 | const host = '0.0.0.0' 63 | const port = process.PORT || '3333' 64 | app.listen(port, host, () => { 65 | console.log(`server is listening on ${port}`) 66 | }) 67 | -------------------------------------------------------------------------------- /client/views/user/login.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { 4 | inject, 5 | observer 6 | } from 'mobx-react'; 7 | import { Redirect } from 'react-router-dom' 8 | import queryString from 'query-string' 9 | import TextField from '@material-ui/core/TextField' 10 | import Button from '@material-ui/core/Button' 11 | import { withStyles } from '@material-ui/core/styles' 12 | 13 | import UserWrapper from './user' 14 | import loginStyles from './styles/login-style' 15 | 16 | @inject((stores) => { 17 | return { 18 | appState: stores.appState, 19 | user: stores.appState.user 20 | } 21 | }) @observer 22 | class UserLogin extends React.Component { 23 | static contextTypes = { 24 | router: PropTypes.object 25 | } 26 | 27 | constructor() { 28 | super() 29 | this.state = { 30 | accesstoken: '', 31 | helpText: '' 32 | } 33 | this.handleInput = this.handleInput.bind(this) 34 | this.handleLogin = this.handleLogin.bind(this) 35 | } 36 | 37 | getFrom(location) { 38 | location = location || this.props.location 39 | const query = queryString.parse(location.search) 40 | return query.from || '/user/info' 41 | } 42 | 43 | handleInput(event) { 44 | this.setState({ 45 | accesstoken: event.target.value.trim() 46 | }) 47 | } 48 | 49 | handleLogin() { 50 | if (!this.state.accesstoken) { 51 | return this.setState({ 52 | helpText: '必须填写' 53 | }) 54 | } 55 | this.setState({ 56 | helpText: '' 57 | }) 58 | return this.props.appState.login(this.state.accesstoken) 59 | .catch((error) => { 60 | console.log(error) // eslint-disable-line 61 | }) 62 | } 63 | 64 | render() { 65 | const { classes } = this.props 66 | const from = this.getFrom() 67 | const { isLogin } = this.props.user 68 | if (isLogin) { 69 | return 70 | } 71 | return ( 72 | 73 |
74 | 83 | 92 |
93 |
94 | ) 95 | } 96 | } 97 | 98 | UserLogin.propTypes = { 99 | classes: PropTypes.object.isRequired, 100 | location: PropTypes.object.isRequired 101 | } 102 | 103 | UserLogin.wrappedComponent.propTypes = { 104 | appState: PropTypes.object.isRequired, 105 | user: PropTypes.object.isRequired 106 | } 107 | 108 | export default withStyles(loginStyles)(UserLogin) 109 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "new-react-ssr", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "clear": "rimraf dist", 8 | "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.config.client.js", 9 | "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.config.server.js", 10 | "dev:client": "cross-env NODE_ENV=development webpack-dev-server --config build/webpack.config.client.js", 11 | "dev:server": "cross-env NODE_ENV=development nodemon server/server.js", 12 | "build": "npm run clear && npm run build:client && npm run build:server", 13 | "start": "cross-env NODE_ENV=production node server/server.js", 14 | "lint": "eslint --ext .js --ext .jsx client/", 15 | "lint-fix": "eslint --fix --ext .js --ext .jsx client/", 16 | "precommit": "npm run lint" 17 | }, 18 | "author": "cp", 19 | "license": "MIT", 20 | "keyword": [ 21 | "react", 22 | "ssr", 23 | "koa", 24 | "material-ui", 25 | "mobx" 26 | ], 27 | "devDependencies": { 28 | "babel-core": "^6.26.3", 29 | "babel-eslint": "^8.2.6", 30 | "babel-loader": "^7.1.5", 31 | "babel-plugin-transform-decorators-legacy": "^1.3.5", 32 | "babel-preset-es2015": "^6.24.1", 33 | "babel-preset-es2015-loose": "^8.0.0", 34 | "babel-preset-react": "^6.24.1", 35 | "babel-preset-stage-1": "^6.24.1", 36 | "cross-env": "^5.2.0", 37 | "ejs-compiled-loader": "^1.1.0", 38 | "eslint": "^5.5.0", 39 | "eslint-config-airbnb": "^17.1.0", 40 | "eslint-config-standard": "^12.0.0", 41 | "eslint-loader": "^2.1.0", 42 | "eslint-plugin-import": "^2.14.0", 43 | "eslint-plugin-jsx-a11y": "^6.1.1", 44 | "eslint-plugin-node": "^7.0.1", 45 | "eslint-plugin-promise": "^4.0.0", 46 | "eslint-plugin-react": "^7.11.1", 47 | "eslint-plugin-standard": "^4.0.0", 48 | "file-loader": "^2.0.0", 49 | "html-webpack-plugin": "^3.2.0", 50 | "husky": "^0.14.3", 51 | "memory-fs": "^0.4.1", 52 | "react-hot-loader": "^4.3.5", 53 | "rimraf": "^2.6.2", 54 | "uglifyjs-webpack-plugin": "^2.0.1", 55 | "webpack": "^4.12.0", 56 | "webpack-cli": "^3.0.8", 57 | "webpack-dev-server": "^3.1.7", 58 | "webpack-merge": "^4.1.4" 59 | }, 60 | "dependencies": { 61 | "@material-ui/core": "^3.0.2", 62 | "@material-ui/icons": "^3.0.1", 63 | "axios": "^0.18.0", 64 | "body-parser": "^1.18.3", 65 | "classnames": "^2.2.6", 66 | "dateformat": "^3.0.3", 67 | "ejs": "^2.6.1", 68 | "http-proxy-middleware": "^0.19.0", 69 | "jss": "^9.8.7", 70 | "koa": "^2.5.2", 71 | "koa-bodyparser": "^4.2.1", 72 | "koa-mount": "^3.0.0", 73 | "koa-router": "^7.4.0", 74 | "koa-session": "^5.9.0", 75 | "koa-static": "^5.0.0", 76 | "koa2-connect": "^1.0.2", 77 | "marked": "^0.5.0", 78 | "mobx": "^5.1.0", 79 | "mobx-react": "^5.2.5", 80 | "prop-types": "^15.6.2", 81 | "query-string": "^6.1.0", 82 | "react": "^16.4.2", 83 | "react-async-bootstrapper": "^2.1.1", 84 | "react-dom": "^16.4.2", 85 | "react-flip-move": "^3.0.2", 86 | "react-helmet": "^5.2.0", 87 | "react-jss": "^8.6.1", 88 | "react-router": "^4.3.1", 89 | "react-router-dom": "^4.3.1", 90 | "react-simplemde-editor": "^3.6.16", 91 | "serialize-javascript": "^1.5.0" 92 | }, 93 | "description": "react ssr with koa" 94 | } 95 | -------------------------------------------------------------------------------- /client/store/topic-store.js: -------------------------------------------------------------------------------- 1 | import { 2 | observable, 3 | toJS, 4 | action, 5 | extendObservable, 6 | computed 7 | } from 'mobx' 8 | 9 | import { topicSchema, replySchema } from '../util/variable-define' 10 | 11 | import { get, post } from '../util/http' 12 | 13 | const createTopic = (topic) => { 14 | return Object.assign({}, topicSchema, topic) 15 | } 16 | 17 | const createReply = (reply) => { 18 | return Object.assign({}, replySchema, reply) 19 | } 20 | 21 | class Topic { 22 | constructor(data, isDetail) { 23 | extendObservable(this, data) 24 | this.isDetail = isDetail 25 | } 26 | 27 | @observable syncing = false 28 | 29 | @observable createdReplies = [] 30 | 31 | @action doReply(content) { 32 | return new Promise((resolve, reject) => { 33 | post(`/topic/${this.id}/replies`, { 34 | needAccessToken: true 35 | }, { content }) 36 | .then((resp) => { 37 | if (resp.success) { 38 | this.createdReplies.push(createReply({ 39 | id: resp.reply_id, 40 | content, 41 | create_at: Date.now() 42 | })) 43 | resolve() 44 | } else { 45 | resolve(resp) 46 | } 47 | }).catch(reject) 48 | }) 49 | } 50 | } 51 | 52 | class TopicStore { 53 | @observable topics 54 | 55 | @observable details 56 | 57 | @observable syncing 58 | 59 | @observable createdTopics = [] 60 | 61 | @observable tab 62 | 63 | constructor({ 64 | syncing = false, topics = [], tab = null, details = [] 65 | } = {}) { 66 | this.syncing = syncing 67 | this.topics = topics.map(topic => new Topic(createTopic(topic))) 68 | this.details = details.map(topic => new Topic(createTopic(topic))) 69 | this.tab = tab 70 | } 71 | 72 | addTopic(topic) { 73 | this.topics.push(new Topic(createTopic(topic))) 74 | } 75 | 76 | @computed get detailMap() { 77 | return this.details.reduce((result, detail) => { 78 | result[detail.id] = detail 79 | return result 80 | }, {}) 81 | } 82 | 83 | @action fetchTopics(tab) { 84 | return new Promise((resolve, reject) => { 85 | if (tab === this.tab && this.topics.length > 0) { 86 | resolve() 87 | } else { 88 | this.tab = tab 89 | this.syncing = true 90 | this.topics = [] 91 | get('/topics', { 92 | mdrender: false, 93 | tab 94 | }).then((resp) => { 95 | if (resp.success) { 96 | this.topics = resp.data.map((topic) => { 97 | return new Topic(createTopic(topic)) 98 | }) 99 | resolve() 100 | } else { 101 | reject() 102 | } 103 | this.syncing = false 104 | }).catch((err) => { 105 | reject(err) 106 | this.syncing = false 107 | }) 108 | } 109 | }) 110 | } 111 | 112 | @action getTopicDetail(id) { 113 | return new Promise((resolve, reject) => { 114 | console.log('this.detailMap[id]', this.detailMap[id]) // eslint-disable-line 115 | if (this.detailMap[id]) { 116 | resolve(this.detailMap[id]) 117 | } else { 118 | console.log('get request') // eslint-disable-line 119 | get(`/topic/${id}`, { 120 | mdrender: false 121 | }).then((resp) => { 122 | if (resp.success) { 123 | const topic = new Topic(createTopic(resp.data)) 124 | this.details.push(topic) 125 | resolve(topic) 126 | } else { 127 | reject() 128 | } 129 | }).catch(reject) 130 | } 131 | }) 132 | } 133 | 134 | @action createTopic(title, tab, content) { 135 | return new Promise((resolve, reject) => { 136 | post('/topics', { 137 | needAccessToken: true 138 | }, { 139 | title, tab, content 140 | }).then((resp) => { 141 | if (resp.success) { 142 | const topic = { 143 | title, 144 | tab, 145 | content, 146 | id: resp.topic_id, 147 | create_at: Date.now() 148 | } 149 | this.createdTopics.push(new Topic(createTopic(topic))) 150 | resolve() 151 | } else { 152 | reject() 153 | } 154 | }).catch(reject) 155 | }) 156 | } 157 | 158 | toJson() { 159 | return { 160 | topics: toJS(this.topics), 161 | syncing: this.syncing, 162 | details: toJS(this.details), 163 | tab: this.tab 164 | } 165 | } 166 | } 167 | 168 | export default TopicStore 169 | -------------------------------------------------------------------------------- /client/views/topic-create/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { 4 | inject, 5 | observer 6 | } from 'mobx-react' 7 | 8 | import TextField from '@material-ui/core/TextField' 9 | import Radio from '@material-ui/core/Radio' 10 | import Button from '@material-ui/core/Button' 11 | import IconReply from '@material-ui/icons/Reply' 12 | import SnackBar from '@material-ui/core/Snackbar' 13 | import { withStyles } from '@material-ui/core/styles' 14 | 15 | import SimpleMDE from 'react-simplemde-editor'; 16 | import Container from '../layout/container' 17 | import createStyles from './styles' 18 | import { tabs } from '../../util/variable-define' 19 | 20 | 21 | @inject((stores) => { 22 | return { 23 | topicStore: stores.topicStore 24 | } 25 | }) @observer 26 | class TopicCreate extends React.Component { 27 | static contextTypes = { 28 | router: PropTypes.object, 29 | } 30 | 31 | constructor() { 32 | super() 33 | this.state = { 34 | title: '', 35 | content: '', 36 | tab: 'dev', 37 | open: false, 38 | message: '' 39 | } 40 | this.handleTitleChange = this.handleTitleChange.bind(this) 41 | this.handleContentChange = this.handleContentChange.bind(this) 42 | this.handleChangeTab = this.handleChangeTab.bind(this) 43 | this.handleCreate = this.handleCreate.bind(this) 44 | this.handleClose = this.handleClose.bind(this) 45 | } 46 | 47 | handleTitleChange(e) { 48 | this.setState({ 49 | title: e.target.value.trim() 50 | }) 51 | } 52 | 53 | /* eslient-disable */ 54 | handleContentChange(value) { 55 | this.setState({ 56 | content: value 57 | }) 58 | } 59 | 60 | handleChangeTab(e) { 61 | this.setState({ 62 | tab: e.currentTarget.value 63 | }) 64 | } 65 | 66 | handleCreate() { 67 | const { 68 | tab, 69 | title, 70 | content 71 | } = this.state 72 | if (!title) { 73 | this.showMessage('title 必须填写') 74 | return 75 | } 76 | if (!content) { 77 | this.showMessage('内容必须填写') 78 | return 79 | } 80 | this.props.topicStore.createTopic(title, tab, content) 81 | .then(() => { 82 | this.context.router.history.push('/list') 83 | }) 84 | .catch((err) => { 85 | this.showMessage(err.message) 86 | }) 87 | } 88 | /* eslient-enable */ 89 | 90 | showMessage(message) { 91 | this.setState({ 92 | open: true, 93 | message 94 | }) 95 | } 96 | 97 | handleClose() { 98 | this.setState({ 99 | open: false 100 | }) 101 | } 102 | 103 | render() { 104 | const { classes } = this.props 105 | const { 106 | message, 107 | open 108 | } = this.state 109 | return ( 110 | 111 | 120 |
121 | 128 | 139 |
140 | { 141 | Object.keys(tabs).map((tab) => { 142 | if (tab !== 'all' && tab !== 'good') { 143 | return ( 144 | 145 | 150 | {tabs[tab]} 151 | 152 | ) 153 | } 154 | return null 155 | }) 156 | } 157 |
158 | 161 |
162 |
163 | ) 164 | } 165 | } 166 | TopicCreate.wrappedComponent.propTypes = { 167 | topicStore: PropTypes.object.isRequired 168 | } 169 | 170 | TopicCreate.propTypes = { 171 | classes: PropTypes.object.isRequired 172 | } 173 | 174 | export default withStyles(createStyles)(TopicCreate) 175 | -------------------------------------------------------------------------------- /client/views/user/info.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { 4 | inject, 5 | observer 6 | } from 'mobx-react' 7 | 8 | import Grid from '@material-ui/core/Grid' 9 | import Paper from '@material-ui/core/Paper' 10 | import List from '@material-ui/core/List' 11 | import ListItem from '@material-ui/core/ListItem' 12 | import ListItemText from '@material-ui/core/ListItemText' 13 | import Avatar from '@material-ui/core/Avatar' 14 | import Typography from '@material-ui/core/Typography' 15 | import { withStyles } from '@material-ui/core/styles' 16 | 17 | import UserWrapper from './user' 18 | import infoStyles from './styles/user-info-style' 19 | 20 | const TopicItem = (({ topic, onClick }) => { 21 | return ( 22 | 23 | 24 | 28 | 29 | ) 30 | }) 31 | 32 | TopicItem.propTypes = { 33 | topic: PropTypes.object.isRequired, 34 | onClick: PropTypes.func.isRequired 35 | } 36 | 37 | @inject((stores) => { 38 | return { 39 | user: stores.appState.user, 40 | appState: stores.appState 41 | } 42 | }) @observer 43 | 44 | class UserInfo extends React.Component { 45 | static contextTypes = { 46 | router: PropTypes.object 47 | } 48 | 49 | componentWillMount() { 50 | this.props.appState.getUserDetail() 51 | this.props.appState.getUserCollection() 52 | } 53 | 54 | goToTopic(id) { 55 | this.context.router.history.push(`/detail/${id}`) 56 | } 57 | 58 | render() { 59 | const { classes } = this.props 60 | const topics = this.props.user.detail.recentTopics 61 | const replies = this.props.user.detail.recentReplies 62 | const collections = this.props.user.collections.list 63 | return ( 64 | 65 |
66 | 67 | 68 | 69 | 70 | 最新发布的话题 71 | 72 | 73 | { 74 | topics.length > 0 75 | ? topics.map(topic => ( 76 | this.goToTopic(topic.id)} 80 | /> 81 | )) 82 | : ( 83 | 84 | 最近没有发布过话题 85 | 86 | ) 87 | } 88 | 89 | 90 | 91 | 92 | 93 | 94 | 新的回复 95 | 96 | 97 | { 98 | replies.length > 0 99 | ? replies.map(topic => ( 100 | this.goToTopic(topic.id)} 104 | /> 105 | )) 106 | : ( 107 | 108 | 最近没有新的回复 109 | 110 | ) 111 | } 112 | 113 | 114 | 115 | 116 | 117 | 118 | 收藏的话题 119 | 120 | 121 | { 122 | collections.length > 0 123 | ? collections.map(topic => ( 124 | this.goToTopic(topic.id)} 128 | /> 129 | )) 130 | : ( 131 | 132 | 没有收藏话题哦 133 | 134 | ) 135 | } 136 | 137 | 138 | 139 | 140 |
141 |
142 | ) 143 | } 144 | } 145 | 146 | UserInfo.wrappedComponent.propTypes = { 147 | user: PropTypes.object.isRequired, 148 | appState: PropTypes.object.isRequired 149 | } 150 | 151 | UserInfo.propTypes = { 152 | classes: PropTypes.object.isRequired, 153 | } 154 | 155 | export default withStyles(infoStyles)(UserInfo) 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### react ssr 实践 2 | 3 | 通过jocket老师的课程,记录下react ssr 实践过程中的难点 4 | 5 | [github 代码](https://github.com/fridaydream/react-cnode-ssr) 6 | 7 | [效果展示](http://cpnode.daxierhao.com/) 8 | 9 | > 不一样的地方 10 | 11 | 1. express 换成koa(各种不同中间件和async和await的写法) 12 | 2. webpack升级4 13 | 3. material-ui从beta版升级到3(部分语法变化) 14 | 4. babel从6升到7(为了简化过程,暂时未升级。但是babel未升级,其相应关联的babel扩展包升级了,导致出现各种问题,其中有react-hot-loader/patch,找了好久才找到问题) 15 | 5. react-simplemde-editor富文本编辑器需要添加className 16 | 17 | 18 | > 服务端渲染过程 19 | 20 | ![react ssr 流程图](https://raw.githubusercontent.com/fridaydream/blogpic/master/js/node/react-ssr/react-ssr.jpeg) 21 | 22 | 23 | ### koa中对开发环境和生产环境中api接口、静态资源请求不同处理 24 | 25 | ``` 26 | plugins: [ 27 | new webpack.DefinePlugin({ 28 | 'process.env':{ 29 | 'NODE_ENV': JSON.stringify(isDev?'development':'production'), 30 | 'API_BASE': '"http://127.0.0.1:3333"' 31 | }, 32 | }) 33 | ] 34 | ``` 35 | 前端接口都是以api开头。通过webpack配置API_BASE,设置接口的前缀,开发环境是空字符串,进行代理到node后台,生产环境是一个绝对路径。这样在ssr,服务端渲染直出页面时在mobx里面直接去调用cnode接口。在node里面是没跨域,在浏览器是有跨域的。 36 | 37 | ![react ssr 流程图](https://raw.githubusercontent.com/fridaydream/blogpic/master/js/node/react-ssr/react-server.jpeg) 38 | 39 | 40 | 41 | ### 未登录的跳到需要登录的页面重定向 42 | 43 | 一般这种情况需要在react的componentWillMount的生命周期函数中处理,我记得vue里面有个路由的生命周期函数可以统一处理跳转到登录页。react里面没有,需要封装。 44 | 45 | ``` 46 | const PrivateRoute = ({ isLogin, component: Component, ...rest }) => ( 47 | ( 50 | isLogin ? ( 51 | 52 | ) : ( 53 | 56 | ) 57 | )} 58 | /> 59 | ) 60 | 61 | const InjectedPrivateRoute = withRouter(inject((stores) => { 62 | return { 63 | isLogin: stores.appState.user.isLogin 64 | } 65 | })(observer(PrivateRoute))) 66 | 67 | PrivateRoute.propTypes = { 68 | isLogin: PropTypes.bool, 69 | component: PropTypes.element.isRequired 70 | } 71 | 72 | PrivateRoute.defaultProps = { 73 | isLogin: false 74 | } 75 | export default () => [ 76 | 77 | ] 78 | ``` 79 | 80 | 贴过来一坨代码,自己理解去。 81 | 82 | 主要是通过store中的isLogin去判断是否登录,登录了就跳到指定路由,没有登录就重定向到登录页 83 | 84 | ### 服务端渲染时请求了接口数据,在客户端渲染的时候就不用请求接口 85 | 86 | 页面刷新的时候进行ssr,由服务端通过路由生成静态文件直出,之后操作都是走客户端的代码。刷新页面时在mobx需判断是否数据存在。 87 | 88 | 只有服务端渲染才走bootstrap这个生命周期函数。之后componentDidMount又请求一次接口。所以在mobx中需要判断是否数据已经请求回来了。请求一次数据后tab设置成当前tab,第二次请求之前判断tab是否一样,一样就不发送请求 89 | 90 | list页面 91 | 92 | ``` 93 | bootstrap() { 94 | const query = queryString.parse(this.props.location.search) 95 | const { tab } = query 96 | return this.props.topicStore.fetchTopics(tab || 'all').then(() => { 97 | return true 98 | }).catch(() => { 99 | return false 100 | }) 101 | } 102 | componentDidMount() { 103 | // do something 104 | const tab = this.getTab() 105 | this.props.topicStore.fetchTopics(tab) 106 | } 107 | ``` 108 | mobx 109 | 110 | ``` 111 | @action fetchTopics(tab) { 112 | return new Promise((resolve, reject) => { 113 | if (tab === this.tab && this.topics.length > 0) { 114 | resolve() 115 | } else { 116 | this.tab = tab 117 | this.syncing = true 118 | this.topics = [] 119 | get('/topics', { 120 | mdrender: false, 121 | tab 122 | }).then((resp) => { 123 | if (resp.success) { 124 | this.topics = resp.data.map((topic) => { 125 | return new Topic(createTopic(topic)) 126 | }) 127 | resolve() 128 | } else { 129 | reject() 130 | } 131 | this.syncing = false 132 | }).catch((err) => { 133 | reject(err) 134 | this.syncing = false 135 | }) 136 | } 137 | }) 138 | } 139 | ``` 140 | 141 | ### dev环境ssr入口server.entry.js打包到内存中如何获取webpack的bundle文件 142 | 143 | ``` 144 | const mfs = new MemoryFs 145 | const NativeModule = require('module') 146 | const vm = require('vm') 147 | const getModuleFromString = (bundle, filename) => { 148 | const m = { exports: {} } 149 | const wrapper = NativeModule.wrap(bundle) 150 | const script = new vm.Script(wrapper, { 151 | filename, 152 | displayErrors: true 153 | }) 154 | const result = script.runInThisContext() 155 | result.call(m.exports, m.exports, require, m) 156 | return m 157 | } 158 | 159 | const serverCompiler = webpack(serverConfig) 160 | serverCompiler.outputFileSystem = mfs 161 | let serverBundle 162 | serverCompiler.watch({}, (err, stats) => { 163 | if(err) throw err; 164 | stats = stats.toJson() 165 | stats.errors.forEach(err => console.log(err)) 166 | stats.warnings.forEach(warn => console.log(warn)) 167 | const bundlePath = path.join( 168 | serverConfig.output.path, 169 | serverConfig.output.filename 170 | ) 171 | const bundle = mfs.readFileSync(bundlePath, 'utf8') 172 | 173 | const m = getModuleFromString(bundle, 'server-entry.js') 174 | serverBundle = m.exports 175 | }) 176 | ``` 177 | 178 | 又是一坨代码 179 | 180 | webpack(serverConfig),webpack可以在js中通过webpack模块执行得到编译之后的代码 181 | 182 | 183 | 184 | #### 个人感悟 185 | 186 | 通过editorconfig和eslint对代码规范的约束虽然让开发过程痛苦了一点,但是这个避免了很多不必要的问题。也增强了自己代码的规范性。react中一切都是组件。webpack4配置的简化,mobx的reactive,material-ui用法的流畅,koa的简单便捷。pm2、nginx的优雅。 187 | 188 | -------------------------------------------------------------------------------- /client/views/topic-detail/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import marked from 'marked' 4 | import Helmet from 'react-helmet' 5 | import { 6 | inject, 7 | observer 8 | } from 'mobx-react' 9 | 10 | import { withStyles } from '@material-ui/core/styles' 11 | import Paper from '@material-ui/core/Paper' 12 | import CircularProgress from '@material-ui/core/CircularProgress' 13 | import Button from '@material-ui/core/Button' 14 | import IconReply from '@material-ui/icons/Reply' 15 | 16 | import SimpleMDE from 'react-simplemde-editor' 17 | import Container from '../layout/container' 18 | 19 | import { topicDetailStyle } from './styles' 20 | 21 | import Reply from './reply' 22 | // import store from '../../store/store'; 23 | 24 | @inject((stores) => { 25 | return { 26 | topicStore: stores.topicStore, 27 | user: stores.appState.user 28 | } 29 | }) @observer 30 | 31 | class TopicDetail extends React.Component { 32 | static contextTypes = { 33 | router: PropTypes.object 34 | } 35 | 36 | constructor() { 37 | super() 38 | this.state = { 39 | newReply: '' 40 | } 41 | this.handleNewReplyChange = this.handleNewReplyChange.bind(this) 42 | this.goToLogin = this.goToLogin.bind(this) 43 | this.doReply = this.doReply.bind(this) 44 | } 45 | 46 | componentDidMount() { 47 | const id = this.getTopicId() 48 | console.log('component did mount id:', id) // eslint-disable-line 49 | this.props.topicStore.getTopicDetail(id).catch((err) => { 50 | console.log('detail did mount error:', err) // eslint-disable-line 51 | }) 52 | } 53 | 54 | bootstrap() { 55 | const id = this.getTopicId() 56 | return this.props.topicStore.getTopicDetail(id).then(() => { 57 | return true 58 | }).catch(() => { 59 | return false 60 | }) 61 | } 62 | 63 | getTopicId() { 64 | return this.props.match.params.id 65 | } 66 | 67 | handleNewReplyChange(value) { 68 | this.setState({ 69 | newReply: value 70 | }) 71 | } 72 | 73 | goToLogin() { 74 | const id = this.getTopicId() 75 | this.context.router.history.push(`/user/login?from=/detail/${id}`) 76 | } 77 | 78 | /* eslint-disable */ 79 | doReply() { 80 | const id = this.getTopicId() 81 | const topic = this.props.topicStore.detailMap[id] 82 | topic.doReply(this.state.newReply) 83 | .then(() => { 84 | this.setState({ 85 | newReply: '' 86 | }) 87 | }).catch((err) => { 88 | console.log(err) // eslint-disable-line 89 | }) 90 | } 91 | /* eslint-enable */ 92 | 93 | render() { 94 | const { 95 | classes, 96 | user 97 | } = this.props 98 | const id = this.getTopicId() 99 | const topic = this.props.topicStore.detailMap[id] 100 | if (!topic) { 101 | return ( 102 | 103 |
104 | 105 |
106 |
107 | ) 108 | } 109 | return ( 110 |
111 | 112 | 113 | {topic.title} 114 | 115 |
116 |

{topic.title}

117 |
118 |
119 |

120 |

121 |
122 | 123 | { 124 | topic.createdReplies && topic.createdReplies.length > 0 ? ( 125 | 126 |
127 | 我的最新回复 128 | {`${topic.createdReplies.length}条`} 129 |
130 | { 131 | topic.createdReplies.map(reply => ( 132 | 141 | )) 142 | } 143 |
144 | ) : null 145 | } 146 | 147 |
148 | {`${topic.reply_count} 回复`} 149 | {`最新回复 ${topic.last_reply_at}`} 150 |
151 | { 152 | user.isLogin ? ( 153 |
154 | 166 | 174 |
175 | ) 176 | : (null) 177 | } 178 | { 179 | !user.isLogin 180 | && ( 181 |
182 | 185 |
186 | ) 187 | } 188 |
189 | { 190 | topic.replies.map(reply => ) 191 | } 192 |
193 |
194 |
195 | ) 196 | } 197 | } 198 | 199 | TopicDetail.wrappedComponent.propTypes = { 200 | topicStore: PropTypes.object.isRequired, 201 | user: PropTypes.object.isRequired 202 | } 203 | 204 | TopicDetail.propTypes = { 205 | match: PropTypes.object.isRequired, 206 | classes: PropTypes.object.isRequired, 207 | } 208 | 209 | export default withStyles(topicDetailStyle)(TopicDetail) 210 | -------------------------------------------------------------------------------- /client/views/topic-list/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | observer, 4 | inject 5 | } from 'mobx-react' 6 | import PropTypes from 'prop-types' 7 | import Helmet from 'react-helmet' 8 | import queryString from 'query-string' 9 | 10 | import Tabs from '@material-ui/core/Tabs' 11 | import Tab from '@material-ui/core/Tab' 12 | import List from '@material-ui/core/List' 13 | import CircularProgress from '@material-ui/core/CircularProgress'; 14 | 15 | import ListItem from '@material-ui/core/ListItem' 16 | // import ListItemAvatar from '@material-ui/core/ListItemAvatar'; 17 | import ListItemText from '@material-ui/core/ListItemText' 18 | import Avatar from '@material-ui/core/Avatar' 19 | import { withStyles } from '@material-ui/core/styles' 20 | 21 | import dateFormat from 'dateformat' 22 | import cx from 'classnames' 23 | import Container from '../layout/container' 24 | // import TopicListItem from './list-item' 25 | // import { AppState } from '../../store/store'; 26 | 27 | import { tabs } from '../../util/variable-define' 28 | 29 | import { 30 | topicPrimaryStyle, 31 | topicSecondaryStyle, 32 | } from './styles' 33 | 34 | const getTab = (tab, isTop, isGood) => { 35 | return isTop ? '置顶' : (isGood ? '精品' : tab) // eslint-disable-line 36 | } 37 | 38 | const TopicPrimary = (props) => { 39 | const { topic, classes } = props 40 | const isTop = topic.top 41 | const isGood = topic.good 42 | const classNames = cx([classes.tab, isTop ? classes.top : '', isGood ? classes.good : '']) 43 | return ( 44 |
45 | 48 | {getTab(tabs[topic.tab], isTop, isGood)} 49 | 50 | {topic.title} 51 |
52 | ) 53 | } 54 | 55 | const TopicSecondary = ({ classes, topic }) => ( 56 | 57 | {topic.author.loginname} 58 | 59 | {topic.reply_count} 60 | / 61 | {topic.visit_count} 62 | 63 | 64 | 创建时间: 65 | {dateFormat(topic.create_at, 'yyyy-mm-dd')} 66 | 67 | 68 | ) 69 | 70 | TopicPrimary.propTypes = { 71 | topic: PropTypes.object.isRequired, 72 | classes: PropTypes.object.isRequired 73 | } 74 | 75 | TopicSecondary.propTypes = { 76 | topic: PropTypes.object.isRequired, 77 | classes: PropTypes.object.isRequired 78 | } 79 | 80 | const StyledPrimary = withStyles(topicPrimaryStyle)(TopicPrimary) 81 | const StyledSecondary = withStyles(topicSecondaryStyle)(TopicSecondary) 82 | 83 | @inject(stores => ({ 84 | appState: stores.appState, 85 | topicStore: stores.topicStore, 86 | })) @observer 87 | 88 | class TopicList extends React.Component { 89 | static contextTypes = { 90 | router: PropTypes.object 91 | } 92 | 93 | constructor() { 94 | super() 95 | this.changeTab = this.changeTab.bind(this) 96 | this.listItemClick = this.listItemClick.bind(this) 97 | } 98 | 99 | componentDidMount() { 100 | // do something 101 | const tab = this.getTab() 102 | this.props.topicStore.fetchTopics(tab) 103 | } 104 | 105 | componentWillReceiveProps(nextProps) { 106 | if (nextProps.location.search !== this.props.location.search) { 107 | this.props.topicStore.fetchTopics(this.getTab(nextProps.location.search)) 108 | } 109 | } 110 | 111 | bootstrap() { 112 | const query = queryString.parse(this.props.location.search) 113 | const { tab } = query 114 | return this.props.topicStore.fetchTopics(tab || 'all').then(() => { 115 | return true 116 | }).catch(() => { 117 | return false 118 | }) 119 | } 120 | 121 | changeName(event) { 122 | this.props.appState.changeName(event.target.value) 123 | } 124 | 125 | getTab(search) { 126 | search = search || this.props.location.search 127 | const query = queryString.parse(search) 128 | return query.tab || 'all' 129 | } 130 | 131 | changeTab(e, value) { 132 | this.context.router.history.push({ 133 | pathname: '/list', 134 | search: `?tab=${value}` 135 | }) 136 | } 137 | 138 | listItemClick(topic) { 139 | this.context.router.history.push(`/detail/${topic.id}`) 140 | } 141 | 142 | render() { 143 | const { 144 | topicStore 145 | } = this.props 146 | const topicList = topicStore.topics 147 | const syncingTopics = topicStore.syncing 148 | const tab = this.getTab() 149 | const { 150 | createdTopics 151 | } = topicStore 152 | const { 153 | user, 154 | } = this.props.appState 155 | 156 | return ( 157 | 158 | 159 | This is topic list 160 | 161 | 162 | 163 | { 164 | Object.keys(tabs).map(t => ( 165 | 166 | )) 167 | } 168 | 169 | { 170 | createdTopics && createdTopics.length > 0 ? ( 171 | 172 | { 173 | createdTopics.map((topic) => { 174 | topic = Object.assign({}, topic, { 175 | author: user.info 176 | }) 177 | return ( 178 | { this.listItemClick(topic) }} key={topic.id}> 179 | 180 | } 182 | secondary={( 183 | 192 | )} 193 | /> 194 | 195 | ) 196 | }) 197 | } 198 | 199 | ) : null 200 | } 201 | 202 | 203 | { 204 | topicList.map(topic => ( 205 | { this.listItemClick(topic) }} key={topic.id}> 206 | 207 | } 209 | secondary={} 210 | /> 211 | 212 | )) 213 | } 214 | 215 | { 216 | syncingTopics 217 | ? ( 218 |
225 | 226 |
227 | ) 228 | : null 229 | } 230 | 231 |
232 | ) 233 | } 234 | } 235 | 236 | TopicList.wrappedComponent.propTypes = { 237 | appState: PropTypes.object.isRequired, 238 | topicStore: PropTypes.object.isRequired 239 | } 240 | TopicList.propTypes = { 241 | location: PropTypes.object.isRequired 242 | } 243 | 244 | export default TopicList 245 | --------------------------------------------------------------------------------