├── .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 |
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 | 
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 | 
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 |
106 |
107 | )
108 | }
109 | return (
110 |
111 |
112 |
113 | {topic.title}
114 |
115 |
116 | {topic.title}
117 |
118 |
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 |
--------------------------------------------------------------------------------