├── server ├── Procfile ├── README.md ├── .gitignore ├── routers │ ├── index.js │ └── api.js ├── models │ ├── topic_collect.js │ ├── user.js │ ├── message.js │ ├── comment.js │ └── topic.js ├── db │ └── connect.js ├── index.js ├── package.json ├── middleware │ ├── shouldAuth.js │ └── token.js └── controllers │ ├── message_controller.js │ ├── comment_controller.js │ ├── user_controller.js │ └── topic_controller.js ├── web ├── public │ ├── favicon.ico │ ├── manifest.json │ └── index.html ├── src │ ├── sources │ │ ├── 404.jpg │ │ ├── bg.jpeg │ │ ├── crow.jpg │ │ └── dian.svg │ ├── components │ │ ├── Header │ │ │ ├── index.scss │ │ │ └── index.js │ │ ├── Steps │ │ │ └── index.js │ │ ├── NavBar │ │ │ ├── index.scss │ │ │ └── index.js │ │ ├── Comment │ │ │ ├── index.scss │ │ │ └── index.js │ │ ├── NoMatch │ │ │ ├── index.js │ │ │ └── index.scss │ │ └── TopicItem │ │ │ ├── index.scss │ │ │ └── index.js │ ├── style │ │ ├── animation.scss │ │ └── global.scss │ ├── reducers │ │ ├── index.js │ │ ├── auth.js │ │ ├── comment.js │ │ ├── message.js │ │ ├── user.js │ │ └── topic.js │ ├── containers │ │ ├── Register │ │ │ ├── index.scss │ │ │ └── index.js │ │ ├── Login │ │ │ ├── index.scss │ │ │ └── index.js │ │ ├── Settings │ │ │ ├── index.scss │ │ │ └── index.js │ │ ├── Home │ │ │ └── index.js │ │ ├── Message │ │ │ ├── index.scss │ │ │ └── index.js │ │ ├── UserInfo │ │ │ ├── index.scss │ │ │ └── index.js │ │ ├── TopicDetail │ │ │ ├── index.scss │ │ │ └── index.js │ │ ├── CategoryList │ │ │ └── index.js │ │ └── CreateTopic │ │ │ └── index.js │ ├── stores │ │ └── index.js │ ├── index.js │ ├── server │ │ └── interceptors.js │ ├── routers │ │ └── index.js │ ├── apis │ │ └── index.js │ └── registerServiceWorker.js ├── README.md ├── .gitignore ├── config │ ├── jest │ │ ├── fileTransform.js │ │ └── cssTransform.js │ ├── polyfills.js │ ├── paths.js │ ├── env.js │ ├── webpackDevServer.config.js │ ├── webpack.config.dev.js │ └── webpack.config.prod.js ├── scripts │ ├── test.js │ ├── start.js │ └── build.js └── package.json ├── .gitignore ├── README.md └── LICENSE /server/Procfile: -------------------------------------------------------------------------------- 1 | web: node index.js -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shiyangzhaoa/call-club/HEAD/web/public/favicon.ico -------------------------------------------------------------------------------- /web/src/sources/404.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shiyangzhaoa/call-club/HEAD/web/src/sources/404.jpg -------------------------------------------------------------------------------- /web/src/sources/bg.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shiyangzhaoa/call-club/HEAD/web/src/sources/bg.jpeg -------------------------------------------------------------------------------- /web/src/sources/crow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shiyangzhaoa/call-club/HEAD/web/src/sources/crow.jpg -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | 自己手搭的 2 | ## server端,用的koa2 + mongodb 3 | node index.js启动,占用8080端口; 4 | ***** 5 | 推荐使用PM2,或者supervisor 6 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /web/node_modules 5 | /server/node_modules 6 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | 用create-react-app作为脚手架 2 | ## web端,用的react + react router v4 + redux 3 | 默认占用3000端口。开发环境代理8080端口,更改的话请修改生产环境的webpack配置. 4 | **** 5 | 启动:npm start; 6 | 打包:npm run build; 7 | -------------------------------------------------------------------------------- /server/routers/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 整合所有子路由 3 | */ 4 | 5 | const router = require('koa-router')(); 6 | const api = require('./api.js'); 7 | 8 | router.use('/api', api.routes(), api.allowedMethods()); 9 | 10 | module.exports = router; 11 | -------------------------------------------------------------------------------- /web/src/components/Header/index.scss: -------------------------------------------------------------------------------- 1 | .breadcrumb { 2 | padding: 20px 80px; 3 | background-color: #444; 4 | 5 | .ant-breadcrumb-separator { 6 | color: #fff; 7 | } 8 | 9 | .ant-breadcrumb-link { 10 | color: #ccc; 11 | } 12 | 13 | .ant-card-head { 14 | background-color: #f6f6f6; 15 | } 16 | } -------------------------------------------------------------------------------- /web/src/style/animation.scss: -------------------------------------------------------------------------------- 1 | .topic-enter { 2 | opacity: 0.01; 3 | } 4 | 5 | .topic-enter.topic-enter-active { 6 | opacity: 1; 7 | transition: opacity 500ms ease-in; 8 | } 9 | 10 | .topic-leave { 11 | opacity: 1; 12 | } 13 | 14 | .topic-leave.topic-leave-active { 15 | opacity: 0.01; 16 | transition: opacity 300ms ease-in; 17 | } -------------------------------------------------------------------------------- /web/src/reducers/index.js: -------------------------------------------------------------------------------- 1 | 2 | import { combineReducers } from 'redux'; 3 | 4 | import topic from './topic'; 5 | import user from './user'; 6 | import auth from './auth'; 7 | import comment from './comment'; 8 | import message from './message'; 9 | 10 | const combined = combineReducers({ topic, user, auth, comment, message }); 11 | export default combined; 12 | -------------------------------------------------------------------------------- /web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "call club", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /web/src/containers/Register/index.scss: -------------------------------------------------------------------------------- 1 | .register-content { 2 | display: flex; 3 | border-radius: 10px; 4 | width: 60%; 5 | margin: 10% auto; 6 | background-color: rgba(255, 255, 255, .4); 7 | padding: 10px; 8 | } 9 | 10 | .register-form { 11 | flex: 1; 12 | } 13 | 14 | @media (max-width: 400px) { 15 | .register-content { 16 | flex-direction: column; 17 | } 18 | } -------------------------------------------------------------------------------- /web/.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 | -------------------------------------------------------------------------------- /web/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/tutorial-webpack.html 7 | 8 | module.exports = { 9 | process(src, filename) { 10 | return `module.exports = ${JSON.stringify(path.basename(filename))};`; 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /web/src/components/Steps/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Timeline } from 'antd'; 3 | 4 | const Steps = (props) => ( 5 | 6 | {props.steps.map(item => ( 7 | 8 | {item.title} 9 | 10 | ))} 11 | 12 | ); 13 | 14 | export default Steps; 15 | -------------------------------------------------------------------------------- /web/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/tutorial-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 | -------------------------------------------------------------------------------- /server/models/topic_collect.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const Schema = mongoose.Schema; 4 | 5 | const topicCollect = new Schema({ 6 | author_id: Schema.Types.ObjectId, 7 | topic_id: { type: Schema.Types.ObjectId, required: true }, 8 | create_at: { type: Date, default: new Date() }, 9 | }); 10 | 11 | const TopicCollect = mongoose.model('TopicCollect', topicCollect); 12 | 13 | module.exports = TopicCollect; -------------------------------------------------------------------------------- /server/db/connect.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | mongoose.Promise = Promise; 4 | 5 | mongoose.connect('mongodb://127.0.0.1:27017/call_club', { 6 | useMongoClient: true, 7 | promiseLibrary: global.Promise 8 | }); 9 | 10 | const db = mongoose.connection; 11 | 12 | db.on('error', (error) => { 13 | console.log(`数据库创建失败:${error}`); 14 | }); 15 | 16 | db.on('open', () => { 17 | console.log('数据库连接成功'); 18 | }); 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # call-club 2 | 前端:react、redux和react router,默认占用3000,后端:koa2,数据库:mongodb,要求node8.0以上,默认占用8080; 3 | 后端部署到了heroku,已经对跨域做了处理。接口地址:https://call-club.herokuapp.com/api; 4 | **** 5 | [线上地址](https://shiyangzhaoa.github.io/call-club-web/#/) 6 | 7 | cd web => npm install => npm start 8 | **** 9 | cd server = > npm install => node index 10 | **** 11 | 功能: 12 | - [x] 登陆、注册 13 | - [x] 文章列表、详情、创建和评论 14 | - [x] 对文章的评论和对个人的回复 15 | - [x] 信息数量,标记单个已读和全部已读 16 | - [ ] 管理员功能 17 | -------------------------------------------------------------------------------- /web/src/components/NavBar/index.scss: -------------------------------------------------------------------------------- 1 | .nav-bar { 2 | height: 100vh; 3 | } 4 | @media (max-width: 1000px) { 5 | .pc { 6 | display: none; 7 | } 8 | 9 | .menu { 10 | line-height: 64px; 11 | } 12 | 13 | .ant-layout.ant-layout-has-sider { 14 | flex-direction: column; 15 | } 16 | } 17 | @media (min-width: 1000px) { 18 | .mobile { 19 | display: none; 20 | } 21 | 22 | .ant-layout.ant-layout-has-sider { 23 | flex-direction: row; 24 | } 25 | } -------------------------------------------------------------------------------- /web/src/stores/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | createStore, 3 | applyMiddleware, 4 | compose 5 | } from 'redux'; 6 | import thunk from 'redux-thunk'; 7 | import reducers from '../reducers'; 8 | 9 | function reduxStore(initialState) { 10 | const store = createStore(reducers, initialState, compose( 11 | applyMiddleware(thunk), 12 | window.devToolsExtension ? window.devToolsExtension() : f => f 13 | )); 14 | return store; 15 | } 16 | 17 | export default reduxStore; 18 | -------------------------------------------------------------------------------- /web/src/components/Header/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Breadcrumb } from 'antd'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | import './index.scss'; 6 | 7 | const Header = (props) => ( 8 | 9 | {props.back} 10 | {props.now} 11 | 12 | ); 13 | 14 | export default Header; -------------------------------------------------------------------------------- /server/models/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const Schema = mongoose.Schema; 4 | 5 | const user = new Schema({ 6 | loginname: { type: String, required: true }, 7 | avatar_url: String, 8 | email: { type: String, required: true }, 9 | password: { type: String, required: true }, 10 | signature: String, 11 | web: String, 12 | creat_time: { type: Date, default: new Date() }, 13 | }); 14 | 15 | const User = mongoose.model('User', user); 16 | 17 | module.exports = User; 18 | -------------------------------------------------------------------------------- /web/src/containers/Login/index.scss: -------------------------------------------------------------------------------- 1 | .login-page { 2 | display: flex; 3 | border-radius: 10px; 4 | width: 80%; 5 | margin: 10% auto; 6 | background-color: rgba(255, 255, 255, .4); 7 | padding: 10px; 8 | } 9 | 10 | .login-form { 11 | flex: 1; 12 | margin-left: 20px; 13 | 14 | .form-ctrl { 15 | display: flex; 16 | flex-direction: column; 17 | align-items: center; 18 | } 19 | } 20 | 21 | @media (max-width: 400px) { 22 | .login-page { 23 | flex-direction: column; 24 | } 25 | } -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa'); 2 | const koaBody = require('koa-body'); 3 | const logger = require('koa-logger') 4 | //用来处理跨域的,前端直接代理就可以了 5 | //const cors = require('koa-cors2'); 6 | 7 | const routers = require('./routers/index'); 8 | require('./db/connect'); 9 | 10 | const app = new Koa(); 11 | 12 | //app.use(cors()); 13 | app.use(logger()); 14 | app.use(koaBody(({ multipart: true }))); 15 | app.use(routers.routes()).use(routers.allowedMethods()); 16 | 17 | app.listen(process.env.PORT || 8080); 18 | 19 | console.log('call-club is starting at port 8080'); 20 | -------------------------------------------------------------------------------- /server/models/message.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const Schema = mongoose.Schema; 4 | 5 | const message = new Schema({ 6 | type: { type: String }, 7 | author_id: { type: Schema.Types.ObjectId }, 8 | topic_author_id: { type: Schema.Types.ObjectId }, 9 | topic_id: { type: Schema.Types.ObjectId }, 10 | reply_id: { type: Schema.Types.ObjectId }, 11 | has_read: { type: Boolean, default: false }, 12 | create_at: { type: Date, default: new Date() }, 13 | }); 14 | 15 | const Message = mongoose.model('Message', message); 16 | 17 | module.exports = Message; -------------------------------------------------------------------------------- /web/src/reducers/auth.js: -------------------------------------------------------------------------------- 1 | import { AUTH_REQUSET, AUTH_SUCCESS, AUTH_FAILED, INIT_USER_INFO } from './../apis'; 2 | 3 | const initState = { 4 | status: '', 5 | auth: {} 6 | } 7 | 8 | export default function user (state = initState, action) { 9 | switch (action.type) { 10 | case AUTH_REQUSET: 11 | case AUTH_SUCCESS: 12 | case AUTH_FAILED: 13 | return Object.assign({}, state, { status: action.status }, { auth: action.auth || {} }); 14 | case INIT_USER_INFO: 15 | return Object.assign({}, state, { auth: {} }); 16 | default: 17 | return state; 18 | } 19 | } -------------------------------------------------------------------------------- /web/src/containers/Settings/index.scss: -------------------------------------------------------------------------------- 1 | .setting-page { 2 | 3 | .content { 4 | width: 80%; 5 | margin: 20px auto; 6 | } 7 | 8 | .setting-card { 9 | margin-bottom: 20px; 10 | 11 | .setting-form { 12 | padding: 20px 0 0; 13 | } 14 | } 15 | 16 | .ant-card-body { 17 | padding: 0; 18 | } 19 | 20 | .ant-card-head { 21 | background: #eaecec; 22 | border-top-left-radius: 0.5em; 23 | border-top-right-radius: 0.5em; 24 | } 25 | 26 | .ant-card-bordered { 27 | border-top-left-radius: 0.5em; 28 | border-top-right-radius: 0.5em; 29 | } 30 | } -------------------------------------------------------------------------------- /web/src/components/Comment/index.scss: -------------------------------------------------------------------------------- 1 | .comment-item { 2 | display: flex; 3 | padding: 10px; 4 | font-size: 14px; 5 | 6 | &:not(:last-child) { 7 | border-bottom: 1px solid #e1e1e1; 8 | } 9 | 10 | .reply-avatar { 11 | width: 30px; 12 | height: 30px; 13 | margin: 0 10px; 14 | } 15 | 16 | .reply-body { 17 | flex: 1; 18 | 19 | .reply-head { 20 | display: flex; 21 | justify-content: space-between; 22 | } 23 | 24 | .reply-name { 25 | color: #666; 26 | font-size: 12px; 27 | font-weight: 700; 28 | margin-right: 10px; 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /web/src/containers/Home/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Route, 4 | Switch, 5 | } from 'react-router-dom'; 6 | 7 | import NavBar from './../../components/NavBar'; 8 | import CategoryList from './../CategoryList'; 9 | 10 | const Home = (pros) => { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | 22 | export default Home; -------------------------------------------------------------------------------- /server/models/comment.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const Schema = mongoose.Schema; 4 | 5 | const comment = new Schema({ 6 | author_id: Schema.Types.ObjectId, 7 | topic_id: { type: Schema.Types.ObjectId, required: true }, 8 | content: String, 9 | create_at: { type: Date, default: new Date() }, 10 | ups: [Schema.Types.ObjectId], 11 | reply_id: Schema.Types.ObjectId, 12 | is_uped: { type: Boolean, default: false }, 13 | }); 14 | 15 | comment.index({topic_id: 1}); 16 | comment.index({author_id: 1, create_at: -1}); 17 | 18 | const Comment = mongoose.model('Comment', comment); 19 | 20 | module.exports = Comment; 21 | -------------------------------------------------------------------------------- /web/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 | -------------------------------------------------------------------------------- /web/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | 5 | import 'antd/dist/antd.css'; 6 | import 'github-markdown-css'; 7 | import 'react-quill/dist/quill.snow.css'; 8 | import './style/animation.scss'; 9 | import './style/global.scss'; 10 | import './server/interceptors'; 11 | import AppRouter from './routers'; 12 | import configureStore from './stores'; 13 | 14 | import registerServiceWorker from './registerServiceWorker'; 15 | 16 | const store = configureStore(); 17 | 18 | ReactDOM.render( 19 | 20 | 21 | , 22 | document.getElementById('root') 23 | ); 24 | registerServiceWorker(); 25 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "engines": { 7 | "node": "8.4.0" 8 | }, 9 | "scripts": { 10 | "start": "node index.js", 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "keywords": [ 14 | "node", 15 | "koa2" 16 | ], 17 | "author": "yangzhao", 18 | "license": "ISC", 19 | "dependencies": { 20 | "gravatar": "^1.6.0", 21 | "jsonwebtoken": "^8.0.0", 22 | "koa": "^2.3.0", 23 | "koa-body": "^2.3.0", 24 | "koa-cors2": "0.0.1", 25 | "koa-logger": "^3.0.1", 26 | "koa-router": "^7.2.1", 27 | "lodash": "^4.17.4", 28 | "mongoose": "^4.11.10", 29 | "qiniu": "^7.0.8" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /web/src/style/global.scss: -------------------------------------------------------------------------------- 1 | body { 2 | background-image: url('./../sources/bg.jpeg'); 3 | } 4 | 5 | .rdw-editor-wrapper { 6 | border: 2px solid #eee; 7 | width: 75%; 8 | min-width: 600px; 9 | margin: 0 auto; 10 | padding: 10px 10px; 11 | border-radius: 6px; 12 | } 13 | 14 | .rdw-editor-main { 15 | height: 300px !important; 16 | } 17 | 18 | .ql-container { 19 | min-height: 30em; 20 | border-bottom-left-radius: 0.5em; 21 | border-bottom-right-radius: 0.5em; 22 | background: #fefcfc; 23 | } 24 | 25 | .ql-toolbar { 26 | background: #eaecec; 27 | border-top-left-radius: 0.5em; 28 | border-top-right-radius: 0.5em; 29 | } 30 | 31 | a { 32 | color: #333; 33 | 34 | &:hover { 35 | color: #333; 36 | text-decoration: underline; 37 | } 38 | } -------------------------------------------------------------------------------- /web/src/server/interceptors.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import createHistory from 'history/createHashHistory'; 3 | 4 | const history = createHistory() 5 | 6 | //如果有token就在请求头里面带上 7 | axios.interceptors.request.use(function (config) { 8 | const token = localStorage.getItem('login_token'); 9 | if (token) { 10 | config.headers['x-access-token'] = token; 11 | } 12 | return config; 13 | }, function (error) { 14 | return Promise.reject(error); 15 | }); 16 | 17 | //对登陆过期做处理 18 | axios.interceptors.response.use(function (response) { 19 | return response; 20 | }, function (error) { 21 | const { errCode } = error.response.data; 22 | if (errCode && errCode === 100) { 23 | localStorage.removeItem('login_token'); 24 | history.push('/login'); 25 | } 26 | return Promise.reject(error); 27 | }); -------------------------------------------------------------------------------- /web/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 | -------------------------------------------------------------------------------- /web/src/components/NoMatch/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './index.scss'; 4 | import NotFound from './../../sources/404.jpg'; 5 | import Crow from './../../sources/crow.jpg'; 6 | import Point from './../../sources/dian.svg'; 7 | 8 | class NoMatch extends React.Component { 9 | 10 | render() { 11 | return ( 12 |
13 |
14 |
15 | Crow 16 |
17 |
18 |
404
19 | page not found 20 |
21 | Not Found 22 |
23 | ); 24 | } 25 | } 26 | 27 | NoMatch.defaultProps = { 28 | }; 29 | 30 | export default NoMatch; 31 | -------------------------------------------------------------------------------- /web/src/components/NoMatch/index.scss: -------------------------------------------------------------------------------- 1 | .not-found { 2 | width: 100%; 3 | height: 100vh; 4 | background-color: #fff; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | 9 | .fly-box { 10 | height: 63px; 11 | width: 100vw; 12 | position: fixed; 13 | top: 0; 14 | left: 0; 15 | } 16 | 17 | .crow-box { 18 | width: 100%; 19 | background-color: #fff; 20 | position: fixed; 21 | top: 0; 22 | right: 0; 23 | animation: fly 6s linear infinite; 24 | text-align: right; 25 | } 26 | 27 | .number { 28 | font-size: 100px; 29 | color: #108ee9; 30 | font-weight: bold; 31 | text-align: right; 32 | } 33 | 34 | .warin-text { 35 | font-size: 40px; 36 | color: #108ee9; 37 | } 38 | } 39 | @keyframes fly { 40 | 0% { 41 | right: 0; 42 | } 43 | 100% { 44 | right: calc(100% - 157px); 45 | } 46 | } -------------------------------------------------------------------------------- /server/models/topic.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const Schema = mongoose.Schema; 4 | 5 | const topicSchema = new Schema({ 6 | author_id: { type: Schema.Types.ObjectId, required: true }, 7 | tab: { type: String, required: true }, 8 | content: String, 9 | title: { type: String, required: true }, 10 | last_reply: { type: Schema.Types.ObjectId }, 11 | last_reply_at: { type: Date, default: new Date() }, 12 | good: { type: Boolean, default: false }, 13 | top: { type: Boolean, default: false }, 14 | reply_count: { type: Number, default: 0 }, 15 | visit_count: { type: Number, default: 0 }, 16 | create_at: { type: Date, default: new Date() }, 17 | author: { 18 | loginname: String, 19 | avatar_url: String, 20 | }, 21 | }); 22 | 23 | 24 | topicSchema.index({create_at: -1}); 25 | topicSchema.index({top: -1, last_reply_at: -1}); 26 | 27 | const Topic = mongoose.model('Topics', topicSchema); 28 | 29 | module.exports = Topic; 30 | -------------------------------------------------------------------------------- /server/middleware/shouldAuth.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | 3 | const getVerify = function (token) { 4 | return new Promise((resolve, reject) => { 5 | jwt.verify(token, 'app.get(user)', function (err, decoded) { 6 | if (err) { 7 | reject(00); 8 | } else { 9 | resolve(decoded); 10 | } 11 | }); 12 | }) 13 | } 14 | 15 | const resolveToken = async function (ctx, next) { 16 | let token = ctx.request.body.token || ctx.request.query.token || ctx.request.header['x-access-token']; 17 | if (token) { 18 | try { 19 | const result = await getVerify(token); 20 | ctx.api_user = result; 21 | await next(); 22 | } catch(e) { 23 | if (e === 00) { 24 | await next(); 25 | } else { 26 | ctx.status = 500; 27 | ctx.body = { 28 | success: false, 29 | message: e.message, 30 | } 31 | } 32 | } 33 | } else { 34 | await next(); 35 | } 36 | } 37 | 38 | module.exports = resolveToken; -------------------------------------------------------------------------------- /web/src/components/TopicItem/index.scss: -------------------------------------------------------------------------------- 1 | .topic-item { 2 | margin-bottom: 20px; 3 | 4 | .topic-content { 5 | display: flex; 6 | } 7 | 8 | .avatar_img { 9 | width: 50px; 10 | height: 50px; 11 | } 12 | 13 | .topic-title { 14 | font-size: 15px; 15 | } 16 | 17 | .topic-info { 18 | display: flex; 19 | justify-content: space-between; 20 | align-items: center; 21 | } 22 | 23 | .message { 24 | background-color: #A9BBDC; 25 | padding: 5px 15px; 26 | border-radius: 40px; 27 | line-height: 15px; 28 | font-size: 15px; 29 | color: #fff; 30 | } 31 | 32 | .topic-msg { 33 | margin-left: 20px; 34 | display: flex; 35 | flex-direction: column; 36 | justify-content: center; 37 | 38 | .topic-count { 39 | font-size: 12px; 40 | color: #bbb; 41 | margin-top: 5px; 42 | } 43 | } 44 | 45 | a { 46 | color: #000; 47 | } 48 | 49 | a:hover { 50 | text-decoration: underline; 51 | } 52 | 53 | .ant-card-head { 54 | background: #79B9DA; 55 | } 56 | } -------------------------------------------------------------------------------- /web/src/containers/Message/index.scss: -------------------------------------------------------------------------------- 1 | .message-page { 2 | 3 | .mark-btn { 4 | margin-bottom: 20px; 5 | } 6 | 7 | .info-card { 8 | margin-bottom: 20px; 9 | 10 | .info-value { 11 | font-size: 17px; 12 | color: #333; 13 | } 14 | 15 | .info-signature { 16 | font-style: oblique; 17 | } 18 | 19 | .message-item { 20 | border-bottom: 1px solid #ddd; 21 | padding: 10px 15px; 22 | 23 | &:last-child{ 24 | border: 0; 25 | } 26 | } 27 | 28 | .topic-link { 29 | color: #08c; 30 | 31 | &:hover { 32 | color: #035d8a; 33 | } 34 | } 35 | } 36 | 37 | .ant-card-wider-padding .ant-card-body { 38 | padding: 0; 39 | } 40 | 41 | .ant-card-body { 42 | padding: 0; 43 | } 44 | 45 | .ant-card-head { 46 | background: #eaecec; 47 | border-top-left-radius: 0.5em; 48 | border-top-right-radius: 0.5em; 49 | } 50 | 51 | .ant-card-bordered { 52 | border-top-left-radius: 0.5em; 53 | border-top-right-radius: 0.5em; 54 | } 55 | } -------------------------------------------------------------------------------- /web/src/sources/dian.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/containers/UserInfo/index.scss: -------------------------------------------------------------------------------- 1 | .user-info { 2 | 3 | .info-card { 4 | margin-bottom: 20px; 5 | 6 | .info-value { 7 | font-size: 17px; 8 | color: #333; 9 | } 10 | 11 | .info-signature { 12 | font-style: oblique; 13 | } 14 | 15 | .user-topic-item { 16 | border-bottom: 1px solid #ddd; 17 | padding: 10px 15px; 18 | 19 | &:last-child{ 20 | border: 0; 21 | } 22 | } 23 | 24 | .topic-title { 25 | font-size: 14px; 26 | } 27 | 28 | .topic-count { 29 | font-size: 12px; 30 | color: #bbb; 31 | } 32 | } 33 | 34 | .info-topic { 35 | 36 | .ant-card-wider-padding .ant-card-body { 37 | padding: 0; 38 | } 39 | 40 | .ant-card-body { 41 | padding: 0; 42 | } 43 | } 44 | 45 | .ant-card-head { 46 | background: #eaecec; 47 | border-top-left-radius: 0.5em; 48 | border-top-right-radius: 0.5em; 49 | } 50 | 51 | .ant-card-bordered { 52 | border-top-left-radius: 0.5em; 53 | border-top-right-radius: 0.5em; 54 | } 55 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 yangZ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /web/src/reducers/comment.js: -------------------------------------------------------------------------------- 1 | import { 2 | COMMIT_COMMENT_REQUSET, 3 | COMMIT_COMMENT_SUCCESS, 4 | COMMIT_COMMENT_FAILED, 5 | UP_REPLY_REQUEST, 6 | UP_REPLY_SUCCESS, 7 | UP_REPLY_FAILD, 8 | DELETE_REPLY_REQUEST, 9 | DELETE_REPLY_SUCCESS, 10 | DELETE_REPLY_FAILED, 11 | } from './../apis'; 12 | 13 | const initState = { 14 | commitStatus: '', 15 | upStatus: '', 16 | upAction: {}, 17 | delReplyStatus: '', 18 | } 19 | 20 | export default function commitComment(state=initState, action) { 21 | switch(action.type) { 22 | case COMMIT_COMMENT_REQUSET: 23 | case COMMIT_COMMENT_SUCCESS: 24 | case COMMIT_COMMENT_FAILED: 25 | return Object.assign({}, state, { commitStatus: action.status }); 26 | case UP_REPLY_REQUEST: 27 | case UP_REPLY_SUCCESS: 28 | case UP_REPLY_FAILD: 29 | return Object.assign({}, state, { upStatus: action.status, upAction: action.action }); 30 | case DELETE_REPLY_REQUEST: 31 | case DELETE_REPLY_SUCCESS: 32 | case DELETE_REPLY_FAILED: 33 | return Object.assign({}, state, { delReplyStatus: action.status }); 34 | default: 35 | return state; 36 | } 37 | } -------------------------------------------------------------------------------- /server/middleware/token.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | 3 | const getVerify = function (token) { 4 | return new Promise((resolve, reject) => { 5 | jwt.verify(token, 'app.get(user)', function (err, decoded) { 6 | if (err) { 7 | reject(100); 8 | } else { 9 | resolve(decoded); 10 | } 11 | }); 12 | }) 13 | } 14 | 15 | const resolveToken = async function (ctx, next) { 16 | let token = ctx.request.body.token || ctx.request.query.token || ctx.request.header['x-access-token']; 17 | if (token) { 18 | try { 19 | const result = await getVerify(token); 20 | ctx.api_user = result; 21 | await next(); 22 | } catch(e) { 23 | if (e === 100) { 24 | ctx.status = 401; 25 | ctx.body = { 26 | success: false, 27 | message: 'token过期', 28 | errCode: 100, 29 | } 30 | } else { 31 | ctx.status = 500; 32 | ctx.body = { 33 | success: false, 34 | message: e.message, 35 | } 36 | } 37 | } 38 | } else { 39 | ctx.status = 403; 40 | ctx.body = { 41 | success: false, 42 | message: '缺少token', 43 | } 44 | } 45 | } 46 | 47 | module.exports = resolveToken; -------------------------------------------------------------------------------- /web/src/components/TopicItem/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Card } from 'antd'; 3 | import { Link } from 'react-router-dom'; 4 | import moment from 'moment'; 5 | 6 | import './index.scss'; 7 | 8 | const TopicItem = (props) => { 9 | const style = { 10 | color: props.info.top || props.info.good ? '#cc1e4e' : '#fff' 11 | } 12 | const { info } = props; 13 | const lastReply = moment(info.last_reply_at).fromNow() 14 | return ( 15 |
16 | {info.kind} 18 | } bordered={true} style={{ width: '100%' }}> 19 |
20 |
21 | {info.author && avatar} 22 | {info.author ? 23 |
24 | {info.title} 25 |
{info.author.loginname} > 浏览量{info.visit_count} • 最后回复于{lastReply}
26 |
27 | : 无数据} 28 |
29 | {!!info.reply_count &&
{info.reply_count}
} 30 |
31 |
32 |
33 | ) 34 | } 35 | 36 | export default TopicItem; -------------------------------------------------------------------------------- /web/src/reducers/message.js: -------------------------------------------------------------------------------- 1 | import { 2 | GET_MESSAGE_COUNT_REQUEST, 3 | GET_MESSAGE_COUNT_SUCCESS, 4 | GET_MESSAGE_COUNT_FAILED, 5 | GET_MESSAGES_REQUEST, 6 | GET_MESSAGES_SUCCESS, 7 | GET_MESSAGES_FAILED, 8 | MARK_ONE_MESSAGE_REQUEST, 9 | MARK_ONE_MESSAGE_SUCCESS, 10 | MARK_ONE_MESSAGE_FAILED, 11 | MARK_ALL_MESSAGE_REQUEST, 12 | MARK_ALL_MESSAGE_SUCCESS, 13 | MARK_ALL_MESSAGE_FAILED, 14 | } from './../apis'; 15 | 16 | const initState = { 17 | messageCountStatus: '', 18 | count: 0, 19 | getMessageStatus: '', 20 | messages: null, 21 | markOneStatus: '', 22 | markAllStatus: '', 23 | }; 24 | 25 | export default function commitComment(state=initState, action) { 26 | switch(action.type) { 27 | case GET_MESSAGE_COUNT_REQUEST: 28 | return Object.assign({}, state, { messageCountStatus: action.status }); 29 | case GET_MESSAGE_COUNT_SUCCESS: 30 | case GET_MESSAGE_COUNT_FAILED: 31 | return Object.assign({}, state, { messageCountStatus: action.status, count: action.count }); 32 | case GET_MESSAGES_REQUEST: 33 | case GET_MESSAGES_SUCCESS: 34 | case GET_MESSAGES_FAILED: 35 | return Object.assign({}, state, { getMessageStatus: action.status, messages: action.messages }); 36 | case MARK_ONE_MESSAGE_REQUEST: 37 | case MARK_ONE_MESSAGE_SUCCESS: 38 | case MARK_ONE_MESSAGE_FAILED: 39 | return Object.assign({}, state, { markOneStatus: action.status }); 40 | case MARK_ALL_MESSAGE_REQUEST: 41 | case MARK_ALL_MESSAGE_SUCCESS: 42 | case MARK_ALL_MESSAGE_FAILED: 43 | return Object.assign({}, state, { markAllStatus: action.status, markResult: action.data }); 44 | default: 45 | return state; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /web/src/reducers/user.js: -------------------------------------------------------------------------------- 1 | import { 2 | SUBMIT_REGISTER_REQUSET, 3 | SUBMIT_REGISTER_SUCCESS, 4 | SUBMIT_REGISTER_FAILED, 5 | LOGIN_REQUSET, 6 | LOGIN_SUCCESS, 7 | LOGIN_FAILED, 8 | GET_USERINFO_REQUSET, 9 | GET_USERINFO_SUCCESS, 10 | GET_USERINFO_FAILED, 11 | UPDATE_SETTING_REQUEST, 12 | UPDATE_SETTING_SUCCESS, 13 | UPDATE_SETTING_FAILED, 14 | UPDATE_PASSWORD_REQUEST, 15 | UPDATE_PASSWORD_SUCCESS, 16 | UPDATE_PASSWORD_FAILED, 17 | } from './../apis'; 18 | 19 | const initState = { 20 | registerStatus: '', 21 | info: null, 22 | loginStatus: '', 23 | getInfoStatus: '', 24 | updateStatus: '', 25 | changeStatus: '', 26 | } 27 | 28 | export default function user (state = initState, action) { 29 | switch(action.type) { 30 | case SUBMIT_REGISTER_REQUSET: 31 | case SUBMIT_REGISTER_SUCCESS: 32 | case SUBMIT_REGISTER_FAILED: 33 | return Object.assign({}, state, { registerStatus: action.status }); 34 | case LOGIN_REQUSET: 35 | case LOGIN_SUCCESS: 36 | case LOGIN_FAILED: 37 | return Object.assign({}, state, { loginStatus: action.status }); 38 | case GET_USERINFO_REQUSET: 39 | case GET_USERINFO_SUCCESS: 40 | case GET_USERINFO_FAILED: 41 | return Object.assign({}, state, { info: action.info }, { getInfoStatus: action.status }); 42 | case UPDATE_SETTING_REQUEST: 43 | case UPDATE_SETTING_SUCCESS: 44 | case UPDATE_SETTING_FAILED: 45 | return Object.assign({}, state, { updateStatus: action.status }); 46 | case UPDATE_PASSWORD_REQUEST: 47 | case UPDATE_PASSWORD_SUCCESS: 48 | case UPDATE_PASSWORD_FAILED: 49 | return Object.assign({}, state, { changeStatus: action.status }); 50 | default: 51 | return state; 52 | } 53 | } -------------------------------------------------------------------------------- /web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | call-club 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /web/src/components/Comment/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Icon } from 'antd'; 3 | import { Link } from 'react-router-dom'; 4 | import moment from 'moment'; 5 | import ReactMarkdown from 'react-markdown'; 6 | 7 | import './index.scss'; 8 | 9 | const Comment = props => { 10 | const style = { 11 | fontSize: props.is_up ? '18px' : '14px', 12 | color: props.is_up ? '#000' : '#c8c8c8', 13 | transition: 'all .5s', 14 | } 15 | const { info, index, changeStatus, upReply, commentId, author, deleteReply } = props; 16 | const dura = moment(info.create_at).fromNow(); 17 | return ( 18 |
19 | 20 | reply_obj 21 | 22 |
23 |
24 |
25 | {info.author.loginname} 26 | {`${index + 1}楼•${dura}`} 27 |
28 |
29 | { 30 | info.author_id === author && 31 | deleteReply(info.id)}> 32 | 33 | 34 | } 35 | 36 | upReply(info.id, index)} style={{...style}}/> 37 | 38 | {props.ups.length} 39 | 40 | 41 | 42 |
43 |
44 | 45 | {commentId === info.id && props.children} 46 |
47 |
48 | ); 49 | } 50 | 51 | export default Comment; -------------------------------------------------------------------------------- /server/routers/api.js: -------------------------------------------------------------------------------- 1 | const router = require('koa-router')(); 2 | const topicController = require('./../controllers/topic_controller'); 3 | const userController = require('./../controllers/user_controller'); 4 | const commentController = require('./../controllers/comment_controller'); 5 | const messageController = require('./../controllers/message_controller'); 6 | const resolveToken = require('./../middleware/token'); 7 | const shouldAuth = require('./../middleware/shouldAuth'); 8 | 9 | 10 | const routers = router 11 | .get('/topics', topicController.getTopics) 12 | .get('/topics/:id', shouldAuth, topicController.getTopicDetail) 13 | .get('/topic/no_reply', topicController.getNoReply) 14 | .post('/topic/create', resolveToken, topicController.create) 15 | .post('/topic/:topic_id/replies', resolveToken, commentController.create) 16 | .delete('/topic/:topic_id/delete', resolveToken, topicController.removeTopic) 17 | .put('/topic/update', resolveToken, topicController.updateTopic) 18 | .post('/reply/:reply_id/ups', resolveToken, commentController.upReply) 19 | .delete('/reply/:reply_id/delete', resolveToken, commentController.deleteReply) 20 | .post('/topic/topic_collect/collect', resolveToken, topicController.collect) 21 | .post('/topic_collect/de_collect', resolveToken, topicController.cancelCollect) 22 | .post('/user/login', userController.login) 23 | .post('/user/register', userController.createUser) 24 | .get('/user-info/:loginname', userController.getInfo) 25 | .get('/user/auth', resolveToken, userController.getAuth) 26 | .put('/user/basic_setting', resolveToken, userController.changeSetting) 27 | .put('/user/set_password', resolveToken, userController.setNewPassword) 28 | .get('/message/count', resolveToken, messageController.getCount) 29 | .get('/messages', resolveToken, messageController.getMessages) 30 | .post('/message/mark_one/:msg_id', resolveToken, messageController.markOne) 31 | .post('/message/mark_all', resolveToken, messageController.markAll); 32 | 33 | module.exports = routers; 34 | -------------------------------------------------------------------------------- /web/src/containers/TopicDetail/index.scss: -------------------------------------------------------------------------------- 1 | .topic-detail { 2 | display: flex; 3 | padding: 20px 30px; 4 | width: 100%; 5 | box-sizing: border-box; 6 | 7 | .topic-info { 8 | flex: 7; 9 | 10 | .topic-item { 11 | margin-top: 20px; 12 | } 13 | } 14 | 15 | .article { 16 | flex: 3; 17 | margin-left: 20px; 18 | 19 | .no-reply-topic { 20 | padding: 10px 15px; 21 | font-size: 15px; 22 | 23 | &:not(:last-child) { 24 | border-bottom: 1px solid #ddd; 25 | } 26 | } 27 | 28 | a { 29 | color: #333; 30 | } 31 | } 32 | 33 | .topic-level { 34 | background: #80bd01; 35 | padding: 6px 12px; 36 | border-radius: 3px; 37 | color: #fff; 38 | font-size: 12px; 39 | margin-right: 10px; 40 | } 41 | 42 | .info-item { 43 | margin-right: 4px; 44 | 45 | &::before { 46 | content: '•'; 47 | } 48 | } 49 | 50 | .topic-info { 51 | margin-bottom: 10px; 52 | 53 | .topic-header { 54 | display: flex; 55 | justify-content: space-between; 56 | align-items: center; 57 | } 58 | } 59 | 60 | ul { 61 | list-style: initial; 62 | } 63 | 64 | ol { 65 | list-style: decimal; 66 | } 67 | 68 | .reply-btn { 69 | margin-top: 10px; 70 | } 71 | 72 | .no-reply-item { 73 | margin: 20px 0; 74 | 75 | .ant-card-body { 76 | padding: 0; 77 | } 78 | } 79 | .ant-card-head { 80 | background: #B5CAC6; 81 | } 82 | 83 | .loading { 84 | display: flex; 85 | justify-content: center; 86 | padding: 20px 0; 87 | } 88 | } 89 | 90 | .detail-page { 91 | width: 100vw; 92 | height: 100vh; 93 | 94 | .comment { 95 | .ant-card-body { 96 | padding: 0; 97 | } 98 | } 99 | 100 | a { 101 | color: #108ee9; 102 | } 103 | 104 | .ant-card-head-title { 105 | width: 100%; 106 | } 107 | 108 | @media (max-width: 1000px) { 109 | .topic-detail { 110 | flex-direction: column; 111 | } 112 | 113 | .article { 114 | margin: 0; 115 | } 116 | } 117 | } -------------------------------------------------------------------------------- /web/config/paths.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const url = require('url'); 6 | 7 | // Make sure any symlinks in the project folder are resolved: 8 | // https://github.com/facebookincubator/create-react-app/issues/637 9 | const appDirectory = fs.realpathSync(process.cwd()); 10 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath); 11 | 12 | const envPublicUrl = process.env.PUBLIC_URL; 13 | 14 | function ensureSlash(path, needsSlash) { 15 | const hasSlash = path.endsWith('/'); 16 | if (hasSlash && !needsSlash) { 17 | return path.substr(path, path.length - 1); 18 | } else if (!hasSlash && needsSlash) { 19 | return `${path}/`; 20 | } else { 21 | return path; 22 | } 23 | } 24 | 25 | const getPublicUrl = appPackageJson => 26 | envPublicUrl || require(appPackageJson).homepage; 27 | 28 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer 29 | // "public path" at which the app is served. 30 | // Webpack needs to know it to put the right