├── 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(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/reducers/accesstoken.js: -------------------------------------------------------------------------------- 1 | import { 2 | REGISTER_ACCESSTOKEN 3 | } from 'actions/users' 4 | 5 | export default function accesstoken(state = 0, action) { 6 | switch (action.type) { 7 | case REGISTER_ACCESSTOKEN: 8 | return action.data 9 | default: 10 | return state 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/ToTop/toTop.less: -------------------------------------------------------------------------------- 1 | // 返回顶部 2 | #to_top { 3 | position: fixed; 4 | bottom: 67px; 5 | right: 20px; 6 | z-index: 100; 7 | width: 44px; 8 | height: 44px; 9 | line-height: 44px; 10 | font-size: 40px; 11 | opacity: 0; 12 | transition: opacity .5s; 13 | &.fade-in { 14 | opacity: 1; 15 | } 16 | } -------------------------------------------------------------------------------- /docs/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "CNode", 3 | "name": "CNode React", 4 | "icons": [{ 5 | "src": "favicon.png", 6 | "sizes": "64x64 32x32 24x24 16x16", 7 | "type": "image/png" 8 | }], 9 | "start_url": "./index.html", 10 | "display": "standalone", 11 | "theme_color": "#000000", 12 | "background_color": "#ffffff" 13 | } -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "CNode", 3 | "name": "CNode React", 4 | "icons": [{ 5 | "src": "favicon.png", 6 | "sizes": "64x64 32x32 24x24 16x16", 7 | "type": "image/png" 8 | }], 9 | "start_url": "./index.html", 10 | "display": "standalone", 11 | "theme_color": "#000000", 12 | "background_color": "#ffffff" 13 | } -------------------------------------------------------------------------------- /src/reducers/user.js: -------------------------------------------------------------------------------- 1 | import { 2 | FETCH_USER 3 | } from 'actions/users' 4 | 5 | let _state = { 6 | recent_topics: [], 7 | recent_replies: [] 8 | } 9 | export default function user(state = _state, action) { 10 | switch (action.type) { 11 | case FETCH_USER: 12 | return action.data 13 | default: 14 | return state 15 | } 16 | } -------------------------------------------------------------------------------- /config/jest/fileTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | // This is a custom Jest transformer turning file imports into filenames. 6 | // http://facebook.github.io/jest/docs/en/webpack.html 7 | 8 | module.exports = { 9 | process(src, filename) { 10 | return `module.exports = ${JSON.stringify(path.basename(filename))};`; 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This is a custom Jest transformer turning style imports into empty objects. 4 | // http://facebook.github.io/jest/docs/en/webpack.html 5 | 6 | module.exports = { 7 | process() { 8 | return 'module.exports = {};'; 9 | }, 10 | getCacheKey() { 11 | // The output is always the same. 12 | return 'cssTransform'; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/components/Footer/footer.less: -------------------------------------------------------------------------------- 1 | .footer-container { 2 | display: flex; 3 | margin: 10px 5px; 4 | padding: 10px 0; 5 | justify-content: space-between; 6 | align-items: center; 7 | font-size: 12px; 8 | background-color: #fff; 9 | .footer-item { 10 | flex: 1; 11 | text-align: center; 12 | } 13 | .icon-github { 14 | font-size: 20px; 15 | color: #333; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | combineReducers 3 | } from 'redux' 4 | 5 | import accesstoken from './accesstoken' 6 | import self from './self' 7 | import user from './user' 8 | import topic from './topic' 9 | import topics from './topics' 10 | 11 | const reducers = combineReducers({ 12 | topic, 13 | topics, 14 | user, 15 | self, 16 | accesstoken 17 | }) 18 | 19 | export default reducers -------------------------------------------------------------------------------- /src/reducers/self.js: -------------------------------------------------------------------------------- 1 | import { 2 | FETCH_SELF, 3 | LOG_OUT 4 | } from 'actions/users' 5 | 6 | let _state = { 7 | recent_topics: [], 8 | recent_replies: [] 9 | } 10 | export default function self(state = _state, action) { 11 | switch (action.type) { 12 | case FETCH_SELF: 13 | return action.data 14 | case LOG_OUT: 15 | return _state 16 | default: 17 | return state 18 | } 19 | } -------------------------------------------------------------------------------- /src/components/ScrollToTop.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | withRouter 3 | } from 'react-router-dom' 4 | import { 5 | Component 6 | } from 'react' 7 | 8 | class ScrollToTop extends Component { 9 | componentDidUpdate(prevProps) { 10 | if (this.props.location !== prevProps.location) { 11 | window.scrollTo(0, 0) 12 | } 13 | } 14 | 15 | render() { 16 | return this.props.children 17 | } 18 | } 19 | 20 | export default withRouter(ScrollToTop) -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { 4 | Provider 5 | } from 'react-redux' 6 | import store from './store' 7 | import App from './components/App' 8 | import registerServiceWorker from './registerServiceWorker' 9 | import 'normalize.css' 10 | import './public/styles/index.less' 11 | 12 | ReactDOM.render( 13 | 14 | 15 | , 16 | document.getElementById('root') 17 | ) 18 | registerServiceWorker() -------------------------------------------------------------------------------- /src/components/Footer/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | Component 3 | } from 'react' 4 | import { 5 | Link 6 | } from 'react-router-dom' 7 | import './footer' 8 | 9 | class Footer extends Component { 10 | render() { 11 | return ( 12 |
13 | cnode-react 14 | 15 | Copyright ©2018 stop2stare 16 |
17 | ) 18 | } 19 | } 20 | 21 | export default Footer -------------------------------------------------------------------------------- /src/components/SignIn/signIn.less: -------------------------------------------------------------------------------- 1 | // 登录页 2 | .accesstoken { 3 | display: flex; 4 | margin-top: 10px; 5 | align-items: center; 6 | justify-content: space-between; 7 | padding: 0 10px; 8 | label { 9 | line-height: 30px; 10 | font-size: 14px; 11 | color: #333; 12 | } 13 | .form_control { 14 | flex: 1; 15 | margin-top: 5px; 16 | input { 17 | width: 100%; 18 | line-height: 28px; 19 | padding: 0 4px; 20 | font-size: 12px; 21 | color: #666; 22 | outline: none; 23 | border: 1px #e0e0e0 solid; 24 | box-sizing: border-box; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | CNode-React
-------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | CNode-React 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/reducers/topics.js: -------------------------------------------------------------------------------- 1 | import { 2 | FETCH_TOPICS, 3 | FETCH_MORE_TOPICS, 4 | RESET_PAGE 5 | } from 'actions/topics' 6 | 7 | let _state = { 8 | page: 1, 9 | tab: 'all', 10 | list: [] 11 | } 12 | export default function topics(state = _state, action) { 13 | switch (action.type) { 14 | case FETCH_TOPICS: 15 | return action.data 16 | case FETCH_MORE_TOPICS: 17 | const { 18 | page, 19 | tab, 20 | list 21 | } = action.data 22 | return { 23 | page, 24 | tab, 25 | list: [...state.list, ...list] 26 | } 27 | case RESET_PAGE: 28 | return _state 29 | default: 30 | return state 31 | } 32 | } -------------------------------------------------------------------------------- /scripts/test.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 = 'test'; 5 | process.env.NODE_ENV = 'test'; 6 | process.env.PUBLIC_URL = ''; 7 | 8 | // Makes the script crash on unhandled rejections instead of silently 9 | // ignoring them. In the future, promise rejections that are not handled will 10 | // terminate the Node.js process with a non-zero exit code. 11 | process.on('unhandledRejection', err => { 12 | throw err; 13 | }); 14 | 15 | // Ensure environment variables are read. 16 | require('../config/env'); 17 | 18 | const jest = require('jest'); 19 | const argv = process.argv.slice(2); 20 | 21 | // Watch unless on CI or in coverage mode 22 | if (!process.env.CI && argv.indexOf('--coverage') < 0) { 23 | argv.push('--watch'); 24 | } 25 | 26 | 27 | jest.run(argv); 28 | -------------------------------------------------------------------------------- /src/components/ToTop/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | Component 3 | } from 'react' 4 | import './toTop' 5 | 6 | class ToTop extends Component { 7 | constructor() { 8 | super() 9 | this.state = { 10 | show: false 11 | } 12 | } 13 | componentDidMount() { 14 | window.addEventListener('scroll', () => { 15 | if (window.scrollY > 150) { 16 | this.setState({ 17 | show: true 18 | }) 19 | } else { 20 | this.setState({ 21 | show: false 22 | }) 23 | } 24 | }) 25 | } 26 | returnTop() { 27 | window.scrollTo(0, 0) 28 | } 29 | render() { 30 | let classname = 'iconfont icon-top ' + (this.state.show ? 'fade-in' : '') 31 | 32 | return ( 33 |
34 | ) 35 | } 36 | } 37 | 38 | export default ToTop -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * get a certain part of cookies 3 | * expect: 'a=1; b=2; c=3', 'a' 4 | * return: 1 5 | * 6 | * @param {String} raw 7 | * @return {String} 8 | */ 9 | export const getCookie = (raw, key) => { 10 | let ret 11 | raw.replace(/(\S+)=(\S+);?/g, (...args) => { 12 | if (args[1] === key) ret = args[2].replace(/;$/, '') 13 | }) 14 | return ret 15 | } 16 | 17 | /** 18 | * delete a certain part of cookie 19 | * expect: 'a=1; b=2; c=3', 'b' 20 | * return: 'a=1; c=3' 21 | * 22 | * @param {String} raw 23 | * @param {String} key 24 | * @return {Object} 25 | */ 26 | export const delCookie = (raw, key) => { 27 | var exp = new Date() 28 | exp.setTime(exp.getTime() - 1) 29 | var value = getCookie(raw, key) 30 | return key + "=" + value + ";expires=" + exp.toGMTString() 31 | } 32 | 33 | export const formatNumber = raw => (raw).toLocaleString('en-US') 34 | -------------------------------------------------------------------------------- /config/polyfills.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | if (typeof Promise === 'undefined') { 4 | // Rejection tracking prevents a common issue where React gets into an 5 | // inconsistent state due to an error, but it gets swallowed by a Promise, 6 | // and the user has no idea what causes React's erratic future behavior. 7 | require('promise/lib/rejection-tracking').enable(); 8 | window.Promise = require('promise/lib/es6-extensions.js'); 9 | } 10 | 11 | // fetch() polyfill for making API calls. 12 | require('whatwg-fetch'); 13 | 14 | // Object.assign() is commonly used with React. 15 | // It will use the native implementation if it's present and isn't buggy. 16 | Object.assign = require('object-assign'); 17 | 18 | // In tests, polyfill requestAnimationFrame since jsdom doesn't provide it yet. 19 | // We don't polyfill it in the browser--this is user's responsibility. 20 | if (process.env.NODE_ENV === 'test') { 21 | require('raf').polyfill(global); 22 | } 23 | -------------------------------------------------------------------------------- /src/hl.worker.js: -------------------------------------------------------------------------------- 1 | const hljs = require('highlight.js') 2 | const unescape = require('unescape-alltypes-html') 3 | 4 | const codeRE = /([\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 | {topic.author.loginname} 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 | {reply.author.loginname} 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 | {user.loginname} 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 | {item.author.loginname} 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} --------------------------------------------------------------------------------