├── src
├── types.js
├── constants.js
├── public
│ ├── images
│ │ ├── back.png
│ │ ├── top.png
│ │ └── loading.png
│ └── styles
│ │ ├── reset.less
│ │ ├── index.less
│ │ └── markdown.less
├── middleWares.js
├── store.js
├── __tests__
│ └── App.test.js
├── reducers
│ ├── accesstoken.js
│ ├── user.js
│ ├── index.js
│ ├── self.js
│ ├── topics.js
│ └── topic.js
├── components
│ ├── ToTop
│ │ ├── toTop.less
│ │ └── index.jsx
│ ├── Footer
│ │ ├── footer.less
│ │ └── index.jsx
│ ├── ScrollToTop.jsx
│ ├── SignIn
│ │ ├── signIn.less
│ │ └── index.jsx
│ ├── App.jsx
│ ├── NavBar
│ │ ├── navBar.less
│ │ └── index.jsx
│ ├── IndexPage
│ │ ├── indexPage.less
│ │ └── index.jsx
│ ├── User
│ │ ├── user.less
│ │ └── index.jsx
│ └── TopicDetail
│ │ ├── topicDetail.less
│ │ └── index.jsx
├── index.jsx
├── utils.js
├── hl.worker.js
├── actions
│ ├── users.js
│ └── topics.js
└── registerServiceWorker.js
├── README.md
├── docs
├── favicon.png
├── asset-manifest.json
├── manifest.json
├── index.html
├── service-worker.js
└── static
│ └── css
│ └── main.4ac32dfd.css
├── public
├── favicon.png
├── manifest.json
└── index.html
├── TODO.md
├── config
├── jest
│ ├── fileTransform.js
│ └── cssTransform.js
├── polyfills.js
├── webpackDevServer.config.js
├── paths.js
├── webpack.config.js
├── env.js
├── webpack.config.dev.js
└── webpack.config.prod.js
├── .gitignore
├── scripts
├── test.js
├── start.js
└── build.js
└── package.json
/src/types.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # cnode-react
2 |
3 | 这一个全新的版本。时隔多年重新拾起React。
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | export const domain = 'https://cnodejs.org/api/v1/'
2 |
--------------------------------------------------------------------------------
/docs/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RyanLiu0235/cnode-react/HEAD/docs/favicon.png
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RyanLiu0235/cnode-react/HEAD/public/favicon.png
--------------------------------------------------------------------------------
/src/public/images/back.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RyanLiu0235/cnode-react/HEAD/src/public/images/back.png
--------------------------------------------------------------------------------
/src/public/images/top.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RyanLiu0235/cnode-react/HEAD/src/public/images/top.png
--------------------------------------------------------------------------------
/src/public/images/loading.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RyanLiu0235/cnode-react/HEAD/src/public/images/loading.png
--------------------------------------------------------------------------------
/TODO.md:
--------------------------------------------------------------------------------
1 | # TODO
2 |
3 | * highlight.js高亮代码,尝试在 web worker 里计算
4 | * 按需加载评论
5 | * 回复/点赞/收藏帖子
6 | * loading
7 | * 左右滑动tab
8 | * PWA
--------------------------------------------------------------------------------
/docs/asset-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "main.css": "static/css/main.4ac32dfd.css",
3 | "main.js": "static/js/main.8cad8da0.js"
4 | }
--------------------------------------------------------------------------------
/src/public/styles/reset.less:
--------------------------------------------------------------------------------
1 | ul,
2 | ol {
3 | list-style: none;
4 | }
5 |
6 | h1,
7 | h2,
8 | h3,
9 | h4,
10 | h5,
11 | h6,
12 | p {
13 | margin: 0;
14 | font-weight: normal;
15 | }
16 |
17 | a {
18 | text-decoration: none;
19 | }
--------------------------------------------------------------------------------
/src/middleWares.js:
--------------------------------------------------------------------------------
1 | // middlewares for handling responses
2 | export const handleResponse = res => {
3 | if (res.ok) {
4 | return res.json()
5 | } else {
6 | return Promise.reject(res.statusText)
7 | }
8 | }
9 |
10 | export const handleError = err => {
11 | console.error(err)
12 | }
13 |
--------------------------------------------------------------------------------
/src/store.js:
--------------------------------------------------------------------------------
1 | import {
2 | createStore,
3 | applyMiddleware
4 | } from 'redux'
5 | import thunk from 'redux-thunk'
6 | import reducer from './reducers/index.js'
7 |
8 | const finalCreactStore = applyMiddleware(thunk)(createStore)
9 | const store = finalCreactStore(reducer)
10 |
11 | export default store
--------------------------------------------------------------------------------
/src/__tests__/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from '../App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | ReactDOM.render(
([\s\S]*?)<\/code>/gm
5 | const handleHighlight = raw => {
6 | return raw.replace(codeRE, (...args) => {
7 | const raw = args[1]
8 | const unescaped = unescape(raw)
9 | const { value } = hljs.highlight('js', unescaped)
10 | return `${value}`
11 | })
12 | }
13 |
14 | const linksRE = /( handleAddHash(handleHighlight(raw))
18 |
19 | onmessage = e => {
20 | const { content, replies } = e.data
21 | const _content = handler(content)
22 | const _replies = replies.map(reply => {
23 | reply.content = handler(reply.content)
24 | return reply
25 | })
26 |
27 | postMessage(Object.assign({}, e.data, {
28 | content: _content,
29 | replies: _replies
30 | }))
31 | }
32 |
--------------------------------------------------------------------------------
/src/reducers/topic.js:
--------------------------------------------------------------------------------
1 | import {
2 | FETCH_TOPIC_DETAIL,
3 | COLLECT_TOPIC,
4 | LIKE_REPLY
5 | } from 'actions/topics'
6 |
7 | let _state = {
8 | replies: [],
9 | title: '',
10 | author: '',
11 | create_at: 0,
12 | reply_count: 0,
13 | visit_count: 0,
14 | content: ''
15 | }
16 | export default function topic(state = _state, action) {
17 | switch (action.type) {
18 | case FETCH_TOPIC_DETAIL:
19 | return action.data
20 | case COLLECT_TOPIC:
21 | return Object.assign({}, state, { is_collect: !action.action })
22 | case LIKE_REPLY:
23 | const copy = Object.assign({}, state)
24 | const reply = copy.replies.find(item => item.id === action.id)
25 | if (reply) {
26 | if (action.action === 'up') {
27 | reply.is_uped = true
28 | reply.ups.push(action.id)
29 | } else {
30 | reply.is_uped = false
31 | reply.ups.splice(reply.ups.indexOf(action.id))
32 | }
33 | }
34 | return copy
35 | default:
36 | return state
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/App.jsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | Component
3 | } from 'react'
4 | import {
5 | HashRouter as Router,
6 | Route
7 | } from 'react-router-dom'
8 | import ScrollToTop from './ScrollToTop'
9 | import IndexPage from './IndexPage'
10 | import TopicDetail from './TopicDetail'
11 | import SignIn from './SignIn'
12 | import User from './User'
13 | import ToTop from './ToTop'
14 | import NavBar from './NavBar'
15 | import Footer from './Footer'
16 |
17 | class App extends Component {
18 | render() {
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | )
35 | }
36 | }
37 |
38 | export default App
39 |
--------------------------------------------------------------------------------
/src/components/NavBar/navBar.less:
--------------------------------------------------------------------------------
1 | // 顶部导航
2 | .header_container {
3 | display: flex;
4 | height: 34px;
5 | align-items: center;
6 | justify-content: space-between;
7 | .go_back {
8 | display: block;
9 | width: 30px;
10 | height: 30px;
11 | line-height: 30px;
12 | font-size: 22px;
13 | }
14 | .tab_list {
15 | display: flex;
16 | flex: 1;
17 | }
18 | .tab_item {
19 | display: block;
20 | width: 36px;
21 | height: 34px;
22 | padding: 0 3px;
23 | line-height: 34px;
24 | text-align: center;
25 | font-size: 14px;
26 | color: #333;
27 | &[aria-current=true] {
28 | color: #fff;
29 | background-color: #f64c4c;
30 | }
31 | }
32 | .user_name {
33 | position: relative;
34 | display: block;
35 | width: 30px;
36 | img {
37 | display: block;
38 | width: 100%;
39 | border-radius: 50%;
40 | }
41 | .unread_num {
42 | position: absolute;
43 | top: -2px;
44 | right: -2px;
45 | z-index: 2;
46 | height: 12px;
47 | min-width: 6px;
48 | line-height: 14px;
49 | padding: 0 3px;
50 | border-radius: 6px;
51 | font-size: 10px;
52 | color: #fff;
53 | font-style: normal;
54 | background-color: #f64c4c;
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/config/webpackDevServer.config.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware');
4 | const noopServiceWorkerMiddleware = require('react-dev-utils/noopServiceWorkerMiddleware');
5 | const ignoredFiles = require('react-dev-utils/ignoredFiles');
6 | const config = require('./webpack.config.dev');
7 | const paths = require('./paths');
8 |
9 | const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
10 | const host = process.env.HOST || '0.0.0.0';
11 |
12 | module.exports = function(proxy, allowedHost) {
13 | return {
14 | disableHostCheck:
15 | !proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === 'true',
16 | compress: true,
17 | clientLogLevel: 'none',
18 | contentBase: paths.appPublic,
19 | watchContentBase: true,
20 | hot: true,
21 | publicPath: config.output.publicPath,
22 | quiet: true,
23 | watchOptions: {
24 | ignored: ignoredFiles(paths.appSrc),
25 | },
26 | https: protocol === 'https',
27 | host: host,
28 | overlay: false,
29 | historyApiFallback: {
30 | disableDotRule: true,
31 | },
32 | public: allowedHost,
33 | proxy,
34 | before(app) {
35 | app.use(errorOverlayMiddleware());
36 | app.use(noopServiceWorkerMiddleware());
37 | }
38 | };
39 | };
40 |
--------------------------------------------------------------------------------
/src/public/styles/index.less:
--------------------------------------------------------------------------------
1 | @import './reset.less';
2 | @import './markdown.less';
3 |
4 | body {
5 | position: relative;
6 | background-color: #f5f5f5;
7 | font: 12px Helvetica Neue, Helvetica, Arial, Microsoft Yahei, Hiragino Sans GB, Heiti SC, WenQuanYi Micro Hei, sans-serif;
8 | }
9 |
10 | #cnode_container {
11 | padding-bottom: 60px;
12 | }
13 |
14 | .iconfont {
15 | text-align: center;
16 | }
17 |
18 | // 公共section
19 | .panel {
20 | padding: 10px 5px;
21 | margin: 10px 5px 0;
22 | background-color: #fff;
23 | .panel_title {
24 | padding: 0 10px;
25 | line-height: 30px;
26 | font-size: 16px;
27 | color: #333;
28 | border-bottom: 1px #e0e0e0 solid;
29 | }
30 | .panel_container {
31 | padding: 0 10px;
32 | }
33 | .panel_empty {
34 | line-height: 16px;
35 | margin-top: 5px;
36 | font-size: 12px;
37 | color: #666;
38 | }
39 | .panel_row {
40 | margin-top: 10px;
41 | }
42 | }
43 |
44 | .button_container {
45 | text-align: center;
46 | }
47 |
48 | .button {
49 | display: inline-block;
50 | height: 30px;
51 | line-height: 30px;
52 | padding: 0 10px;
53 | text-align: center;
54 | font-size: 14px;
55 | color: #fff;
56 | &.button_warning {
57 | background-color: #f64c4c;
58 | }
59 | &.button_primary {
60 | background-color: #6666ff;
61 | }
62 | &.button_info {
63 | background-color: #91b151;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/config/paths.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const fs = require('fs');
5 | const url = require('url');
6 |
7 | const appDirectory = fs.realpathSync(process.cwd());
8 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
9 |
10 | function ensureSlash(path, needsSlash) {
11 | const hasSlash = path.endsWith('/');
12 | if (hasSlash && !needsSlash) {
13 | return path.substr(path, path.length - 1);
14 | } else if (!hasSlash && needsSlash) {
15 | return `${path}/`;
16 | } else {
17 | return path;
18 | }
19 | }
20 |
21 | const getPublicUrl = appPackageJson => require(appPackageJson).homepage;
22 |
23 | function getServedPath(appPackageJson) {
24 | const publicUrl = getPublicUrl(appPackageJson);
25 | const servedUrl = publicUrl ? url.parse(publicUrl).pathname : '/'
26 | return ensureSlash(servedUrl, true);
27 | }
28 |
29 | module.exports = {
30 | dotenv: resolveApp('.env'),
31 | appBuild: resolveApp('docs'),
32 | appPublic: resolveApp('public'),
33 | appHtml: resolveApp('public/index.html'),
34 | appIndexJs: resolveApp('src/index.jsx'),
35 | appPackageJson: resolveApp('package.json'),
36 | appSrc: resolveApp('src'),
37 | yarnLockFile: resolveApp('yarn.lock'),
38 | testsSetup: resolveApp('src/setupTests.js'),
39 | appNodeModules: resolveApp('node_modules'),
40 | publicUrl: getPublicUrl(resolveApp('package.json')),
41 | servedPath: '/cnode-react/',
42 | };
43 |
--------------------------------------------------------------------------------
/config/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const paths = require('./paths')
3 | const eslintFormatter = require('react-dev-utils/eslintFormatter')
4 | const resolve = dir => path.resolve(__dirname, '../src', dir)
5 |
6 | module.exports = {
7 | resolve: {
8 | extensions: ['.web.js', '.mjs', '.js', '.json', '.web.jsx', '.jsx', '.less'],
9 | alias: {
10 | 'react-native': 'react-native-web',
11 | '@': resolve('./'),
12 | actions: resolve('./actions')
13 | }
14 | },
15 | module: {
16 | strictExportPresence: true,
17 | rules: [{
18 | test: /\.(js|jsx|mjs)$/,
19 | enforce: 'pre',
20 | use: [{
21 | options: {
22 | formatter: eslintFormatter,
23 | eslintPath: require.resolve('eslint')
24 | },
25 | loader: require.resolve('eslint-loader')
26 | }],
27 | include: paths.appSrc
28 | }, {
29 | test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
30 | loader: require.resolve('url-loader'),
31 | options: {
32 | limit: 10000,
33 | name: 'static/media/[name].[hash:8].[ext]'
34 | }
35 | }, {
36 | test: /\.(js|jsx|mjs)$/,
37 | include: paths.appSrc,
38 | loader: require.resolve('babel-loader'),
39 | options: {
40 | compact: true
41 | }
42 | }, {
43 | test: /\.worker\.js$/,
44 | use: { loader: 'worker-loader' }
45 | }]
46 | },
47 | node: {
48 | dgram: 'empty',
49 | fs: 'empty',
50 | net: 'empty',
51 | tls: 'empty',
52 | child_process: 'empty'
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/IndexPage/indexPage.less:
--------------------------------------------------------------------------------
1 | // 主题列表
2 | .topic_list {
3 | margin: 10px 5px;
4 | background-color: #fff;
5 | .topic_item {
6 | display: flex;
7 | align-items: center;
8 | height: 50px;
9 | padding: 0 10px;
10 | border-bottom: 1px #e0e0e0 solid;
11 | box-sizing: border-box;
12 | .user_avatar {
13 | position: relative;
14 | display: block;
15 | width: 30px;
16 | margin-right: 5px;
17 | border-radius: 50%;
18 | overflow: hidden;
19 | &:after {
20 | content: '';
21 | display: block;
22 | width: 100%;
23 | padding-top: 100%;
24 | }
25 | img {
26 | position: absolute;
27 | top: 0;
28 | left: 0;
29 | width: 100%;
30 | }
31 | }
32 | .topic_title {
33 | flex: 1;
34 | height: 50px;
35 | line-height: 50px;
36 | margin-right: 5px;
37 | font-weight: normal;
38 | font-size: 14px;
39 | overflow: hidden;
40 | a {
41 | display: block;
42 | color: #333;
43 | overflow: hidden;
44 | text-overflow: ellipsis;
45 | white-space: nowrap;
46 | }
47 | }
48 | .reply_view {
49 | font-size: 12px;
50 | .reply_number {
51 | color: #999;
52 | }
53 | .view_number {
54 | color: #666;
55 | }
56 | }
57 | }
58 | // 查看更多
59 | .load_more {
60 | display: block;
61 | height: 40px;
62 | line-height: 40px;
63 | margin-top: 20px;
64 | text-align: center;
65 | font-size: 16px;
66 | color: #333;
67 | background-color: #eaeaea;
68 | &.show {
69 | display: block;
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/config/env.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const fs = require('fs')
4 | const path = require('path')
5 | const paths = require('./paths')
6 |
7 | delete require.cache[require.resolve('./paths')]
8 |
9 | const NODE_ENV = process.env.NODE_ENV
10 | if (!NODE_ENV) {
11 | throw new Error(
12 | 'The NODE_ENV environment variable is required but was not specified.'
13 | )
14 | }
15 |
16 | var dotenvFiles = [
17 | `${paths.dotenv}.${NODE_ENV}.local`,
18 | `${paths.dotenv}.${NODE_ENV}`,
19 | NODE_ENV !== 'test' && `${paths.dotenv}.local`,
20 | paths.dotenv,
21 | ].filter(Boolean)
22 |
23 | dotenvFiles.forEach(dotenvFile => {
24 | if (fs.existsSync(dotenvFile)) {
25 | require('dotenv-expand')(
26 | require('dotenv').config({
27 | path: dotenvFile,
28 | })
29 | )
30 | }
31 | })
32 |
33 | const appDirectory = fs.realpathSync(process.cwd())
34 | process.env.NODE_PATH = (process.env.NODE_PATH || '')
35 | .split(path.delimiter)
36 | .filter(folder => folder && !path.isAbsolute(folder))
37 | .map(folder => path.resolve(appDirectory, folder))
38 | .join(path.delimiter)
39 |
40 | const REACT_APP = /^REACT_APP_/i
41 |
42 | function getClientEnvironment(publicUrl) {
43 | const raw = Object.keys(process.env)
44 | .filter(key => REACT_APP.test(key))
45 | .reduce(
46 | (env, key) => {
47 | env[key] = process.env[key]
48 | return env
49 | }, {
50 | NODE_ENV: process.env.NODE_ENV || 'development',
51 | PUBLIC_URL: publicUrl,
52 | }
53 | )
54 | const stringified = {
55 | 'process.env': Object.keys(raw).reduce((env, key) => {
56 | env[key] = JSON.stringify(raw[key])
57 | return env
58 | }, {})
59 | }
60 |
61 | return { raw, stringified }
62 | }
63 |
64 | module.exports = getClientEnvironment
65 |
--------------------------------------------------------------------------------
/src/actions/users.js:
--------------------------------------------------------------------------------
1 | import {
2 | delCookie
3 | } from '@/utils'
4 | import { handleResponse, handleError } from '@/middleWares'
5 | import { domain } from '@/constants'
6 |
7 | const _fetchUser = (name, dispatch, mutation) => {
8 | fetch(`${domain}user/${name}`)
9 | .then(handleResponse)
10 | .then(({ data }) => {
11 | dispatch({
12 | type: mutation,
13 | data
14 | })
15 | }).catch(handleError)
16 | }
17 |
18 | export const FETCH_USER = 'FETCH_USER'
19 | export const fetchUser = name => dispatch => {
20 | _fetchUser(name, dispatch, FETCH_USER)
21 | }
22 |
23 | export const FETCH_SELF = 'FETCH_SELF'
24 | export const fetchSelf = name => dispatch => {
25 | _fetchUser(name, dispatch, FETCH_SELF)
26 | }
27 |
28 | export const REGISTER_ACCESSTOKEN = 'REGISTER_ACCESSTOKEN'
29 | export const registerAccesstoken = accesstoken => dispatch => {
30 | dispatch({
31 | type: REGISTER_ACCESSTOKEN,
32 | data: accesstoken
33 | })
34 | }
35 |
36 | export const login = accesstoken => dispatch => {
37 | return fetch(`${domain}accesstoken`, {
38 | method: 'POST',
39 | body: JSON.stringify({
40 | accesstoken
41 | }),
42 | headers: new Headers({
43 | 'Content-Type': 'application/json'
44 | })
45 | })
46 | .then(handleResponse)
47 | .then(res => {
48 | dispatch({
49 | type: REGISTER_ACCESSTOKEN,
50 | data: accesstoken
51 | })
52 | return Promise.resolve(res)
53 | }, err => Promise.reject(err))
54 | }
55 |
56 | export const LOG_OUT = 'LOG_OUT'
57 | export const logout = () => dispatch => {
58 | return new Promise(resolve => {
59 | // 清除本地数据
60 | dispatch({
61 | type: LOG_OUT
62 | })
63 | // 清除cookie
64 | const cookie = delCookie(document.cookie, 'cnode')
65 | document.cookie = cookie
66 | resolve()
67 | })
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/SignIn/index.jsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | Component
3 | } from 'react'
4 | import {
5 | connect
6 | } from 'react-redux'
7 | import {
8 | bindActionCreators
9 | } from 'redux'
10 | import {
11 | login,
12 | fetchSelf
13 | } from 'actions/users'
14 | import './signIn'
15 |
16 | class SignIn extends Component {
17 | constructor(props) {
18 | super(props)
19 | this.state = {
20 | accesstoken: '',
21 | landState: '登录'
22 | }
23 | }
24 | handleInput(e) {
25 | this.setState({
26 | accesstoken: e.target.value.trim()
27 | })
28 | }
29 | handleSignin() {
30 | this.setState = {
31 | landState: '登录中...'
32 | }
33 |
34 | this.props.login(this.state.accesstoken).then(({ loginname }) => {
35 | this.props.history.push('/')
36 | this.props.fetchSelf(loginname)
37 | localStorage.setItem('cnode', loginname)
38 | localStorage.setItem('accesstoken', this.state.accesstoken)
39 | }, err => {
40 | console.error(err)
41 | })
42 | }
43 | render() {
44 | const buttonClass = 'button ' + (this.state.landState === '登录中...' ? 'button_primary' : 'button_info')
45 | return (
46 |
47 | 登录到CNode
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | { this.state.landState }
57 |
58 |
59 |
60 | )
61 | }
62 | }
63 |
64 | function mapDispatchToProps(dispatch) {
65 | return bindActionCreators({
66 | login,
67 | fetchSelf
68 | }, dispatch)
69 | }
70 |
71 | export default connect(() => ({}), mapDispatchToProps)(SignIn)
--------------------------------------------------------------------------------
/src/components/User/user.less:
--------------------------------------------------------------------------------
1 | // 用户页
2 | .user_page {
3 | .user_info {
4 | .user_row {
5 | display: flex;
6 | align-items: center;
7 | }
8 | .user_avatar {
9 | width: 60px;
10 | height: 60px;
11 | margin-right: 10px;
12 | border-radius: 50%;
13 | overflow: hidden;
14 | img {
15 | display: block;
16 | width: 100%;
17 | }
18 | }
19 | .user_name {
20 | flex: 1;
21 | margin-right: 5px;
22 | font-size: 14px;
23 | color: #666;
24 | }
25 | .user_github {
26 | font-size: 16px;
27 | }
28 | .user_createdAt,
29 | .user_score,
30 | .user_notification {
31 | font-size: 12px;
32 | }
33 | }
34 |
35 | .recent_topic_list {
36 | background-color: #fff;
37 | .topic_item {
38 | display: flex;
39 | align-items: center;
40 | height: 50px;
41 | padding: 0 10px;
42 | border-bottom: 1px #e0e0e0 solid;
43 | box-sizing: border-box;
44 | .user_avatar {
45 | position: relative;
46 | display: block;
47 | width: 30px;
48 | margin-right: 5px;
49 | border-radius: 50%;
50 | overflow: hidden;
51 | &:after {
52 | content: '';
53 | display: block;
54 | width: 100%;
55 | padding-top: 100%;
56 | }
57 | img {
58 | position: absolute;
59 | top: 0;
60 | left: 0;
61 | width: 100%;
62 | }
63 | }
64 | .topic_title {
65 | flex: 1;
66 | height: 50px;
67 | line-height: 50px;
68 | margin-right: 5px;
69 | font-weight: normal;
70 | font-size: 14px;
71 | overflow: hidden;
72 | a {
73 | display: block;
74 | text-overflow: ellipsis;
75 | white-space: nowrap;
76 | overflow: hidden;
77 | color: #333;
78 | }
79 | }
80 | .reply_view {
81 | font-size: 12px;
82 | .reply_number {
83 | color: #999;
84 | }
85 | .view_number {
86 | color: #666;
87 | }
88 | }
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/components/NavBar/index.jsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | Component
3 | } from 'react'
4 | import {
5 | NavLink
6 | } from 'react-router-dom'
7 | import {
8 | connect
9 | } from 'react-redux'
10 | import {
11 | bindActionCreators
12 | } from 'redux'
13 | import {
14 | fetchSelf,
15 | registerAccesstoken
16 | } from 'actions/users'
17 | import './navBar'
18 |
19 | const list = [{
20 | name: 'all',
21 | text: '全部'
22 | }, {
23 | name: 'good',
24 | text: '精华'
25 | }, {
26 | name: 'share',
27 | text: '分享'
28 | }, {
29 | name: 'ask',
30 | text: '问答'
31 | }, {
32 | name: 'job',
33 | text: '招聘'
34 | }, {
35 | name: 'dev',
36 | text: '测试'
37 | }]
38 |
39 | class NavBar extends Component {
40 | componentWillMount() {
41 | const cnode = localStorage.getItem('cnode')
42 | const accesstoken = localStorage.getItem('accesstoken') || 0
43 |
44 | if (cnode) {
45 | this.props.fetchSelf(cnode)
46 | }
47 | this.props.registerAccesstoken(accesstoken)
48 | }
49 | goBack() {
50 | window.history.back()
51 | }
52 | render() {
53 | const user = this.props.self
54 | const navList = list.map(item => {
55 | return (
56 |
57 | {item.text}
58 |
59 | )
60 | })
61 |
62 | return (
63 |
64 |
65 |
66 |
67 | {navList}
68 |
69 |
70 | {
71 | user.loginname ?
72 |
73 |
74 | {/*{ unread }*/}
75 | :
76 | 登录
77 | }
78 |
79 |
80 |
81 | )
82 | }
83 | }
84 |
85 | function mapStateToProps(state) {
86 | return {
87 | self: state.self
88 | }
89 | }
90 |
91 | function mapDispatchToProps(dispatch) {
92 | return bindActionCreators({
93 | fetchSelf,
94 | registerAccesstoken
95 | }, dispatch)
96 | }
97 |
98 | export default connect(mapStateToProps, mapDispatchToProps)(NavBar)
--------------------------------------------------------------------------------
/src/components/IndexPage/index.jsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | Component
3 | } from 'react'
4 | import {
5 | connect
6 | } from 'react-redux'
7 | import {
8 | Link
9 | } from 'react-router-dom'
10 | import {
11 | bindActionCreators
12 | } from 'redux'
13 | import {
14 | fetchTopics,
15 | fetchMoreTopics,
16 | resetPage
17 | } from 'actions/topics'
18 | import './indexPage'
19 |
20 | class IndexPage extends Component {
21 | componentWillMount() {
22 | const {
23 | match,
24 | topics,
25 | fetchTopics
26 | } = this.props
27 | const tab = match.params.id || 'all'
28 |
29 | fetchTopics({
30 | tab,
31 | page: topics.page
32 | })
33 | }
34 | componentWillReceiveProps({
35 | topics,
36 | fetchTopics,
37 | match
38 | }) {
39 | const tab = topics.tab
40 | const newTab = match.params.id || 'all'
41 |
42 | if (newTab !== tab) {
43 | fetchTopics({
44 | tab: newTab,
45 | page: 1
46 | })
47 | }
48 | }
49 |
50 | // when leaving this page, reset page data
51 | componentWillUnmount() {
52 | this.props.resetPage()
53 | }
54 | loadMore() {
55 | const {
56 | topics,
57 | fetchMoreTopics
58 | } = this.props
59 |
60 | fetchMoreTopics(topics)
61 | }
62 | render() {
63 | const topicList = this.props.topics.list.map(item => {
64 | return (
65 |
66 |
67 |
68 |
69 |
70 | { item.title }
71 |
72 |
73 | { item.reply_count }
74 | /
75 | { item.visit_count}
76 |
77 |
78 | )
79 | })
80 | return (
81 |
82 | { topicList }
83 | 查看更多
84 |
85 | )
86 | }
87 | }
88 |
89 | function mapStateToProps(state) {
90 | return {
91 | topics: state.topics
92 | }
93 | }
94 |
95 | function mapDispatchToProps(dispatch) {
96 | return bindActionCreators({
97 | fetchTopics,
98 | fetchMoreTopics,
99 | resetPage
100 | }, dispatch)
101 | }
102 |
103 | export default connect(mapStateToProps, mapDispatchToProps)(IndexPage)
--------------------------------------------------------------------------------
/config/webpack.config.dev.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const autoprefixer = require('autoprefixer')
4 | const path = require('path')
5 | const webpack = require('webpack')
6 | const HtmlWebpackPlugin = require('html-webpack-plugin')
7 | const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')
8 | const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin')
9 | const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin')
10 | const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin')
11 | const getClientEnvironment = require('./env')
12 | const paths = require('./paths')
13 | const merge = require('webpack-merge')
14 | const config = require('./webpack.config')
15 |
16 | const publicPath = '/'
17 | const publicUrl = ''
18 | const env = getClientEnvironment(publicUrl)
19 |
20 | module.exports = merge(config, {
21 | devtool: 'cheap-module-source-map',
22 | entry: [
23 | require.resolve('./polyfills'),
24 | require.resolve('react-dev-utils/webpackHotDevClient'),
25 | paths.appIndexJs
26 | ],
27 | output: {
28 | pathinfo: true,
29 | filename: 'static/js/bundle.js',
30 | chunkFilename: 'static/js/[name].chunk.js',
31 | publicPath: publicPath,
32 | devtoolModuleFilenameTemplate: info =>
33 | path.resolve(info.absoluteResourcePath).replace(/\\/g, '/')
34 | },
35 | resolve: {
36 | modules: ['node_modules', paths.appNodeModules].concat(
37 | process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
38 | ),
39 | plugins: [
40 | new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson])
41 | ]
42 | },
43 | module: {
44 | rules: [{
45 | test: /\.(css|less)$/,
46 | use: [
47 | require.resolve('style-loader'),
48 | {
49 | loader: require.resolve('css-loader'),
50 | options: {
51 | importLoaders: 1
52 | }
53 | }, {
54 | loader: require.resolve('postcss-loader'),
55 | options: {
56 | ident: 'postcss',
57 | plugins: () => [
58 | require('postcss-flexbugs-fixes'),
59 | autoprefixer({
60 | browsers: [
61 | '>1%',
62 | 'last 4 versions',
63 | 'Firefox ESR',
64 | 'not ie < 9' // React doesn't support IE8 anyway
65 | ],
66 | flexbox: 'no-2009'
67 | })
68 | ]
69 | }
70 | }, {
71 | loader: require.resolve('less-loader') // compiles Less to CSS
72 | }
73 | ]
74 | }]
75 | },
76 | plugins: [
77 | new InterpolateHtmlPlugin(env.raw),
78 | new HtmlWebpackPlugin({
79 | inject: true,
80 | template: paths.appHtml,
81 | }),
82 | new webpack.NamedModulesPlugin(),
83 | new webpack.DefinePlugin(env.stringified),
84 | new webpack.HotModuleReplacementPlugin(),
85 | new CaseSensitivePathsPlugin(),
86 | new WatchMissingNodeModulesPlugin(paths.appNodeModules),
87 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
88 | ],
89 | performance: {
90 | hints: false
91 | }
92 | })
93 |
--------------------------------------------------------------------------------
/docs/service-worker.js:
--------------------------------------------------------------------------------
1 | "use strict";var precacheConfig=[["/cnode-react/0689b7edca8c767ca8ab.worker.js","cb8d82f5781b4a88fe445d7acb41468a"],["/cnode-react/index.html","a241c60eaf0e4dda6d43e687dc593454"],["/cnode-react/static/css/main.4ac32dfd.css","4ac32dfd87e59f13abc06fd71d88d7d8"],["/cnode-react/static/js/main.8cad8da0.js","13d458666e5ed13785ac3176ea1f596a"]],cacheName="sw-precache-v3-sw-precache-webpack-plugin-"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var n=new URL(e);return"/"===n.pathname.slice(-1)&&(n.pathname+=t),n.toString()},cleanResponse=function(t){return t.redirected?("body"in t?Promise.resolve(t.body):t.blob()).then(function(e){return new Response(e,{headers:t.headers,status:t.status,statusText:t.statusText})}):Promise.resolve(t)},createCacheKey=function(e,t,n,r){var a=new URL(e);return r&&a.pathname.match(r)||(a.search+=(a.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(n)),a.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var n=new URL(t).pathname;return e.some(function(e){return n.match(e)})},stripIgnoredUrlParameters=function(e,n){var t=new URL(e);return t.hash="",t.search=t.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(t){return n.every(function(e){return!e.test(t[0])})}).map(function(e){return e.join("=")}).join("&"),t.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],n=e[1],r=new URL(t,self.location),a=createCacheKey(r,hashParamName,n,/\.\w{8}\./);return[r.toString(),a]}));function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(r){return setOfCachedUrls(r).then(function(n){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(t){if(!n.has(t)){var e=new Request(t,{credentials:"same-origin"});return fetch(e).then(function(e){if(!e.ok)throw new Error("Request for "+t+" returned a response with status "+e.status);return cleanResponse(e).then(function(e){return r.put(t,e)})})}}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var n=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(t){return t.keys().then(function(e){return Promise.all(e.map(function(e){if(!n.has(e.url))return t.delete(e)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(t){if("GET"===t.request.method){var e,n=stripIgnoredUrlParameters(t.request.url,ignoreUrlParametersMatching),r="index.html";(e=urlsToCacheKeys.has(n))||(n=addDirectoryIndex(n,r),e=urlsToCacheKeys.has(n));var a="/cnode-react/index.html";!e&&"navigate"===t.request.mode&&isPathWhitelisted(["^(?!\\/__).*"],t.request.url)&&(n=new URL(a,self.location).toString(),e=urlsToCacheKeys.has(n)),e&&t.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(n)).then(function(e){if(e)return e;throw Error("The cached response that was expected is missing.")})}).catch(function(e){return console.warn('Couldn\'t serve response for "%s" from cache: %O',t.request.url,e),fetch(t.request)}))}});
--------------------------------------------------------------------------------
/src/components/TopicDetail/topicDetail.less:
--------------------------------------------------------------------------------
1 | // 帖子详情
2 | .topic_detail {
3 | padding: 5px;
4 | .topic_header {
5 | padding: 10px;
6 | margin-top: 10px;
7 | border-bottom: 1px #e0e0e0 solid;
8 | background-color: #fff;
9 | .topic_title {
10 | max-height: 40px;
11 | line-height: 20px;
12 | font-size: 18px;
13 | overflow: hidden;
14 | }
15 | .topic_info {
16 | height: 20px;
17 | line-height: 20px;
18 | margin-top: 5px;
19 | span {
20 | margin-right: 4px;
21 | font-size: 10px;
22 | color: #999;
23 | &:nth-child(2) {
24 | color: #666;
25 | }
26 | }
27 | }
28 | }
29 |
30 | .topic_body {
31 | padding: 10px;
32 | margin-top: 5px;
33 | background-color: #fff;
34 | }
35 |
36 | .topic_comment {
37 | margin-top: 10px;
38 | }
39 |
40 | .comment_header {
41 | height: 40px;
42 | line-height: 40px;
43 | padding: 0 10px;
44 | font-size: 16px;
45 | color: #333;
46 | background-color: #fff;
47 | } // 帖子评论
48 | .comment_list {
49 | margin-top: 5px;
50 | }
51 | .comment_item {
52 | margin-top: 5px;
53 | padding: 10px;
54 | border-top: 1px solid #f0f0f0;
55 | background-color: #fff;
56 | .meta_info {
57 | display: flex;
58 | justify-content: space-between;
59 | align-items: center;
60 | }
61 | .user_action {
62 | display: flex;
63 | span {
64 | height: 14px;
65 | line-height: 14px;
66 | color: #999;
67 | font-size: 14px;
68 | }
69 | .action_item {
70 | margin-left: 5px;
71 | }
72 | .liked {
73 | .icon-thumbup,
74 | .up_number {
75 | color: #6cf;
76 | }
77 | }
78 | }
79 | .user_info {
80 | display: flex;
81 | align-items: center;
82 | height: 30px;
83 | }
84 | .user_avatar {
85 | position: relative;
86 | width: 30px;
87 | margin-right: 10px;
88 | border-radius: 50%;
89 | overflow: hidden;
90 | &:after {
91 | content: '';
92 | display: block;
93 | width: 100%;
94 | padding-top: 100%;
95 | }
96 | img {
97 | position: absolute;
98 | top: 0;
99 | left: 0;
100 | width: 100%;
101 | }
102 | }
103 | .user_name {
104 | margin-right: 5px;
105 | font-size: 10px;
106 | color: #666;
107 | }
108 | .time_stamp {
109 | font-size: 10px;
110 | color: #999;
111 | }
112 | .up_number {
113 | margin-left: 3px;
114 | }
115 | .floor {
116 | margin-left: 4px;
117 | font-size: 10px;
118 | color: #999;
119 | }
120 | .comment_content {
121 | margin-top: 6px;
122 | .markdown-text {
123 | // display: none;
124 | }
125 | }
126 | }
127 | }
128 |
129 | .topic_reply {
130 | margin-top: 10px;
131 | background-color: #fff;
132 | p {
133 | height: 40px;
134 | line-height: 40px;
135 | padding: 0 10px;
136 | font-size: 16px;
137 | color: #333;
138 | }
139 | .form {
140 | padding: 5px 10px;
141 | font-size: 14px;
142 | }
143 | textarea {
144 | display: block;
145 | width: 100%;
146 | height: 140px;
147 | outline: none;
148 | border: none;
149 | resize: none;
150 | }
151 | .button {
152 | margin-top: 5px;
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/src/actions/topics.js:
--------------------------------------------------------------------------------
1 | import Worker from '@/hl.worker.js'
2 | import { handleResponse, handleError } from '@/middleWares'
3 | import { domain } from '@/constants'
4 |
5 | export const COLLECT_TOPIC = 'COLLECT_TOPIC'
6 | export const collectTopic = ({ accesstoken, topic_id, is_collect }) => dispatch => {
7 | const url = `${domain}topic_collect/${is_collect ? 'de_' : ''}collect`
8 | fetch(url, {
9 | method: 'POST',
10 | body: JSON.stringify({ accesstoken, topic_id }),
11 | headers: new Headers({
12 | 'Content-Type': 'application/json'
13 | })
14 | })
15 | .then(handleResponse)
16 | .then(res => {
17 | if (res.success) {
18 | dispatch({
19 | type: COLLECT_TOPIC,
20 | action: is_collect
21 | })
22 | }
23 | })
24 | }
25 |
26 | export const LIKE_REPLY = 'LIKE_REPLY'
27 | export const likeComment = ({ id, accesstoken }) => dispatch => {
28 | id = String(id)
29 | fetch(`${domain}reply/${id}/ups`, {
30 | method: 'POST',
31 | body: JSON.stringify({ accesstoken }),
32 | headers: new Headers({
33 | 'Content-Type': 'application/json'
34 | })
35 | })
36 | .then(handleResponse)
37 | .then(res => {
38 | if (res.success) {
39 | dispatch({
40 | type: LIKE_REPLY,
41 | action: res.action,
42 | id
43 | })
44 | }
45 | })
46 | }
47 |
48 | export const submitReply = ({ accesstoken, topic_id, content }) => dispatch => {
49 | return fetch(`${domain}topic/${topic_id}/replies`, {
50 | method: 'POST',
51 | body: JSON.stringify({ accesstoken, content }),
52 | headers: new Headers({
53 | 'Content-Type': 'application/json'
54 | })
55 | })
56 | .then(handleResponse)
57 | }
58 |
59 | const _fetchTopics = (page, tab, mutation, dispatch) => {
60 | fetch(`${domain}topics?page=${page}&tab=${tab}`)
61 | .then(handleResponse)
62 | .then(({ data }) => {
63 | dispatch({
64 | type: mutation,
65 | data: {
66 | list: data,
67 | page: page + 1,
68 | tab
69 | }
70 | })
71 | }).catch(handleError)
72 | }
73 |
74 | export const FETCH_TOPICS = 'FETCH_TOPICS'
75 | export const fetchTopics = ({ tab, page }) => dispatch => {
76 | _fetchTopics(page, tab, FETCH_TOPICS, dispatch)
77 | }
78 |
79 | export const FETCH_MORE_TOPICS = 'FETCH_MORE_TOPICS'
80 | export const fetchMoreTopics = ({ tab, page }) => dispatch => {
81 | _fetchTopics(page, tab, FETCH_MORE_TOPICS, dispatch)
82 | }
83 |
84 | export const RESET_PAGE = 'RESET_PAGE'
85 | export const resetPage = () => dispatch => {
86 | dispatch({
87 | type: RESET_PAGE
88 | })
89 | }
90 |
91 | export const FETCH_TOPIC_DETAIL = 'FETCH_TOPIC_DETAIL'
92 | export const fetchTopicDetail = ({ id, accesstoken }) => dispatch => {
93 | fetch(`${domain}topic/${id}/?accesstoken=${accesstoken}`)
94 | .then(handleResponse)
95 | .then(({ data }) => {
96 | // dispatch first for pre-rendering
97 | dispatch({
98 | type: FETCH_TOPIC_DETAIL,
99 | data
100 | })
101 |
102 | // highlight code, add hash to user link and re-render
103 | const _data = Object.assign({}, data)
104 | const worker = new Worker()
105 | worker.onmessage = e => {
106 | console.timeEnd('worker')
107 | dispatch({
108 | type: FETCH_TOPIC_DETAIL,
109 | data: e.data
110 | })
111 | }
112 | console.time('worker')
113 | worker.postMessage(_data)
114 | })
115 | }
116 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cnode-react",
3 | "version": "0.1.1",
4 | "private": true,
5 | "dependencies": {
6 | "format-publish-date": "^0.0.3",
7 | "highlight.js": "^9.12.0",
8 | "normalize.css": "^8.0.0",
9 | "react": "^16.2.0",
10 | "react-dom": "^16.2.0",
11 | "react-redux": "^5.0.7",
12 | "react-router-dom": "^4.2.2",
13 | "redux": "^3.7.2",
14 | "redux-thunk": "^2.2.0",
15 | "unescape-alltypes-html": "0.1.1",
16 | "zoomme": "0.0.3"
17 | },
18 | "devDependencies": {
19 | "autoprefixer": "7.1.6",
20 | "babel-core": "6.26.0",
21 | "babel-eslint": "7.2.3",
22 | "babel-jest": "20.0.3",
23 | "babel-loader": "7.1.2",
24 | "babel-preset-react-app": "^3.1.1",
25 | "babel-runtime": "6.26.0",
26 | "case-sensitive-paths-webpack-plugin": "2.1.1",
27 | "chalk": "1.1.3",
28 | "css-loader": "0.28.7",
29 | "dotenv": "4.0.0",
30 | "dotenv-expand": "4.2.0",
31 | "eslint": "4.10.0",
32 | "eslint-config-react-app": "^2.1.0",
33 | "eslint-loader": "1.9.0",
34 | "eslint-plugin-flowtype": "2.39.1",
35 | "eslint-plugin-import": "2.8.0",
36 | "eslint-plugin-jsx-a11y": "5.1.1",
37 | "eslint-plugin-react": "7.4.0",
38 | "extract-text-webpack-plugin": "3.0.2",
39 | "file-loader": "1.1.5",
40 | "fs-extra": "3.0.1",
41 | "html-webpack-plugin": "2.29.0",
42 | "jest": "20.0.4",
43 | "less": "^3.0.1",
44 | "less-loader": "^4.1.0",
45 | "object-assign": "4.1.1",
46 | "postcss-flexbugs-fixes": "3.2.0",
47 | "postcss-loader": "2.0.8",
48 | "promise": "8.0.1",
49 | "raf": "3.4.0",
50 | "react-dev-utils": "^5.0.0",
51 | "style-loader": "0.19.0",
52 | "sw-precache-webpack-plugin": "0.11.4",
53 | "url-loader": "0.6.2",
54 | "webpack": "3.8.1",
55 | "webpack-dev-server": "2.9.4",
56 | "webpack-manifest-plugin": "1.3.2",
57 | "webpack-merge": "^4.1.2",
58 | "whatwg-fetch": "2.0.3",
59 | "worker-loader": "^1.1.1"
60 | },
61 | "scripts": {
62 | "start": "node scripts/start.js",
63 | "build": "node scripts/build.js",
64 | "test": "node scripts/test.js --env=jsdom"
65 | },
66 | "jest": {
67 | "collectCoverageFrom": [
68 | "src/**/*.{js,jsx,mjs}"
69 | ],
70 | "setupFiles": [
71 | "/config/polyfills.js"
72 | ],
73 | "testMatch": [
74 | "/src/**/__tests__/**/*.{js,jsx,mjs}",
75 | "/src/**/?(*.)(spec|test).{js,jsx,mjs}"
76 | ],
77 | "testEnvironment": "node",
78 | "testURL": "http://localhost",
79 | "transform": {
80 | "^.+\\.(js|jsx|mjs)$": "/node_modules/babel-jest",
81 | "^.+\\.css$": "/config/jest/cssTransform.js",
82 | "^(?!.*\\.(js|jsx|mjs|css|json)$)": "/config/jest/fileTransform.js"
83 | },
84 | "transformIgnorePatterns": [
85 | "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$"
86 | ],
87 | "moduleNameMapper": {
88 | "^react-native$": "react-native-web"
89 | },
90 | "moduleFileExtensions": [
91 | "web.js",
92 | "mjs",
93 | "js",
94 | "json",
95 | "web.jsx",
96 | "jsx",
97 | "node"
98 | ]
99 | },
100 | "babel": {
101 | "presets": [
102 | "react-app"
103 | ]
104 | },
105 | "eslintConfig": {
106 | "extends": "react-app"
107 | },
108 | "repository": {
109 | "type": "git",
110 | "url": "git+https://github.com/stop2stare/cnode-react.git"
111 | },
112 | "keywords": [
113 | "mvvm"
114 | ],
115 | "author": "liucheng",
116 | "license": "MIT",
117 | "bugs": {
118 | "url": "https://github.com/stop2stare/cnode-react/issues"
119 | },
120 | "homepage": "https://github.com/stop2stare/cnode-react#readme"
121 | }
122 |
--------------------------------------------------------------------------------
/scripts/start.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | // Do this as the first thing so that any code reading it knows the right env.
4 | process.env.BABEL_ENV = 'development';
5 | process.env.NODE_ENV = 'development';
6 |
7 | // Makes the script crash on unhandled rejections instead of silently
8 | // ignoring them. In the future, promise rejections that are not handled will
9 | // terminate the Node.js process with a non-zero exit code.
10 | process.on('unhandledRejection', err => {
11 | throw err;
12 | });
13 |
14 | // Ensure environment variables are read.
15 | require('../config/env');
16 |
17 | const fs = require('fs');
18 | const chalk = require('chalk');
19 | const webpack = require('webpack');
20 | const WebpackDevServer = require('webpack-dev-server');
21 | const clearConsole = require('react-dev-utils/clearConsole');
22 | const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
23 | const {
24 | choosePort,
25 | createCompiler,
26 | prepareProxy,
27 | prepareUrls,
28 | } = require('react-dev-utils/WebpackDevServerUtils');
29 | const openBrowser = require('react-dev-utils/openBrowser');
30 | const paths = require('../config/paths');
31 | const config = require('../config/webpack.config.dev');
32 | const createDevServerConfig = require('../config/webpackDevServer.config');
33 |
34 | const useYarn = fs.existsSync(paths.yarnLockFile);
35 | const isInteractive = process.stdout.isTTY;
36 |
37 | // Warn and crash if required files are missing
38 | if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
39 | process.exit(1);
40 | }
41 |
42 | // Tools like Cloud9 rely on this.
43 | const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000;
44 | const HOST = process.env.HOST || '0.0.0.0';
45 |
46 | if (process.env.HOST) {
47 | console.log(
48 | chalk.cyan(
49 | `Attempting to bind to HOST environment variable: ${chalk.yellow(
50 | chalk.bold(process.env.HOST)
51 | )}`
52 | )
53 | );
54 | console.log(
55 | `If this was unintentional, check that you haven't mistakenly set it in your shell.`
56 | );
57 | console.log(`Learn more here: ${chalk.yellow('http://bit.ly/2mwWSwH')}`);
58 | console.log();
59 | }
60 |
61 | // We attempt to use the default port but if it is busy, we offer the user to
62 | // run on a different port. `choosePort()` Promise resolves to the next free port.
63 | choosePort(HOST, DEFAULT_PORT)
64 | .then(port => {
65 | if (port == null) {
66 | // We have not found a port.
67 | return;
68 | }
69 | const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
70 | const appName = require(paths.appPackageJson).name;
71 | const urls = prepareUrls(protocol, HOST, port);
72 | // Create a webpack compiler that is configured with custom messages.
73 | const compiler = createCompiler(webpack, config, appName, urls, useYarn);
74 | // Load proxy config
75 | const proxySetting = require(paths.appPackageJson).proxy;
76 | const proxyConfig = prepareProxy(proxySetting, paths.appPublic);
77 | // Serve webpack assets generated by the compiler over a web sever.
78 | const serverConfig = createDevServerConfig(
79 | proxyConfig,
80 | urls.lanUrlForConfig
81 | );
82 | const devServer = new WebpackDevServer(compiler, serverConfig);
83 | // Launch WebpackDevServer.
84 | devServer.listen(port, HOST, err => {
85 | if (err) {
86 | return console.log(err);
87 | }
88 | if (isInteractive) {
89 | clearConsole();
90 | }
91 | console.log(chalk.cyan('Starting the development server...\n'));
92 | openBrowser(urls.localUrlForBrowser);
93 | });
94 |
95 | ['SIGINT', 'SIGTERM'].forEach(function(sig) {
96 | process.on(sig, function() {
97 | devServer.close();
98 | process.exit();
99 | });
100 | });
101 | })
102 | .catch(err => {
103 | if (err && err.message) {
104 | console.log(err.message);
105 | }
106 | process.exit(1);
107 | });
108 |
--------------------------------------------------------------------------------
/src/public/styles/markdown.less:
--------------------------------------------------------------------------------
1 | // markdown
2 | .markdown-text {
3 | ul,
4 | ol {
5 | padding: 8px;
6 | background-color: #efefef;
7 | }
8 | p,
9 | li {
10 | line-height: 20px;
11 | margin-top: 15px;
12 | font-size: 14px;
13 | color: #333;
14 | white-space: pre-wrap;
15 | word-wrap: break-word;
16 | &:first-child {
17 | margin-top: 0;
18 | }
19 | img {
20 | display: block;
21 | max-width: 100%;
22 | }
23 | }
24 | a {
25 | color: #06c;
26 | }
27 | img {
28 | display: block;
29 | max-width: 100%;
30 | }
31 | pre {
32 | padding: 7px;
33 | margin: 4px 0;
34 | background-color: #f3f3f3;
35 | font-size: 12px;
36 | }
37 | h1 {
38 | margin-top: 20px;
39 | line-height: 24px;
40 | font-size: 20px;
41 | color: #000;
42 | &~p,
43 | &~ul {
44 | margin-top: 5px;
45 | }
46 | }
47 | h2 {
48 | margin-top: 18px;
49 | line-height: 22px;
50 | font-size: 18px;
51 | color: #000;
52 | &~p,
53 | &~ul {
54 | margin-top: 5px;
55 | }
56 | }
57 | h3 {
58 | margin-top: 18px;
59 | line-height: 20px;
60 | font-size: 16px;
61 | font-weight: normal;
62 | color: #333;
63 | &~p,
64 | &~ul {
65 | margin-top: 5px;
66 | }
67 | }
68 | h4 {
69 | margin-top: 16px;
70 | line-height: 18px;
71 | font-size: 14px;
72 | font-weight: normal;
73 | color: #333;
74 | &~p,
75 | &~ul {
76 | margin-top: 5px;
77 | }
78 | }
79 | h5 {
80 | margin-top: 14px;
81 | line-height: 16px;
82 | font-size: 12px;
83 | font-weight: normal;
84 | color: #666;
85 | &~p,
86 | &~ul {
87 | margin-top: 5px;
88 | }
89 | }
90 | blockquote {
91 | padding: 0 0 0 15px;
92 | margin: 20px 0;
93 | border-left: 5px solid #eee;
94 | }
95 | hr {
96 | margin-top: 20px;
97 | }
98 | table {
99 | max-width: 100%;
100 | border-collapse: collapse;
101 | border-spacing: 0;
102 | tr {
103 | margin: 0;
104 | padding: 0;
105 | border-top: 1px solid #ccc;
106 | background-color: #fff;
107 | }
108 | th,
109 | td {
110 | margin: 0;
111 | padding: 6px 13px;
112 | border: 1px solid #ccc;
113 | text-align: left;
114 | }
115 | }
116 | code,
117 | pre {
118 | padding: 0 3px 2px;
119 | font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
120 | font-size: 12px;
121 | color: #444;
122 | background-color: #f7f7f9;
123 | border-radius: 3px;
124 | }
125 | pre.prettyprint {
126 | font-size: 14px;
127 | border-radius: 0;
128 | padding: 5px;
129 | border: none;
130 | margin: 10px 0;
131 | border-width: 1px 0;
132 | background: #f7f7f7;
133 | overflow-x: scroll;
134 | }
135 | .pln {
136 | color: #000
137 | }
138 | @media screen {
139 | .str {
140 | color: #080
141 | }
142 | .kwd {
143 | color: #008
144 | }
145 | .com {
146 | color: #800
147 | }
148 | .typ {
149 | color: #606
150 | }
151 | .lit {
152 | color: #066
153 | }
154 | .clo,
155 | .opn,
156 | .pun {
157 | color: #660
158 | }
159 | .tag {
160 | color: #008
161 | }
162 | .atn {
163 | color: #606
164 | }
165 | .atv {
166 | color: #080
167 | }
168 | .dec,
169 | .var {
170 | color: #606
171 | }
172 | .fun {
173 | color: red
174 | }
175 | }
176 | @media print,
177 | projection {
178 | .kwd,
179 | .tag,
180 | .typ {
181 | font-weight: 700
182 | }
183 | .str {
184 | color: #060
185 | }
186 | .kwd {
187 | color: #006
188 | }
189 | .com {
190 | color: #600;
191 | font-style: italic
192 | }
193 | .typ {
194 | color: #404
195 | }
196 | .lit {
197 | color: #044
198 | }
199 | .clo,
200 | .opn,
201 | .pun {
202 | color: #440
203 | }
204 | .tag {
205 | color: #006
206 | }
207 | .atn {
208 | color: #404
209 | }
210 | .atv {
211 | color: #060
212 | }
213 | }
214 | li.L1,
215 | li.L3,
216 | li.L5,
217 | li.L7,
218 | li.L9 {
219 | background: #eee
220 | }
221 | }
222 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return;
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (isLocalhost) {
36 | // This is running on localhost. Lets check if a service worker still exists or not.
37 | checkValidServiceWorker(swUrl);
38 |
39 | // Add some additional logging to localhost, pointing developers to the
40 | // service worker/PWA documentation.
41 | navigator.serviceWorker.ready.then(() => {
42 | console.log(
43 | 'This web app is being served cache-first by a service ' +
44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ'
45 | );
46 | });
47 | } else {
48 | // Is not local host. Just register service worker
49 | registerValidSW(swUrl);
50 | }
51 | });
52 | }
53 | }
54 |
55 | function registerValidSW(swUrl) {
56 | navigator.serviceWorker
57 | .register(swUrl)
58 | .then(registration => {
59 | registration.onupdatefound = () => {
60 | const installingWorker = registration.installing;
61 | installingWorker.onstatechange = () => {
62 | if (installingWorker.state === 'installed') {
63 | if (navigator.serviceWorker.controller) {
64 | // At this point, the old content will have been purged and
65 | // the fresh content will have been added to the cache.
66 | // It's the perfect time to display a "New content is
67 | // available; please refresh." message in your web app.
68 | console.log('New content is available; please refresh.');
69 | } else {
70 | // At this point, everything has been precached.
71 | // It's the perfect time to display a
72 | // "Content is cached for offline use." message.
73 | console.log('Content is cached for offline use.');
74 | }
75 | }
76 | };
77 | };
78 | })
79 | .catch(error => {
80 | console.error('Error during service worker registration:', error);
81 | });
82 | }
83 |
84 | function checkValidServiceWorker(swUrl) {
85 | // Check if the service worker can be found. If it can't reload the page.
86 | fetch(swUrl)
87 | .then(response => {
88 | // Ensure service worker exists, and that we really are getting a JS file.
89 | if (
90 | response.status === 404 ||
91 | response.headers.get('content-type').indexOf('javascript') === -1
92 | ) {
93 | // No service worker found. Probably a different app. Reload the page.
94 | navigator.serviceWorker.ready.then(registration => {
95 | registration.unregister().then(() => {
96 | window.location.reload();
97 | });
98 | });
99 | } else {
100 | // Service worker found. Proceed as normal.
101 | registerValidSW(swUrl);
102 | }
103 | })
104 | .catch(() => {
105 | console.log(
106 | 'No internet connection found. App is running in offline mode.'
107 | );
108 | });
109 | }
110 |
111 | export function unregister() {
112 | if ('serviceWorker' in navigator) {
113 | navigator.serviceWorker.ready.then(registration => {
114 | registration.unregister();
115 | });
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/components/User/index.jsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | Component
3 | } from 'react'
4 | import {
5 | Link
6 | } from 'react-router-dom'
7 | import {
8 | connect
9 | } from 'react-redux'
10 | import {
11 | bindActionCreators
12 | } from 'redux'
13 | import {
14 | fetchUser,
15 | fetchSelf,
16 | logout
17 | } from 'actions/users'
18 | import './user'
19 | import formatter from 'format-publish-date'
20 |
21 | const format = raw => formatter(new Date(raw))
22 |
23 | class NavBar extends Component {
24 | constructor() {
25 | super()
26 | this.state = {
27 | isSelf: false
28 | }
29 | }
30 | getInfo({
31 | match,
32 | self
33 | }) {
34 | const name = match.params.name
35 | if (name === self.loginname) {
36 | this.setState({
37 | isSelf: true
38 | })
39 | } else {
40 | this.props.fetchUser(name)
41 | }
42 | }
43 | componentWillMount() {
44 | this.getInfo(this.props)
45 | }
46 | componentWillReceiveProps(nextProps) {
47 | this.getInfo(nextProps)
48 | }
49 | handleLogout() {
50 | this.props.logout().then(rs => {
51 | this.props.history.push('/')
52 | localStorage.removeItem('cnode')
53 | localStorage.removeItem('accesstoken')
54 | })
55 | }
56 | render() {
57 | const user = this.state.isSelf ? this.props.self : this.props.user
58 | const {
59 | recent_topics,
60 | recent_replies
61 | } = user
62 | const topics = recent_topics.map(topic => {
63 | return (
64 |
65 |
66 |
67 |
68 |
69 |
70 | { topic.title }
71 |
72 |
73 | { format(topic.last_reply_at) }
74 |
75 | )
76 | })
77 | const replies = recent_replies.map(reply => {
78 | return (
79 |
80 |
81 |
82 |
83 |
84 |
85 | { reply.title }
86 |
87 |
88 | { format(reply.last_reply_at) }
89 |
90 | )
91 | })
92 | const signout =
93 |
94 | 登出
95 | 查看消息
96 |
97 | return (
98 |
99 |
100 | 个人简介
101 |
102 |
103 |
104 |
105 |
106 | { user.loginname }
107 | { this.state.isSelf && signout }
108 |
109 | github名称:{ user.githubUsername }
110 | 注册于:{ format(user.create_at) }
111 | 积分:{ user.score }
112 | {
113 | this.state.isSelf &&
114 |
115 | 未读消息:{/*{ unread }*/}
116 |
117 | }
118 |
119 |
120 |
121 | 最近参与的话题
122 | {
123 | recent_topics.length === 0 ?
124 | 最近没有参与话题
:
125 |
126 | {topics}
127 |
128 | }
129 |
130 |
131 | 最近回复的话题
132 | {
133 | recent_replies.length === 0 ?
134 | 最近没有参与话题
:
135 |
136 | {replies}
137 |
138 | }
139 |
140 |
141 | )
142 | }
143 | }
144 |
145 | function mapStateToProps(state) {
146 | return {
147 | user: state.user,
148 | self: state.self
149 | }
150 | }
151 |
152 | function mapDispatchToProps(dispatch) {
153 | return bindActionCreators({
154 | fetchUser,
155 | fetchSelf,
156 | logout
157 | }, dispatch)
158 | }
159 |
160 | export default connect(mapStateToProps, mapDispatchToProps)(NavBar)
--------------------------------------------------------------------------------
/config/webpack.config.prod.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const autoprefixer = require('autoprefixer')
4 | const path = require('path')
5 | const webpack = require('webpack')
6 | const HtmlWebpackPlugin = require('html-webpack-plugin')
7 | const ExtractTextPlugin = require('extract-text-webpack-plugin')
8 | const ManifestPlugin = require('webpack-manifest-plugin')
9 | const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin')
10 | const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin')
11 | const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin')
12 | const paths = require('./paths')
13 | const getClientEnvironment = require('./env')
14 | const merge = require('webpack-merge')
15 | const config = require('./webpack.config')
16 |
17 | const publicPath = paths.servedPath
18 |
19 | const shouldUseRelativeAssetPaths = publicPath === './'
20 | const publicUrl = publicPath.slice(0, -1)
21 | const env = getClientEnvironment(publicUrl)
22 |
23 | if (env.stringified['process.env'].NODE_ENV !== '"production"') {
24 | throw new Error('Production builds must have NODE_ENV=production.')
25 | }
26 |
27 | const cssFilename = 'static/css/[name].[contenthash:8].css'
28 |
29 | const extractTextPluginOptions = shouldUseRelativeAssetPaths ? { publicPath: Array(cssFilename.split('/').length).join('../') } : {}
30 |
31 | module.exports = merge(config, {
32 | bail: true,
33 | devtool: false,
34 | entry: [require.resolve('./polyfills'), paths.appIndexJs],
35 | output: {
36 | path: paths.appBuild,
37 | filename: 'static/js/[name].[chunkhash:8].js',
38 | chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js',
39 | publicPath: publicPath,
40 | devtoolModuleFilenameTemplate: info =>
41 | path
42 | .relative(paths.appSrc, info.absoluteResourcePath)
43 | .replace(/\\/g, '/')
44 | },
45 | resolve: {
46 | modules: ['node_modules', paths.appNodeModules].concat(
47 | process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
48 | ),
49 | plugins: [
50 | new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson])
51 | ]
52 | },
53 | module: {
54 | strictExportPresence: true,
55 | rules: [{
56 | test: /\.(css|less)$/,
57 | loader: ExtractTextPlugin.extract(
58 | Object.assign({
59 | fallback: {
60 | loader: require.resolve('style-loader'),
61 | options: {
62 | hmr: false
63 | }
64 | },
65 | use: [{
66 | loader: require.resolve('css-loader'),
67 | options: {
68 | importLoaders: 1,
69 | minimize: true,
70 | sourceMap: false,
71 | }
72 | }, {
73 | loader: require.resolve('postcss-loader'),
74 | options: {
75 | ident: 'postcss',
76 | plugins: () => [
77 | require('postcss-flexbugs-fixes'),
78 | autoprefixer({
79 | browsers: [
80 | '>1%',
81 | 'last 4 versions',
82 | 'Firefox ESR',
83 | 'not ie < 9', // React doesn't support IE8 anyway
84 | ],
85 | flexbox: 'no-2009'
86 | })
87 | ]
88 | }
89 | }, {
90 | loader: require.resolve('less-loader') // compiles Less to CSS
91 | }]
92 | },
93 | extractTextPluginOptions
94 | )
95 | )
96 | }]
97 | },
98 | plugins: [
99 | new InterpolateHtmlPlugin(env.raw),
100 | new HtmlWebpackPlugin({
101 | inject: true,
102 | template: paths.appHtml,
103 | minify: {
104 | removeComments: true,
105 | collapseWhitespace: true,
106 | removeRedundantAttributes: true,
107 | useShortDoctype: true,
108 | removeEmptyAttributes: true,
109 | removeStyleLinkTypeAttributes: true,
110 | keepClosingSlash: true,
111 | minifyJS: true,
112 | minifyCSS: true,
113 | minifyURLs: true,
114 | },
115 | }),
116 | new webpack.DefinePlugin(env.stringified),
117 | new webpack.optimize.UglifyJsPlugin({
118 | compress: {
119 | warnings: false,
120 | comparisons: false,
121 | },
122 | mangle: {
123 | safari10: true,
124 | },
125 | output: {
126 | comments: false,
127 | ascii_only: true,
128 | },
129 | sourceMap: false,
130 | }),
131 | new ExtractTextPlugin({
132 | filename: cssFilename,
133 | }),
134 | new ManifestPlugin({
135 | fileName: 'asset-manifest.json',
136 | }),
137 | new SWPrecacheWebpackPlugin({
138 | dontCacheBustUrlsMatching: /\.\w{8}\./,
139 | filename: 'service-worker.js',
140 | logger(message) {
141 | if (message.indexOf('Total precache size is') === 0) {
142 | return
143 | }
144 | if (message.indexOf('Skipping static resource') === 0) {
145 | return
146 | }
147 | console.log(message)
148 | },
149 | minify: true,
150 | navigateFallback: publicUrl + '/index.html',
151 | navigateFallbackWhitelist: [/^(?!\/__).*/],
152 | staticFileGlobsIgnorePatterns: [/\.map$/, /asset-manifest\.json$/]
153 | }),
154 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
155 | ]
156 | })
157 |
--------------------------------------------------------------------------------
/scripts/build.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | // Do this as the first thing so that any code reading it knows the right env.
4 | process.env.BABEL_ENV = 'production';
5 | process.env.NODE_ENV = 'production';
6 |
7 | // Makes the script crash on unhandled rejections instead of silently
8 | // ignoring them. In the future, promise rejections that are not handled will
9 | // terminate the Node.js process with a non-zero exit code.
10 | process.on('unhandledRejection', err => {
11 | throw err;
12 | });
13 |
14 | // Ensure environment variables are read.
15 | require('../config/env');
16 |
17 | const path = require('path');
18 | const chalk = require('chalk');
19 | const fs = require('fs-extra');
20 | const webpack = require('webpack');
21 | const config = require('../config/webpack.config.prod');
22 | const paths = require('../config/paths');
23 | const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
24 | const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
25 | const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
26 | const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
27 | const printBuildError = require('react-dev-utils/printBuildError');
28 |
29 | const measureFileSizesBeforeBuild =
30 | FileSizeReporter.measureFileSizesBeforeBuild;
31 | const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild;
32 | const useYarn = fs.existsSync(paths.yarnLockFile);
33 |
34 | // These sizes are pretty large. We'll warn for bundles exceeding them.
35 | const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
36 | const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
37 |
38 | // Warn and crash if required files are missing
39 | if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
40 | process.exit(1);
41 | }
42 |
43 | // First, read the current file sizes in build directory.
44 | // This lets us display how much they changed later.
45 | measureFileSizesBeforeBuild(paths.appBuild)
46 | .then(previousFileSizes => {
47 | // Remove all content but keep the directory so that
48 | // if you're in it, you don't end up in Trash
49 | fs.emptyDirSync(paths.appBuild);
50 | // Merge with the public folder
51 | copyPublicFolder();
52 | // Start the webpack build
53 | return build(previousFileSizes);
54 | })
55 | .then(
56 | ({ stats, previousFileSizes, warnings }) => {
57 | if (warnings.length) {
58 | console.log(chalk.yellow('Compiled with warnings.\n'));
59 | console.log(warnings.join('\n\n'));
60 | console.log(
61 | '\nSearch for the ' +
62 | chalk.underline(chalk.yellow('keywords')) +
63 | ' to learn more about each warning.'
64 | );
65 | console.log(
66 | 'To ignore, add ' +
67 | chalk.cyan('// eslint-disable-next-line') +
68 | ' to the line before.\n'
69 | );
70 | } else {
71 | console.log(chalk.green('Compiled successfully.\n'));
72 | }
73 |
74 | console.log('File sizes after gzip:\n');
75 | printFileSizesAfterBuild(
76 | stats,
77 | previousFileSizes,
78 | paths.appBuild,
79 | WARN_AFTER_BUNDLE_GZIP_SIZE,
80 | WARN_AFTER_CHUNK_GZIP_SIZE
81 | );
82 | console.log();
83 |
84 | const appPackage = require(paths.appPackageJson);
85 | const publicUrl = paths.publicUrl;
86 | const publicPath = config.output.publicPath;
87 | const buildFolder = path.relative(process.cwd(), paths.appBuild);
88 | printHostingInstructions(
89 | appPackage,
90 | publicUrl,
91 | publicPath,
92 | buildFolder,
93 | useYarn
94 | );
95 | },
96 | err => {
97 | console.log(chalk.red('Failed to compile.\n'));
98 | printBuildError(err);
99 | process.exit(1);
100 | }
101 | );
102 |
103 | // Create the production build and print the deployment instructions.
104 | function build(previousFileSizes) {
105 | console.log('Creating an optimized production build...');
106 |
107 | let compiler = webpack(config);
108 | return new Promise((resolve, reject) => {
109 | compiler.run((err, stats) => {
110 | if (err) {
111 | return reject(err);
112 | }
113 | const messages = formatWebpackMessages(stats.toJson({}, true));
114 | if (messages.errors.length) {
115 | // Only keep the first error. Others are often indicative
116 | // of the same problem, but confuse the reader with noise.
117 | if (messages.errors.length > 1) {
118 | messages.errors.length = 1;
119 | }
120 | return reject(new Error(messages.errors.join('\n\n')));
121 | }
122 | if (
123 | process.env.CI &&
124 | (typeof process.env.CI !== 'string' ||
125 | process.env.CI.toLowerCase() !== 'false') &&
126 | messages.warnings.length
127 | ) {
128 | console.log(
129 | chalk.yellow(
130 | '\nTreating warnings as errors because process.env.CI = true.\n' +
131 | 'Most CI servers set it automatically.\n'
132 | )
133 | );
134 | return reject(new Error(messages.warnings.join('\n\n')));
135 | }
136 | return resolve({
137 | stats,
138 | previousFileSizes,
139 | warnings: messages.warnings,
140 | });
141 | });
142 | });
143 | }
144 |
145 | function copyPublicFolder() {
146 | fs.copySync(paths.appPublic, paths.appBuild, {
147 | dereference: true,
148 | filter: file => file !== paths.appHtml,
149 | });
150 | }
151 |
--------------------------------------------------------------------------------
/src/components/TopicDetail/index.jsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | Component
3 | } from 'react'
4 | import {
5 | Link
6 | } from 'react-router-dom'
7 | import {
8 | connect
9 | } from 'react-redux'
10 | import {
11 | bindActionCreators
12 | } from 'redux'
13 | import {
14 | fetchTopicDetail,
15 | collectTopic,
16 | submitReply,
17 | likeComment
18 | } from 'actions/topics'
19 | import {
20 | formatNumber
21 | } from '@/utils'
22 | import './topicDetail'
23 | import formatter from 'format-publish-date'
24 | import Zoomme from 'zoomme'
25 | import 'highlight.js/styles/default.css'
26 |
27 | const format = raw => formatter(new Date(raw))
28 |
29 | class TopicDetail extends Component {
30 | constructor(props) {
31 | super(props)
32 | this.state = {
33 | reply: ''
34 | }
35 | }
36 | componentWillMount() {
37 | this._fetchTopicDetail()
38 | }
39 | componentDidMount() {
40 | const container = document.querySelector('.topic_body')
41 | new Zoomme({
42 | container
43 | })
44 | }
45 | _fetchTopicDetail() {
46 | const id = this.props.match.params.id
47 |
48 | this.props.fetchTopicDetail({
49 | id,
50 | accesstoken: this.props.accesstoken
51 | })
52 | }
53 | collect() {
54 | const {
55 | topic,
56 | accesstoken,
57 | collectTopic
58 | } = this.props
59 | if (!accesstoken) {
60 | alert('请先登录!')
61 | return
62 | }
63 |
64 | collectTopic({
65 | accesstoken,
66 | topic_id: topic.id,
67 | is_collect: topic.is_collect
68 | })
69 | }
70 | like(id, author) {
71 | const {
72 | accesstoken,
73 | likeComment,
74 | self
75 | } = this.props
76 | if (!accesstoken) {
77 | alert('请先登录!')
78 | return
79 | }
80 | if (author === self) {
81 | alert('不能给自己点赞哦!')
82 | return
83 | }
84 |
85 | likeComment({
86 | accesstoken,
87 | id
88 | })
89 | }
90 | reply(name) {
91 | const at = `@${name} `
92 | this.refs.textarea.value = at
93 | this.refs.textarea.focus()
94 | this.setState({
95 | reply: at
96 | })
97 | }
98 | handleInput(e) {
99 | this.setState({
100 | reply: e.target.value.trim()
101 | })
102 | }
103 | submit() {
104 | const {
105 | accesstoken,
106 | topic,
107 | submitReply
108 | } = this.props
109 | if (!accesstoken) {
110 | alert('请先登录!')
111 | return
112 | }
113 |
114 | submitReply({
115 | accesstoken,
116 | topic_id: topic.id,
117 | content: this.state.reply
118 | }).then(res => {
119 | if (res.success) {
120 | this._fetchTopicDetail()
121 | this.setState({
122 | reply: ''
123 | })
124 | this.refs.textarea.value = ''
125 | }
126 | })
127 | }
128 | render() {
129 | const {
130 | replies,
131 | title,
132 | author,
133 | create_at,
134 | reply_count,
135 | visit_count,
136 | is_collect,
137 | content
138 | } = this.props.topic
139 | const commentList = replies.length;
140 | const ReplyList = replies.map((item, index) => {
141 | return (
142 |
143 |
144 |
145 |
146 |
147 |
148 | {item.author.loginname}
149 | {format(item.create_at)}
150 | {index + 1}楼
151 |
152 | {/*赞*/}
153 |
154 |
155 |
156 | {item.ups.length}
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 | )
166 | })
167 | return (
168 |
169 |
170 | {title}
171 |
172 | {author.loginname}
173 | {formatNumber(reply_count)} / {formatNumber(visit_count)}
174 | 发表于:{format(create_at)}
175 | {is_collect ? '已' : ''}收藏
176 |
177 |
178 |
179 |
180 | {!!commentList ? '评论列表' : '暂无评论'}
181 | {ReplyList}
182 |
183 |
184 | 添加评论
185 |
186 |
187 | 提交
188 |
189 |
190 |
191 | )
192 | }
193 | }
194 |
195 | function mapStateToProps({
196 | topic,
197 | accesstoken,
198 | self
199 | }) {
200 | return {
201 | topic,
202 | accesstoken,
203 | self: self.loginname
204 | }
205 | }
206 |
207 | function mapDispatchToProps(dispatch) {
208 | return bindActionCreators({
209 | fetchTopicDetail,
210 | collectTopic,
211 | submitReply,
212 | likeComment
213 | }, dispatch)
214 | }
215 |
216 | export default connect(mapStateToProps, mapDispatchToProps)(TopicDetail)
--------------------------------------------------------------------------------
/docs/static/css/main.4ac32dfd.css:
--------------------------------------------------------------------------------
1 | .topic_list{margin:10px 5px;background-color:#fff}.topic_list .topic_item{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;height:50px;padding:0 10px;border-bottom:1px solid #e0e0e0;-webkit-box-sizing:border-box;box-sizing:border-box}.topic_list .topic_item .user_avatar{position:relative;display:block;width:30px;margin-right:5px;border-radius:50%;overflow:hidden}.topic_list .topic_item .user_avatar:after{content:"";display:block;width:100%;padding-top:100%}.topic_list .topic_item .user_avatar img{position:absolute;top:0;left:0;width:100%}.topic_list .topic_item .topic_title{-ms-flex:1 1;flex:1 1;height:50px;line-height:50px;margin-right:5px;font-weight:400;font-size:14px;overflow:hidden}.topic_list .topic_item .topic_title a{display:block;color:#333;overflow:hidden;-o-text-overflow:ellipsis;text-overflow:ellipsis;white-space:nowrap}.topic_list .topic_item .reply_view{font-size:12px}.topic_list .topic_item .reply_view .reply_number{color:#999}.topic_list .topic_item .reply_view .view_number{color:#666}.topic_list .load_more{display:block;height:40px;line-height:40px;margin-top:20px;text-align:center;font-size:16px;color:#333;background-color:#eaeaea}.topic_list .load_more.show{display:block}.topic_detail{padding:5px}.topic_detail .topic_header{padding:10px;margin-top:10px;border-bottom:1px solid #e0e0e0;background-color:#fff}.topic_detail .topic_header .topic_title{max-height:40px;line-height:20px;font-size:18px;overflow:hidden}.topic_detail .topic_header .topic_info{height:20px;line-height:20px;margin-top:5px}.topic_detail .topic_header .topic_info span{margin-right:4px;font-size:10px;color:#999}.topic_detail .topic_header .topic_info span:nth-child(2){color:#666}.topic_detail .topic_body{padding:10px;margin-top:5px;background-color:#fff}.topic_detail .topic_comment{margin-top:10px}.topic_detail .comment_header{height:40px;line-height:40px;padding:0 10px;font-size:16px;color:#333;background-color:#fff}.topic_detail .comment_list{margin-top:5px}.topic_detail .comment_item{margin-top:5px;padding:10px;border-top:1px solid #f0f0f0;background-color:#fff}.topic_detail .comment_item .meta_info{display:-ms-flexbox;display:flex;-ms-flex-pack:justify;justify-content:space-between;-ms-flex-align:center;align-items:center}.topic_detail .comment_item .user_action{display:-ms-flexbox;display:flex}.topic_detail .comment_item .user_action span{height:14px;line-height:14px;color:#999;font-size:14px}.topic_detail .comment_item .user_action .action_item{margin-left:5px}.topic_detail .comment_item .user_action .liked .icon-thumbup,.topic_detail .comment_item .user_action .liked .up_number{color:#6cf}.topic_detail .comment_item .user_info{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;height:30px}.topic_detail .comment_item .user_avatar{position:relative;width:30px;margin-right:10px;border-radius:50%;overflow:hidden}.topic_detail .comment_item .user_avatar:after{content:"";display:block;width:100%;padding-top:100%}.topic_detail .comment_item .user_avatar img{position:absolute;top:0;left:0;width:100%}.topic_detail .comment_item .user_name{margin-right:5px;font-size:10px;color:#666}.topic_detail .comment_item .time_stamp{font-size:10px;color:#999}.topic_detail .comment_item .up_number{margin-left:3px}.topic_detail .comment_item .floor{margin-left:4px;font-size:10px;color:#999}.topic_detail .comment_item .comment_content{margin-top:6px}.topic_reply{margin-top:10px;background-color:#fff}.topic_reply p{height:40px;line-height:40px;padding:0 10px;font-size:16px;color:#333}.topic_reply .form{padding:5px 10px;font-size:14px}.topic_reply textarea{display:block;width:100%;height:140px;outline:none;border:none;resize:none}.topic_reply .button{margin-top:5px}.hljs{display:block;overflow-x:auto;padding:.5em;background:#f0f0f0}.hljs,.hljs-subst{color:#444}.hljs-comment{color:#888}.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-meta-keyword,.hljs-name,.hljs-selector-tag{font-weight:700}.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{color:#800}.hljs-section,.hljs-title{color:#800;font-weight:700}.hljs-link,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#bc6060}.hljs-literal{color:#78a960}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{color:#397300}.hljs-meta{color:#1f7199}.hljs-meta-string{color:#4d99bf}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.accesstoken{display:-ms-flexbox;display:flex;margin-top:10px;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between;padding:0 10px}.accesstoken label{line-height:30px;font-size:14px;color:#333}.accesstoken .form_control{-ms-flex:1 1;flex:1 1;margin-top:5px}.accesstoken .form_control input{width:100%;line-height:28px;padding:0 4px;font-size:12px;color:#666;outline:none;border:1px solid #e0e0e0;-webkit-box-sizing:border-box;box-sizing:border-box}.user_page .user_info .user_row{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.user_page .user_info .user_avatar{width:60px;height:60px;margin-right:10px;border-radius:50%;overflow:hidden}.user_page .user_info .user_avatar img{display:block;width:100%}.user_page .user_info .user_name{-ms-flex:1 1;flex:1 1;margin-right:5px;font-size:14px;color:#666}.user_page .user_info .user_github{font-size:16px}.user_page .user_info .user_createdAt,.user_page .user_info .user_notification,.user_page .user_info .user_score{font-size:12px}.user_page .recent_topic_list{background-color:#fff}.user_page .recent_topic_list .topic_item{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;height:50px;padding:0 10px;border-bottom:1px solid #e0e0e0;-webkit-box-sizing:border-box;box-sizing:border-box}.user_page .recent_topic_list .topic_item .user_avatar{position:relative;display:block;width:30px;margin-right:5px;border-radius:50%;overflow:hidden}.user_page .recent_topic_list .topic_item .user_avatar:after{content:"";display:block;width:100%;padding-top:100%}.user_page .recent_topic_list .topic_item .user_avatar img{position:absolute;top:0;left:0;width:100%}.user_page .recent_topic_list .topic_item .topic_title{-ms-flex:1 1;flex:1 1;height:50px;line-height:50px;margin-right:5px;font-weight:400;font-size:14px;overflow:hidden}.user_page .recent_topic_list .topic_item .topic_title a{display:block;-o-text-overflow:ellipsis;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;color:#333}.user_page .recent_topic_list .topic_item .reply_view{font-size:12px}.user_page .recent_topic_list .topic_item .reply_view .reply_number{color:#999}.user_page .recent_topic_list .topic_item .reply_view .view_number{color:#666}#to_top{position:fixed;bottom:67px;right:20px;z-index:100;width:44px;height:44px;line-height:44px;font-size:40px;opacity:0;-webkit-transition:opacity .5s;-o-transition:opacity .5s;transition:opacity .5s}#to_top.fade-in{opacity:1}.header_container{display:-ms-flexbox;display:flex;height:34px;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.header_container .go_back{display:block;width:30px;height:30px;line-height:30px;font-size:22px}.header_container .tab_list{display:-ms-flexbox;display:flex;-ms-flex:1 1;flex:1 1}.header_container .tab_item{display:block;width:36px;height:34px;padding:0 3px;line-height:34px;text-align:center;font-size:14px;color:#333}.header_container .tab_item[aria-current=true]{color:#fff;background-color:#f64c4c}.header_container .user_name{position:relative;display:block;width:30px}.header_container .user_name img{display:block;width:100%;border-radius:50%}.header_container .user_name .unread_num{position:absolute;top:-2px;right:-2px;z-index:2;height:12px;min-width:6px;line-height:14px;padding:0 3px;border-radius:6px;font-size:10px;color:#fff;font-style:normal;background-color:#f64c4c}.footer-container{display:-ms-flexbox;display:flex;margin:10px 5px;padding:10px 0;-ms-flex-pack:justify;justify-content:space-between;-ms-flex-align:center;align-items:center;font-size:12px;background-color:#fff}.footer-container .footer-item{-ms-flex:1 1;flex:1 1;text-align:center}.footer-container .icon-github{font-size:20px;color:#333}/*! normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}h1{font-size:2em;margin:.67em 0}hr{-webkit-box-sizing:content-box;box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{-webkit-box-sizing:border-box;box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{-webkit-box-sizing:border-box;box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}[hidden],template{display:none}ol,ul{list-style:none}h1,h2,h3,h4,h5,h6,p{margin:0;font-weight:400}a{text-decoration:none}.markdown-text ol,.markdown-text ul{padding:8px;background-color:#efefef}.markdown-text li,.markdown-text p{line-height:20px;margin-top:15px;font-size:14px;color:#333;white-space:pre-wrap;word-wrap:break-word}.markdown-text li:first-child,.markdown-text p:first-child{margin-top:0}.markdown-text li img,.markdown-text p img{display:block;max-width:100%}.markdown-text a{color:#06c}.markdown-text img{display:block;max-width:100%}.markdown-text pre{padding:7px;margin:4px 0;background-color:#f3f3f3;font-size:12px}.markdown-text h1{margin-top:20px;line-height:24px;font-size:20px;color:#000}.markdown-text h1~p,.markdown-text h1~ul{margin-top:5px}.markdown-text h2{margin-top:18px;line-height:22px;font-size:18px;color:#000}.markdown-text h2~p,.markdown-text h2~ul{margin-top:5px}.markdown-text h3{margin-top:18px;line-height:20px;font-size:16px;font-weight:400;color:#333}.markdown-text h3~p,.markdown-text h3~ul{margin-top:5px}.markdown-text h4{margin-top:16px;line-height:18px;font-size:14px;font-weight:400;color:#333}.markdown-text h4~p,.markdown-text h4~ul{margin-top:5px}.markdown-text h5{margin-top:14px;line-height:16px;font-size:12px;font-weight:400;color:#666}.markdown-text h5~p,.markdown-text h5~ul{margin-top:5px}.markdown-text blockquote{padding:0 0 0 15px;margin:20px 0;border-left:5px solid #eee}.markdown-text hr{margin-top:20px}.markdown-text table{max-width:100%;border-collapse:collapse;border-spacing:0}.markdown-text table tr{margin:0;padding:0;border-top:1px solid #ccc;background-color:#fff}.markdown-text table td,.markdown-text table th{margin:0;padding:6px 13px;border:1px solid #ccc;text-align:left}.markdown-text code,.markdown-text pre{padding:0 3px 2px;font-family:Monaco,Menlo,Consolas,Courier New,monospace;font-size:12px;color:#444;background-color:#f7f7f9;border-radius:3px}.markdown-text pre.prettyprint{font-size:14px;border-radius:0;padding:5px;border:none;margin:10px 0;border-width:1px 0;background:#f7f7f7;overflow-x:scroll}.markdown-text .pln{color:#000}@media screen{.markdown-text .str{color:#080}.markdown-text .kwd{color:#008}.markdown-text .com{color:#800}.markdown-text .typ{color:#606}.markdown-text .lit{color:#066}.markdown-text .clo,.markdown-text .opn,.markdown-text .pun{color:#660}.markdown-text .tag{color:#008}.markdown-text .atn{color:#606}.markdown-text .atv{color:#080}.markdown-text .dec,.markdown-text .var{color:#606}.markdown-text .fun{color:red}}@media print,projection{.markdown-text .kwd,.markdown-text .tag,.markdown-text .typ{font-weight:700}.markdown-text .str{color:#060}.markdown-text .kwd{color:#006}.markdown-text .com{color:#600;font-style:italic}.markdown-text .typ{color:#404}.markdown-text .lit{color:#044}.markdown-text .clo,.markdown-text .opn,.markdown-text .pun{color:#440}.markdown-text .tag{color:#006}.markdown-text .atn{color:#404}.markdown-text .atv{color:#060}}.markdown-text li.L1,.markdown-text li.L3,.markdown-text li.L5,.markdown-text li.L7,.markdown-text li.L9{background:#eee}body{position:relative;background-color:#f5f5f5;font:12px Helvetica Neue,Helvetica,Arial,Microsoft Yahei,Hiragino Sans GB,Heiti SC,WenQuanYi Micro Hei,sans-serif}#cnode_container{padding-bottom:60px}.iconfont{text-align:center}.panel{padding:10px 5px;margin:10px 5px 0;background-color:#fff}.panel .panel_title{padding:0 10px;line-height:30px;font-size:16px;color:#333;border-bottom:1px solid #e0e0e0}.panel .panel_container{padding:0 10px}.panel .panel_empty{line-height:16px;margin-top:5px;font-size:12px;color:#666}.panel .panel_row{margin-top:10px}.button,.button_container{text-align:center}.button{display:inline-block;height:30px;line-height:30px;padding:0 10px;font-size:14px;color:#fff}.button.button_warning{background-color:#f64c4c}.button.button_primary{background-color:#66f}.button.button_info{background-color:#91b151}
--------------------------------------------------------------------------------