├── .env
├── .prettierignore
├── assets
├── _styles.less
├── self-styles.less
└── antd-custom.less
├── .eslintignore
├── redux
├── reducers
│ ├── selectors
│ │ └── index.ts
│ ├── index.ts
│ ├── channel.ts
│ ├── topic.ts
│ └── user.ts
├── actions
│ ├── channel.ts
│ ├── topic.ts
│ └── user.ts
├── store.ts
└── sagas
│ └── index.ts
├── screenshot
├── demo.gif
├── screenshot1.png
├── screenshot2.png
├── screenshot3.png
└── Nobibi-structure.png
├── static
└── favicon.ico
├── .prettierrc
├── next-env.d.ts
├── now.json
├── constants
├── CustomTheme.ts
├── ConstTypes.ts
└── ActionTypes.ts
├── .babelrc
├── components
├── NoFooter
│ └── index.tsx
├── TopicEditor
│ └── index.tsx
├── NoAvatar
│ └── index.tsx
├── Editor
│ └── index.tsx
├── NoHeader
│ ├── index.less
│ └── index.tsx
├── TopicItem
│ └── index.tsx
├── ErrorPage.tsx
├── NoLayout
│ ├── index.tsx
│ └── index.less
└── CommentList
│ └── index.tsx
├── pages
├── _error.tsx
├── _document.tsx
├── _app.tsx
├── login.tsx
├── topicEdit.tsx
├── register.tsx
├── modifyUser.tsx
├── changePass.tsx
├── index.tsx
└── topicDetail.tsx
├── tsconfig.json
├── .gitignore
├── @types
└── index.d.ts
├── .editorconfig
├── utils
├── index.ts
├── fetch.ts
└── timer.ts
├── pm2.config.js
├── README_en.md
├── LICENSE
├── server.js
├── .eslintrc
├── package.json
├── README.md
├── api
└── index.ts
└── next.config.js
/.env:
--------------------------------------------------------------------------------
1 | BASE_URL=http://47.244.103.124:3001
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | build/
--------------------------------------------------------------------------------
/assets/_styles.less:
--------------------------------------------------------------------------------
1 | @import "./antd-custom.less";
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .next/
3 | .editorconfig
4 |
--------------------------------------------------------------------------------
/assets/self-styles.less:
--------------------------------------------------------------------------------
1 | @import "~antd/dist/antd.less";
2 | @import "./antd-custom.less";
--------------------------------------------------------------------------------
/redux/reducers/selectors/index.ts:
--------------------------------------------------------------------------------
1 | export const selectUserInfo = state => state.user;
2 |
--------------------------------------------------------------------------------
/screenshot/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/seawind8888/Nobibi/HEAD/screenshot/demo.gif
--------------------------------------------------------------------------------
/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/seawind8888/Nobibi/HEAD/static/favicon.ico
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "singleQuote": true,
4 | "trailingComma": "all"
5 | }
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/screenshot/screenshot1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/seawind8888/Nobibi/HEAD/screenshot/screenshot1.png
--------------------------------------------------------------------------------
/screenshot/screenshot2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/seawind8888/Nobibi/HEAD/screenshot/screenshot2.png
--------------------------------------------------------------------------------
/screenshot/screenshot3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/seawind8888/Nobibi/HEAD/screenshot/screenshot3.png
--------------------------------------------------------------------------------
/screenshot/Nobibi-structure.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/seawind8888/Nobibi/HEAD/screenshot/Nobibi-structure.png
--------------------------------------------------------------------------------
/now.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 2,
3 | "name": "Nobibi",
4 | "builds": [{ "src": "package.json", "use": "@now/next" }]
5 | }
6 |
--------------------------------------------------------------------------------
/constants/CustomTheme.ts:
--------------------------------------------------------------------------------
1 | /* custom define */
2 | export const color_youdao = '#E93D34';
3 | export const color_youdao_border = '#c20c0c';
4 | export const color_primary = '#52c41a';
5 |
6 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 |
2 | {
3 | "presets": [
4 | "next/babel",
5 | "@zeit/next-typescript/babel"
6 | ],
7 | "plugins": [
8 | [
9 | "import",
10 | {
11 | "libraryName": "antd",
12 | "style": "less"
13 | }
14 | ],
15 | ["lodash"]
16 | ]
17 | }
--------------------------------------------------------------------------------
/constants/ConstTypes.ts:
--------------------------------------------------------------------------------
1 | // 用户级别
2 |
3 |
4 | // 路由对应页面标题
5 | export const RouterTitle = {
6 | '/': 'Home',
7 | '/topicDetail': 'TopicDetail',
8 | '/login': 'Nobibi | 登录',
9 | '/register': 'Nobibi | 注册',
10 | '/topicEdit': 'Nobibi | 发布主题',
11 | '/modifyUser': 'Nobibi | 修改用户信息'
12 | };
13 |
--------------------------------------------------------------------------------
/components/NoFooter/index.tsx:
--------------------------------------------------------------------------------
1 | const NoFooter = () => (
2 |
7 | );
8 | export default NoFooter;
--------------------------------------------------------------------------------
/assets/antd-custom.less:
--------------------------------------------------------------------------------
1 | /* 系统主题颜色 */
2 | @color-primary: #52c41a;
3 |
4 | @primary-color: @color-primary;
5 |
6 | @layout-header-height: 40px;
7 | @border-radius-base: 2px;
8 | .ant-menu-horizontal {
9 | border-bottom: none;
10 | }
11 |
12 | .avatar-uploader > .ant-upload {
13 | width: 128px;
14 | height: 128px;
15 | }
16 | .ant-upload.ant-upload-select-picture-card {
17 | margin:0 auto !important;
18 | }
--------------------------------------------------------------------------------
/redux/actions/channel.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FETCH_CHANNEL_LIST,
3 | FETCH_CHANNEL_LIST_SUCCESS,
4 | FETCH_CHANNEL_LIST_FAIL
5 | } from '../../constants/ActionTypes';
6 |
7 |
8 | export const fetchChannelList = () => ({type: FETCH_CHANNEL_LIST});
9 |
10 | export const fetchChannelListSuccess = (payload) => ({type: FETCH_CHANNEL_LIST_SUCCESS, payload});
11 |
12 | export const fetchChannelListFail = () => ({type: FETCH_CHANNEL_LIST_FAIL});
--------------------------------------------------------------------------------
/redux/actions/topic.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FETCH_TOPIC_LIST,
3 | FETCH_TOPIC_LIST_SUCCESS,
4 | FETCH_TOPIC_LIST_FAIL
5 | } from '../../constants/ActionTypes';
6 |
7 |
8 | export const fetchTopiclList = (payload = {}) => ({type: FETCH_TOPIC_LIST, payload});
9 |
10 | export const fetchTopicListSuccess = (payload) => ({type: FETCH_TOPIC_LIST_SUCCESS, payload});
11 |
12 | export const fetchTopicListFail = () => ({type: FETCH_TOPIC_LIST_FAIL});
--------------------------------------------------------------------------------
/redux/reducers/index.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import user from './user';
3 | import topic from './topic';
4 | import { User, Topic } from '../../@types'
5 | import channel, { ChannelStateType } from './channel';
6 |
7 | export interface AppStateType {
8 | user: User,
9 | channel: ChannelStateType,
10 | topic: Topic
11 | }
12 |
13 | export const rootReducer = combineReducers({
14 | user,
15 | channel,
16 | topic
17 | });
--------------------------------------------------------------------------------
/redux/actions/user.ts:
--------------------------------------------------------------------------------
1 | import {
2 | GET_USER_INFO,
3 | GET_USER_INFO_SUCCESS,
4 | GET_USER_INFO_FAIL,
5 | USER_SIGN_OUT
6 | } from '../../constants/ActionTypes';
7 |
8 |
9 | export const getUserInfo = (payload = {}) => ({type: GET_USER_INFO, payload});
10 |
11 | export const getUserInfoSuccess = (payload) => ({type: GET_USER_INFO_SUCCESS, payload});
12 |
13 | export const getUserInfoFail = () => ({type: GET_USER_INFO_FAIL});
14 |
15 | export const userSignOut = () => ({type: USER_SIGN_OUT});
--------------------------------------------------------------------------------
/pages/_error.tsx:
--------------------------------------------------------------------------------
1 | import { NextPageContext } from 'next';
2 | import ErrorPage from '../components/ErrorPage';
3 |
4 | interface ErrorProps {
5 | statusCode: number
6 | }
7 |
8 | const Error = (props: ErrorProps) => {
9 | return (
10 |
11 | );
12 | }
13 |
14 | Error.getInitialProps = ({ res, err }: NextPageContext) => {
15 | const statusCode = res ? res.statusCode : err ? err.statusCode : null;
16 | return { statusCode };
17 | }
18 |
19 | export default Error
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": false,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve"
16 | },
17 | "exclude": ["node_modules"],
18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "pages/index.tsx"]
19 | }
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # Dependencies
4 | node_modules/
5 | npm-debug.log
6 | yarn-error.log
7 | yarn.lock
8 | package-lock.json
9 | # Compiled output
10 | build
11 |
12 | # Runtime data
13 | database.sqlite
14 |
15 | # Test coverage
16 | coverage
17 |
18 | # Logs
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 | logs/
23 |
24 | # Editors and IDEs
25 | .idea
26 | .vscode/*
27 | !.vscode/settings.json
28 | !.vscode/tasks.json
29 | !.vscode/launch.json
30 | !.vscode/extensions.json
31 |
32 | # Misc
33 | .DS_Store
34 |
35 | # custom
36 | .next
37 | out/
38 |
--------------------------------------------------------------------------------
/@types/index.d.ts:
--------------------------------------------------------------------------------
1 | export interface User {
2 | _id?: string,
3 | userName?: string,
4 | password?: string,
5 | avatar?: string,
6 | email?: string,
7 | visit?: number[],
8 | status?: string,
9 | refUserRoleCode?: string,
10 | createTime?: string,
11 | updateTime?: string
12 | }
13 |
14 | export interface Topic {
15 | _id?: string,
16 | avatar?:string,
17 | topicTitle?: string,
18 | content?: string,
19 | total?: number,
20 | list?: object[],
21 | page?: number,
22 | type?: string,
23 | commentNum?: number,
24 | praiseNum?: number,
25 | userName?: string,
26 | userAvatar?: string,
27 | updateTime?: string,
28 | categoryName?: string
29 | }
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig文件使用INI格式。斜杠(/)作为路径分隔符,#或者;作为注释。路径支持通配符:
2 | # 表明是最顶层的配置文件,发现设为true时,才会停止查找.editorconfig文件
3 | root = true
4 | # * 匹配除/之外的任意字符
5 | # ** 匹配任意字符串
6 | # ? 匹配任意单个字符
7 | # [name] 匹配name字符
8 | # [!name] 不匹配name字符
9 | # [s1,s2,s3] 匹配给定的字符串
10 | # [num1..num2] 匹配num1到mun2直接的整数
11 | [*]
12 | # 文件的charset。有以下几种类型:latin1, utf-8, utf-8-bom, utf-16be, utf-16le
13 | charset = utf-8
14 | # 缩进使用 tab 或者 space
15 | indent_style = space
16 | # 缩进为 space 时,缩进的字符数
17 | indent_size = 2
18 | # 缩进为 tab 时,缩进的宽度
19 | # tab_width = 2
20 | # 换行符的类型。lf, cr, crlf三种
21 | end_of_line = lf
22 | # 是否将行尾空格自动删除
23 | trim_trailing_whitespace = true
24 | # 是否使文件以一个空白行结尾
25 | insert_final_newline = true
--------------------------------------------------------------------------------
/utils/index.ts:
--------------------------------------------------------------------------------
1 |
2 |
3 | import { message } from 'antd';
4 |
5 | export function getRandomColor(){
6 | return "#" + ("00000" + ((Math.random() * 16777215 + 0.5) >> 0).toString(16)).slice(-6);
7 | }
8 |
9 | export function getBase64(img, callback) {
10 | const reader = new FileReader();
11 | reader.addEventListener('load', () => callback(reader.result));
12 | reader.readAsDataURL(img);
13 | }
14 |
15 | export function beforeUpload(file) {
16 | const isJPG = file.type === 'image/jpeg';
17 | if (!isJPG) {
18 | message.error('只能上传图片!');
19 | }
20 | const isLt2M = file.size / 48 / 48 < 2;
21 | if (!isLt2M) {
22 | message.error('图片需小于48KB!');
23 | }
24 | return isJPG && isLt2M;
25 | }
--------------------------------------------------------------------------------
/components/TopicEditor/index.tsx:
--------------------------------------------------------------------------------
1 |
2 |
3 | import { NextPage } from 'next';
4 | import dynamic from 'next/dynamic';
5 | import 'braft-editor/dist/index.css';
6 |
7 | const BraftEditor = dynamic(
8 | import('braft-editor'),
9 | { ssr: false }
10 | );
11 |
12 | interface TopicEditorProps {
13 | editorValue: object,
14 | editorChange: (e:any) => void
15 | }
16 |
17 | const TopicEditor: NextPage = (props) => {
18 | const { editorValue, editorChange } = props;
19 | return (
20 |
25 | );
26 | }
27 |
28 | export default TopicEditor;
--------------------------------------------------------------------------------
/constants/ActionTypes.ts:
--------------------------------------------------------------------------------
1 | // ================= Home Part ==================== //
2 | export const FETCH_CHANNEL_LIST = 'FETCH_CHANNEL_LIST';
3 | export const FETCH_CHANNEL_LIST_SUCCESS = 'FETCH_CHANNEL_LIST_SUCCESS';
4 | export const FETCH_CHANNEL_LIST_FAIL = 'FETCH_CHANNEL_LIST_FAIL';
5 | export const FETCH_TOPIC_LIST = 'FETCH_TOPIC_LIST';
6 | export const FETCH_TOPIC_LIST_SUCCESS = 'FETCH_TOPIC_LIST_SUCCESS';
7 | export const FETCH_TOPIC_LIST_FAIL = 'FETCH_TOPIC_LIST_FAIL';
8 |
9 | // ================= User Part ==================== //
10 | export const GET_USER_INFO = 'GET_USER_INFO';
11 | export const GET_USER_INFO_SUCCESS = 'GET_USER_INFO_SUCCESS';
12 | export const GET_USER_INFO_FAIL = 'GET_USER_INFO_FAIL';
13 | export const USER_SIGN_OUT = 'USER_SIGN_OUT';
--------------------------------------------------------------------------------
/redux/reducers/channel.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FETCH_CHANNEL_LIST,
3 | FETCH_CHANNEL_LIST_SUCCESS,
4 | FETCH_CHANNEL_LIST_FAIL
5 | } from '../../constants/ActionTypes';
6 |
7 | export interface ChannelStateType {
8 | list: object[]
9 | }
10 |
11 | const initialState = {
12 | list: []
13 | };
14 |
15 | const channel = (state: ChannelStateType = initialState, { type, payload = {list:[]} }) => {
16 | switch (type) {
17 | case FETCH_CHANNEL_LIST:
18 | case FETCH_CHANNEL_LIST_SUCCESS:
19 | return {
20 | ...state,
21 | list: payload.list
22 | };
23 | case FETCH_CHANNEL_LIST_FAIL:
24 | return initialState;
25 | default:
26 | return state;
27 | }
28 | };
29 |
30 | export default channel;
--------------------------------------------------------------------------------
/redux/reducers/topic.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FETCH_TOPIC_LIST,
3 | FETCH_TOPIC_LIST_SUCCESS,
4 | FETCH_TOPIC_LIST_FAIL
5 | } from '../../constants/ActionTypes';
6 |
7 | import { Topic } from '../../@types'
8 |
9 |
10 | const initialState = {
11 | list: [],
12 | categoryName: '',
13 | type: '',
14 | total: 0
15 | };
16 |
17 | const topic = (state: Topic = initialState, { type, payload = {} }) => {
18 | switch (type) {
19 | case FETCH_TOPIC_LIST:
20 | return initialState;
21 | case FETCH_TOPIC_LIST_SUCCESS:
22 | return {
23 | ...state,
24 | ...payload
25 | };
26 | case FETCH_TOPIC_LIST_FAIL:
27 | return initialState;
28 | default:
29 | return state;
30 | }
31 | };
32 |
33 | export default topic;
--------------------------------------------------------------------------------
/pm2.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | apps: [
3 | {
4 | name: 'Nobibi',
5 | script: './server.js',
6 | cwd: './', // current workspace
7 | watch: [
8 | // watch directorys and restart when they change
9 | '.next'
10 | ],
11 | ignore_watch: [
12 | // ignore watch
13 | 'node_modules',
14 | 'logs',
15 | 'static'
16 | ],
17 | instances: 2, // start 2 instances
18 | node_args: '--harmony',
19 | env: {
20 | NODE_ENV: 'production',
21 | PORT: 3006
22 | },
23 | out_file: './logs/out.log', // normal log
24 | error_file: './logs/err.log', // error log
25 | merge_logs: true,
26 | log_date_format: 'YYYY-MM-DD HH:mm Z' // date format
27 | }
28 | ]
29 | };
30 |
--------------------------------------------------------------------------------
/redux/reducers/user.ts:
--------------------------------------------------------------------------------
1 | import {
2 | GET_USER_INFO,
3 | GET_USER_INFO_SUCCESS,
4 | USER_SIGN_OUT
5 | } from '../../constants/ActionTypes';
6 | import { User } from '../../@types'
7 |
8 |
9 | const initialState = {
10 | _id: '',
11 | userName: '',
12 | avatar: '',
13 | email: '',
14 | visit: [],
15 | status: '',
16 | refUserRoleCode: '',
17 | createTime: '',
18 | updateTime: ''
19 | };
20 |
21 | const user = (state: User = initialState, { type, payload = {} }) => {
22 | switch (type) {
23 | case GET_USER_INFO:
24 | return state;
25 | case GET_USER_INFO_SUCCESS:
26 | return {
27 | ...payload
28 | };
29 | case USER_SIGN_OUT:{
30 | return {
31 | ...initialState
32 | };
33 | }
34 | default:
35 | return state;
36 | }
37 |
38 | };
39 |
40 | export default user;
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | // _document is only rendered on the server side and not on the client side
2 | // Event handlers like onClick can't be added to this file
3 |
4 | // ./pages/_document.js
5 | import Document, { Head, Main, NextScript } from 'next/document';
6 |
7 | export default class MyDocument extends Document {
8 | static async getInitialProps(ctx) {
9 | const initialProps = await Document.getInitialProps(ctx);
10 | return { ...initialProps };
11 | }
12 |
13 | render() {
14 | return (
15 |
16 |
17 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 | }
--------------------------------------------------------------------------------
/components/NoAvatar/index.tsx:
--------------------------------------------------------------------------------
1 | import { NextPage } from 'next';
2 | import { UserOutlined } from '@ant-design/icons';
3 | import { Avatar } from 'antd';
4 |
5 | interface NoAvatarProps {
6 | avatar: string,
7 | userName: string
8 | size?: number | "small" | "large" | "default"
9 | }
10 |
11 | const NoAvatar: NextPage = (props) => {
12 | if (!props.avatar) {
13 | return (
14 | } size={props.size} />
15 | );
16 | }
17 | if (props.avatar.length > 7) {
18 | return (
19 | } src={props.avatar} size={props.size} />
20 | );
21 | }
22 | return (
23 | {props.userName.slice(0, 1)}
32 | );
33 | };
34 |
35 | export default NoAvatar;
--------------------------------------------------------------------------------
/README_en.md:
--------------------------------------------------------------------------------
1 | Nobibi
2 |
3 | [简体中文](./README.md) | English
4 |
5 | > Nobibi is a lightweight open source forum,Quickly build your own forum
6 |
7 | ## Quick Start
8 |
9 | > Make sure you have started [Nobibi-api](https://github.com/seawind8888/Nobibi-api)
10 |
11 | 1. Clone project code.
12 |
13 | ```
14 | git clone https://github.com/seawind8888/Nobibi my-project
15 | ```
16 |
17 | 2. Installation dependence.
18 |
19 | ```
20 | cd my-porject
21 | npm install or yarn
22 | ```
23 |
24 | 3. Start Project
25 |
26 | ```
27 | npm run start
28 | ```
29 |
30 | ## Associated project
31 |
32 | - [Nobibi-api](https://github.com/seawind8888/Nobibi-api) - Nobibi Api Part
33 | - [Nobibi-admin](https://github.com/seawind8888/Nobibi-admin) - Nobibi Admin
34 |
35 | ## Sreenshot
36 |
37 | - Frontend
38 | 
39 | 
40 | 
41 | - Frontend(mobile)
42 | 
43 | - Admin
44 | 
45 |
--------------------------------------------------------------------------------
/components/Editor/index.tsx:
--------------------------------------------------------------------------------
1 | import { NextPage } from 'next';
2 | import PropTypes from 'prop-types';
3 | import {Form, Button, Input} from 'antd';
4 | const { TextArea } = Input;
5 |
6 | interface EditorProps {
7 | onChange: (e: any) => void;
8 | onSubmit: (e: React.MouseEvent) => void;
9 | submitting: boolean,
10 | value: any
11 | }
12 |
13 | const Editor: NextPage = ({ onChange, onSubmit, submitting, value }) => (
14 |
15 |
16 |
17 |
18 |
19 |
22 |
23 |
24 | );
25 |
26 |
27 |
28 | Editor.propTypes = {
29 | onChange: PropTypes.func.isRequired,
30 | onSubmit: PropTypes.func.isRequired,
31 | submitting: PropTypes.bool.isRequired,
32 | value: PropTypes.string
33 | };
34 | Editor.defaultProps = {
35 | value: ''
36 | };
37 |
38 | export default Editor;
39 |
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 luffyZhou
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 |
--------------------------------------------------------------------------------
/utils/fetch.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosResponse, AxiosPromise } from 'axios';
2 | import { message } from 'antd';
3 | const instance = axios.create({
4 | baseURL: process.env.BASE_URL || 'http://localhost:3001',
5 | withCredentials: true,
6 | // timeout: 1000
7 | });
8 |
9 | export default function fetch(options) {
10 | if (options.useToken) {
11 | options.headers = {
12 | Authorization: 'Bearer ' + window.localStorage.getItem('Token'),
13 | };
14 | }
15 |
16 | return instance(options)
17 | .then(response => {
18 | const { data } = response;
19 | const { status } = data;
20 | const success = status === 200 ? true : false;
21 | if (!success && typeof window !== 'undefined') {
22 | message.error(data.message);
23 | }
24 | if (status === 401) {
25 | window.localStorage.removeItem('Token');
26 | window.localStorage.removeItem('userName');
27 | }
28 | return Promise.resolve({
29 | success: success,
30 | ...data,
31 | });
32 | })
33 | .catch(error => {
34 | if (typeof window !== 'undefined') {
35 | message.info(error || 'Network Error');
36 | }
37 | return Promise.reject();
38 | });
39 | }
40 |
--------------------------------------------------------------------------------
/components/NoHeader/index.less:
--------------------------------------------------------------------------------
1 | .header-outside {
2 | display: flex;
3 | height: 45px;
4 | justify-content: center;
5 | border: 1px solid #e8e8e8;
6 | background-color: #ffffff;
7 | }
8 | .header-main {
9 | padding: 0 15px;
10 | width: 1200px;
11 | display: flex;
12 | align-items: center;
13 | .header-title {
14 | margin: 0;
15 | cursor: pointer;
16 | margin-right: 15px;
17 | }
18 | .header-search-container {
19 | width: 200px;
20 | .ant-input {
21 | border-radius: 30px;
22 | }
23 | }
24 | .button-group {
25 | display: flex;
26 | flex:1;
27 | justify-content: flex-end;
28 | align-items: center;
29 | .text-button-container {
30 | margin-left: 10px;
31 | }
32 | }
33 | }
34 |
35 | .toggle-button {
36 | display: none !important;
37 | align-items: center;
38 | margin-right: 10px;
39 | }
40 |
41 |
42 |
43 |
44 |
45 |
46 | @media screen and (min-width: 769px) and (max-width: 1280px) {
47 | .inside-container {
48 | width: 1024px;
49 | }
50 | }
51 |
52 | @media screen and (max-width: 768px) {
53 | .main {
54 | padding-left: 80px;
55 | }
56 | .outside-container {
57 | border: none;
58 | }
59 | .menu-group-header {
60 | display: none;
61 | }
62 | .logo-container {
63 | display: block;
64 | }
65 | .toggle-button {
66 | display: flex !important;
67 | }
68 | }
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const cp = require('child_process');
3 | const next = require('next');
4 | const { publicRuntimeConfig, serverRuntimeConfig } = require('./next.config');
5 |
6 | const { isDev } = publicRuntimeConfig;
7 | const { PORT } = serverRuntimeConfig;
8 |
9 | // 判断开发环境和生产环境
10 | const dev = isDev;
11 |
12 | const app = next({ dev });
13 | const handle = app.getRequestHandler();
14 |
15 | app.prepare()
16 | .then(() => {
17 | const server = express();
18 |
19 | server.get('/topicDetail/:id', (req, res) => {
20 | const { id } = req.params;
21 | return app.render(req, res, '/topicDetail', { id });
22 | });
23 |
24 | server.get('*', (req, res) => {
25 | return handle(req, res);
26 | });
27 |
28 | server.listen(PORT, err => {
29 | if (err) throw err;
30 | const serverUrl = `http://localhost:${PORT}`;
31 | console.log(`> Ready on ${serverUrl}`);
32 | // 开发环境自动启动
33 | if (dev) {
34 | switch (process.platform) {
35 | //mac系统使用 一下命令打开url在浏览器
36 | case 'darwin':
37 | cp.exec(`open ${serverUrl}`);
38 | break;
39 | //win系统使用 一下命令打开url在浏览器
40 | case 'win32':
41 | cp.exec(`start ${serverUrl}`);
42 | break;
43 | // 默认mac系统
44 | default:
45 | cp.exec(`open ${serverUrl}`);
46 | }
47 | }
48 | });
49 | });
50 |
51 |
--------------------------------------------------------------------------------
/utils/timer.ts:
--------------------------------------------------------------------------------
1 |
2 | export default function timer(time) {
3 | const timestamp = +new Date(time) / 1000;
4 |
5 | var curTimestamp = Number(new Date().getTime() / 1000); //当前时间戳
6 | var timestampDiff = curTimestamp - timestamp; // 参数时间戳与当前时间戳相差秒数
7 |
8 | var curDate = new Date(curTimestamp * 1000); // 当前时间日期对象
9 | var tmDate = new Date(timestamp * 1000); // 参数时间戳转换成的日期对象
10 |
11 | var Y = tmDate.getFullYear(),
12 | m = tmDate.getMonth() + 1,
13 | d = tmDate.getDate();
14 | var H = tmDate.getHours(),
15 | i = tmDate.getMinutes();
16 |
17 | if (timestampDiff < 60) { // 一分钟以内
18 | return "刚刚";
19 | } else if (timestampDiff < 3600) { // 一小时前之内
20 | return Math.floor(timestampDiff / 60) + "分钟前";
21 | } else if (curDate.getFullYear() === Y && curDate.getMonth() + 1 === m && curDate.getDate() === d) {
22 | return '今天' + zeroize(H) + ':' + zeroize(i);
23 | }
24 | var newDate = new Date((curTimestamp - 86400) * 1000); // 参数中的时间戳加一天转换成的日期对象
25 | if (newDate.getFullYear() === Y && newDate.getMonth() + 1 === m && newDate.getDate() === d) {
26 | return '昨天' + zeroize(H) + ':' + zeroize(i);
27 | } else if (curDate.getFullYear() === Y) {
28 | return zeroize(m) + '月' + zeroize(d) + '日 ' + zeroize(H) + ':' + zeroize(i);
29 | }
30 | return Y + '年' + zeroize(m) + '月' + zeroize(d) + '日 ' + zeroize(H) + ':' + zeroize(i);
31 |
32 | }
33 | function zeroize(num) {
34 | return (String(num).length === 1 ? '0' : '') + num;
35 | }
36 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "parserOptions": {
4 | "ecmaVersion": 6,
5 | "sourceType": "module",
6 | "ecmaFeatures": {
7 | "jsx": true
8 | }
9 | },
10 | "env": {
11 | "es6": true,
12 | "browser": true,
13 | "node": true
14 | },
15 | "rules":{
16 | "no-console":0,
17 | "indent": [
18 | 2,
19 | 2,
20 | {
21 | "SwitchCase": 1,
22 | "ObjectExpression": 1
23 | }
24 | ],
25 | "react/jsx-uses-vars": [2],
26 | "semi": [2, "always"],
27 | "linebreak-style": 0,
28 | "consistent-return": 0,
29 | "no-use-before-define": 0,
30 | "no-multi-assign": 0,
31 | "no-lonely-if": 1,
32 | "no-nested-ternary": 0,
33 | "wrap-iife": [2, "inside"],
34 | "jsx-quotes": [2, "prefer-single"],
35 | "generator-star-spacing": 0,
36 | "react/forbid-prop-types": 0,
37 | "react/sort-comp": 1,
38 | "react/no-string-refs": 0,
39 | "react/prefer-stateless-function": 0,
40 | "react/prop-types": 2,
41 | "react/require-default-props": [2, { "forbidDefaultForRequired": true }],
42 | "jsx-a11y/no-static-element-interactions": 0,
43 | "keyword-spacing": [2, { "before": true }],
44 | "eqeqeq": [2, "always"],
45 | "space-infix-ops": [2, {"int32Hint": false}],
46 | "comma-spacing": [2, { "before": false, "after": true }],
47 | "block-spacing": [2, "always"],
48 | "no-else-return": 2
49 | },
50 | "extends": "eslint:recommended",
51 | "plugins": [
52 | "react"
53 | ]
54 | }
55 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { NextPage } from 'next';
2 | import { AppProps } from 'next/app';
3 | import { NextJSContext } from 'next-redux-wrapper';
4 | import Head from 'next/head';
5 | import { Provider } from 'react-redux';
6 | import withRedux from 'next-redux-wrapper';
7 | import withReduxSaga from 'next-redux-saga';
8 | import createStore from '../redux/store';
9 | import NoLayout from '../components/NoLayout';
10 | import '../assets/self-styles.less';
11 | import "antd/dist/antd.less";
12 |
13 | type NextContext = NextJSContext & AppProps & {}
14 |
15 | const NextApp: NextPage = (props) => {
16 |
17 | const { Component, pageProps, store } = props;
18 | return (
19 |
20 |
21 |
22 |
23 | Nobibi-next
24 |
25 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 | }
42 |
43 | NextApp.getInitialProps = async (context: NextContext) => {
44 | const { ctx, Component } = context;
45 | const pageProps = Component.getInitialProps ? await Component.getInitialProps(ctx) : {};
46 |
47 | return { pageProps };
48 | }
49 |
50 | export default withRedux(createStore)(withReduxSaga(NextApp));
--------------------------------------------------------------------------------
/redux/store.ts:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, Store } from 'redux';
2 | import createSagaMiddleware, { Task } from 'redux-saga';
3 | import { rootReducer, AppStateType } from './reducers';
4 | import rootSaga from './sagas/index';
5 |
6 | const sagaMiddleware = createSagaMiddleware();
7 |
8 | export interface RootStore extends Store {
9 | sagaTask: Task;
10 | runSagaTask?: () => void;
11 | }
12 |
13 | interface IHotModule {
14 | hot?: { accept: (path: string, callback: () => void) => void };
15 | }
16 |
17 | declare const module: IHotModule;
18 |
19 | const bindMiddleware = (middleware) => {
20 | // add route middleware
21 | if (process.env.NODE_ENV !== 'production') {
22 | const { composeWithDevTools } = require('redux-devtools-extension');
23 | // development use logger
24 | // const { logger } = require('redux-logger');
25 | // middleware.push(logger);
26 | return composeWithDevTools(applyMiddleware(...middleware));
27 | }
28 | return applyMiddleware(...middleware);
29 | };
30 |
31 | function configureStore (initialState) {
32 |
33 | const store: RootStore = createStore(
34 | rootReducer,
35 | initialState,
36 | bindMiddleware([sagaMiddleware])
37 | );
38 |
39 |
40 | // store.sagaTask = sagaMiddleware.run(rootSaga);
41 | store.runSagaTask = () => {
42 | store.sagaTask = sagaMiddleware.run(rootSaga);
43 | };
44 |
45 | store.runSagaTask();
46 |
47 |
48 |
49 | // Hot reload reducers (requires Webpack or Browserify HMR to be enabled)
50 | if (process.env.NODE_ENV !== 'production' && module.hot) {
51 | module.hot.accept('./reducers', () =>
52 | // eslint-disable-next-line global-require
53 | store.replaceReducer(require('./reducers').default),
54 | );
55 | }
56 |
57 |
58 | return store;
59 | }
60 |
61 | export default configureStore;
62 |
--------------------------------------------------------------------------------
/components/TopicItem/index.tsx:
--------------------------------------------------------------------------------
1 | import { NextPage } from 'next';
2 | import { Tag } from 'antd';
3 | import NoAvatar from '../NoAvatar';
4 | import Link from 'next/link';
5 | import PropTypes from 'prop-types';
6 | import timer from '../../utils/timer';
7 | import { Topic } from '../../@types'
8 |
9 | interface TopicInfoProps {
10 | topicInfo: Topic
11 | }
12 |
13 | const TopicItem: NextPage = ({topicInfo}) => {
14 | return (
15 |
16 |
17 |
22 |
23 |
24 |
34 |
35 | {topicInfo.categoryName}
36 | {topicInfo.praiseNum || 0}赞
37 | ·
38 | {topicInfo.userName}
39 | ·
40 | {timer(topicInfo.updateTime)}
41 |
42 |
43 |
44 | {
45 | topicInfo.commentNum ?
46 | {topicInfo.commentNum}
47 |
:
48 | }
49 |
50 |
51 |
52 |
53 | );
54 | };
55 |
56 | TopicItem.propTypes = {
57 | topicInfo: PropTypes.object
58 | };
59 |
60 | TopicItem.defaultProps = {
61 | topicInfo: {
62 | avatar: ''
63 | }
64 | };
65 |
66 |
67 | export default TopicItem;
--------------------------------------------------------------------------------
/components/ErrorPage.tsx:
--------------------------------------------------------------------------------
1 | import { NextPage } from 'next';
2 | import { Button } from 'antd';
3 | import Router from 'next/router';
4 |
5 |
6 | interface ErrorPageProps {
7 | statusCode: number
8 | }
9 |
10 | const ErrorPage: NextPage = (props) => {
11 |
12 | let RenderComp;
13 | switch (props.statusCode) {
14 | case 200:
15 | case 404: {
16 | RenderComp = () => (
17 |
18 |
33 |

34 |
The page is not found | 404~
35 |
36 |
37 | );
38 | break;
39 | }
40 | case 500: {
41 | RenderComp = () => (
42 |
43 |
60 |

61 |
The page is error | 500~
62 |
63 |
64 | );
65 | break;
66 | }
67 | default:
68 | break;
69 | }
70 | return (
71 |
72 | );
73 | }
74 |
75 | export default ErrorPage;
76 |
--------------------------------------------------------------------------------
/redux/sagas/index.ts:
--------------------------------------------------------------------------------
1 |
2 | import { all, call, put, select, takeLatest} from 'redux-saga/effects';
3 | import { getUserInfo, fetchChannelList, fetchTopicList, getFavoriteTopic } from '../../api';
4 | import {
5 | getUserInfoSuccess,
6 | getUserInfoFail,
7 | userSignOut
8 | } from '../actions/user';
9 | import {
10 | fetchChannelListSuccess,
11 | fetchChannelListFail
12 | } from '../actions/channel';
13 | import {
14 | fetchTopicListSuccess,
15 | fetchTopicListFail
16 | } from '../actions/topic';
17 |
18 | import {
19 | GET_USER_INFO,
20 | FETCH_CHANNEL_LIST,
21 | FETCH_TOPIC_LIST,
22 | USER_SIGN_OUT
23 | } from '../../constants/ActionTypes';
24 | import { Topic } from '../../@types'
25 | import { selectUserInfo } from '../reducers/selectors';
26 |
27 |
28 |
29 | export function* userInfo(action) {
30 | const { userName } = action.payload;
31 | try {
32 | const {data} = yield call(getUserInfo, {username:userName});
33 | yield put(getUserInfoSuccess(data));
34 | } catch (error) {
35 | console.log(error);
36 | yield put(getUserInfoFail());
37 | }
38 | }
39 |
40 | export function* channelList() {
41 | try {
42 | const { data } = yield call(fetchChannelList, {});
43 | yield put(fetchChannelListSuccess(data));
44 | } catch (error) {
45 | console.log(error);
46 | yield put(fetchChannelListFail());
47 | }
48 | }
49 |
50 | export function* topicList(action) {
51 | try {
52 | const { type = '', categoryName = '', page = 1 } = action.payload;
53 | const requestUrl = type === 'favorite' ? getFavoriteTopic : fetchTopicList;
54 | // const _categoryName = categoryName === '全部' ? '' : categoryName;
55 | const params: Topic = {
56 | categoryName,
57 | page
58 | };
59 | if (type === '我的发布' || type === '我的收藏') {
60 | const {userName} = yield select(selectUserInfo);
61 | params.userName = userName;
62 | }
63 | const { data } = yield call(requestUrl, params);
64 | yield put(fetchTopicListSuccess({
65 | ...data,
66 | type,
67 | categoryName
68 | }));
69 | } catch (error) {
70 | console.log(error);
71 | yield put(fetchTopicListFail());
72 | }
73 | }
74 |
75 | export default function* rootSagas() {
76 | yield all([
77 | takeLatest(FETCH_TOPIC_LIST, topicList),
78 | takeLatest(GET_USER_INFO, userInfo),
79 | takeLatest(USER_SIGN_OUT, userSignOut),
80 | takeLatest(FETCH_CHANNEL_LIST, channelList)
81 | ]);
82 | }
83 |
--------------------------------------------------------------------------------
/components/NoLayout/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { NextPage } from 'next';
3 | import { useDispatch } from 'react-redux'
4 | import NoHeader from '../NoHeader';
5 | import NoFooter from '../NoFooter';
6 | import { message, } from 'antd';
7 | import { userLogOut } from '../../api';
8 | import { connect } from 'react-redux';
9 | import Router from 'next/router';
10 | import './index.less';
11 | import { getUserInfo } from '../../redux/actions/user';
12 | import { fetchChannelList } from '../../redux/actions/channel';
13 | import { User } from '../../@types'
14 | import { AppStateType } from '../../redux/reducers'
15 |
16 | interface NoLayoutProps {
17 | title?: string,
18 | children?: React.ReactNode,
19 | userInfo: User
20 | }
21 |
22 | const NoLayout: NextPage = (props) => {
23 | const dispatch = useDispatch()
24 | const [collapsed, setCollapsed] = useState(false)
25 | useEffect(() => {
26 |
27 | dispatch(fetchChannelList());
28 | const _userCode = window.localStorage.getItem('userName');
29 | if (_userCode) {
30 | dispatch(getUserInfo({
31 | userName: _userCode
32 | }));
33 |
34 | }
35 | }, [])
36 | const handleChangeCollapsed = () => {
37 | setCollapsed(!collapsed)
38 | }
39 | // const handleSelectMenu = (key: any) => {
40 | // Router.push(key);
41 | // }
42 | const handleSelectUserItem = async e => {
43 | switch (e.key) {
44 | case 'signOut':
45 | var res = await userLogOut();
46 | if (res.success) {
47 | message.success(res.message);
48 | dispatch({
49 | type: 'USER_SIGN_OUT',
50 | });
51 | }
52 | window.localStorage.removeItem('Token');
53 | window.localStorage.removeItem('userName');
54 | break;
55 | case 'changePass':
56 | Router.push('/changePass');
57 | break;
58 | case 'changeUserInfo':
59 | Router.push('/modifyUser');
60 | break;
61 | }
62 | };
63 |
64 | return (
65 |
66 |
67 |
73 |
{props.children}
74 |
75 |
76 |
77 | );
78 | }
79 |
80 | const mapStateToProps = (state: AppStateType) => ({
81 | channelList: state.channel.list,
82 | userInfo: state.user,
83 | });
84 |
85 | export default connect(mapStateToProps)(NoLayout);
86 |
--------------------------------------------------------------------------------
/components/NoHeader/index.tsx:
--------------------------------------------------------------------------------
1 |
2 | import { NextPage } from 'next';
3 | import { ClickParam } from 'antd/es/menu';
4 | import { DownOutlined } from '@ant-design/icons';
5 | import { Menu, Dropdown, Button, Input } from 'antd';
6 | const { Search } = Input;
7 | import Router from 'next/router';
8 | import { User } from '../../@types'
9 | // const { SubMenu } = Menu;
10 | import Link from 'next/link';
11 | import './index.less';
12 |
13 |
14 | // Only holds serverRuntimeConfig and publicRuntimeConfig from next.config.js nothing else.
15 |
16 | interface NoHeaderProps {
17 | onToggle?: () => void,
18 | onUserClick?: (e: ClickParam) => Promise,
19 | userInfo?: User,
20 | isCollapsed?: boolean
21 | }
22 |
23 | const NoHeader: NextPage = (props) => {
24 | const { onUserClick, userInfo } = props;
25 | const handleGotoHome = () => {
26 | Router.push('/');
27 | }
28 |
29 | const menu = () => (
30 |
46 | );
47 | return (
48 |
49 |
50 | {/*
*/}
55 |
56 | Nobibi
57 |
58 |
console.log(value)}
62 | className='header-search-container'
63 | />
64 |
65 | {userInfo.userName ?
66 | :
74 |
75 |
76 | 登录
77 |
78 |
83 |
84 | }
85 |
86 |
87 | );
88 | }
89 |
90 |
91 | export default NoHeader;
92 |
93 |
94 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Nobibi-next",
3 | "version": "1.0.0",
4 | "description": "A Next.js appliction",
5 | "main": "server.js",
6 | "author": "seawind8888",
7 | "license": "MIT",
8 | "husky": {
9 | "hooks": {
10 | "pre-commit": "lint-staged"
11 | }
12 | },
13 | "lint-staged": {
14 | "*.{js,jsx}": [
15 | "eslint --no-ignore --fix",
16 | "git add --force"
17 | ],
18 | "*.{json,md}": [
19 | "prettier --write",
20 | "git add --force"
21 | ]
22 | },
23 | "scripts": {
24 | "next": "next",
25 | "start": "node server.js",
26 | "pm2": "pm2 start pm2.config.js",
27 | "build": "cross-env NODE_ENV=production next build",
28 | "prod": "cross-env NODE_ENV=production node server.js",
29 | "lint:fix": "eslint --fix"
30 | },
31 | "keywords": [
32 | "nextjs",
33 | "antd",
34 | "react",
35 | "ssr",
36 | "redux",
37 | "redux-saga"
38 | ],
39 | "dependencies": {
40 | "@types/next": "^9.0.0",
41 | "@types/next-redux-wrapper": "^3.0.0",
42 | "@types/react": "^16.9.17",
43 | "@types/react-dom": "^16.9.4",
44 | "@types/react-redux": "^7.1.6",
45 | "@zeit/next-css": "^1.0.1",
46 | "@zeit/next-less": "^1.0.1",
47 | "@zeit/next-typescript": "^1.1.1",
48 | "antd": "^4.1.0",
49 | "babel-plugin-import": "^1.9.0",
50 | "dotenv": "^8.0.0",
51 | "es6-promise": "^4.2.5",
52 | "express": "^4.16.3",
53 | "if-comp": "^0.0.8",
54 | "isomorphic-unfetch": "^3.0.0",
55 | "less": "^3.8.1",
56 | "less-vars-to-js": "^1.3.0",
57 | "lodash": ">=4.17.13",
58 | "md5": "^2.2.1",
59 | "next": "^9.3.3",
60 | "next-redux-saga": "^3.0.0",
61 | "next-redux-wrapper": "^2.0.0",
62 | "prop-types": "^15.6.2",
63 | "query-string": "^6.2.0",
64 | "react": "^16.8.6",
65 | "react-dom": "^16.8.6",
66 | "react-no-ssr": "^1.1.0",
67 | "react-redux": "^7.2.0",
68 | "react-share": "^4.1.0",
69 | "redux": "^4.0.0",
70 | "redux-logger": "^3.0.6",
71 | "redux-saga": "^0.16.0"
72 | },
73 | "devDependencies": {
74 | "@ant-design/icons": "^4.0.5",
75 | "@types/node": "^12.12.14",
76 | "axios": "^0.19.0",
77 | "babel-eslint": "^9.0.0",
78 | "babel-plugin-lodash": "^3.3.4",
79 | "braft-editor": "^2.3.7",
80 | "cross-env": "^5.2.0",
81 | "eslint": "^5.4.0",
82 | "eslint-config-react-app": "^2.1.0",
83 | "eslint-loader": "^2.1.0",
84 | "eslint-plugin-flowtype": "^2.50.0",
85 | "eslint-plugin-import": "^2.14.0",
86 | "eslint-plugin-jsx-a11y": "^6.1.1",
87 | "eslint-plugin-react": "^7.11.1",
88 | "husky": "^1.3.1",
89 | "js-cookie": "^2.2.0",
90 | "lint-staged": "^8.1.0",
91 | "prettier": "^1.15.3",
92 | "redux-devtools-extension": "^2.13.5",
93 | "terser-webpack-plugin": "^1.1.0",
94 | "typescript": "^3.7.2",
95 | "webpack": "^4.35.3",
96 | "webpack-bundle-analyzer": "^3.3.2"
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Nobibi
2 |
3 | 简体中文 | [English](./README_en.md)
4 |
5 | > Nobibi 是一款轻量级开源社区,快速搭建属于自己的社区
6 |
7 | > 已更新问 ts 版本,原 js 版本请访问:https://github.com/seawind8888/Nobibi/tree/js
8 |
9 | ## 关于 Nobibi
10 |
11 | - Nobibi 是一款轻量级开源社区,包含前后台
12 | - 前台考虑 SEO 使用 [next.js](https://github.com/zeit/next.js) + antd 服务端渲结构
13 | - 后台系统基于[Ant Design Pro](https://pro.ant.design/index-cn)(react + dvajs + umijs)搭建开发
14 | - 后端接口为 koa+moogoose
15 |
16 | ## 快速开始
17 |
18 | > 保证已启动 api 项目[Nobibi-api](https://github.com/seawind8888/Nobibi-api)
19 |
20 | 1. Clone 项目
21 |
22 | ```
23 | git clone https://github.com/seawind8888/Nobibi my-project
24 | ```
25 |
26 | 2. 安装依赖
27 |
28 | ```
29 | cd my-porject
30 | npm install 或 yarn
31 | ```
32 |
33 | 3. 运行项目
34 |
35 | ```
36 | npm run start
37 | ```
38 |
39 | ## 相关项目
40 |
41 | - [Nobibi-api](https://github.com/seawind8888/Nobibi-api) - Nobibi 后台接口
42 | - [Nobibi-admin](https://github.com/seawind8888/Nobibi-admin) - Nobibi 管理后台
43 | - Nobibi-taro - Nobibi 小程序(待开发)
44 | - Nobibi-nuxt - (待开发)
45 |
46 | ## 示例项目
47 |
48 | 请移步:[http://47.244.103.124:3006/](http://47.244.103.124:3006/)
49 |
50 | ## 效果演示
51 |
52 | - 前台
53 | 
54 | 
55 | 
56 | - 管理后台
57 | 
58 |
59 | ## 项目部署
60 |
61 | > 保证已启动 api 项目[Nobibi-api](https://github.com/seawind8888/Nobibi-api)
62 |
63 | 1. 修改.env 文件下配置
64 |
65 | ```
66 | BASE_URL=http://yourapihost:port // 你的api的host地址
67 | ```
68 |
69 | 2. 将项目除去 node_modules 压缩,上传到服务器
70 |
71 | ```
72 | windows&mac有异同,请自行百度或科学Goo
73 | ```
74 |
75 | 3. 在服务器项目目录下运行
76 |
77 | ```
78 | npm run build && npm run pm2
79 | ```
80 |
81 | ## 技术选型
82 |
83 | 
84 |
85 | ## 目录结构
86 |
87 | ```lua
88 | ant-cms-admin
89 | ├── api/
90 | │ ├── index.js/ # 接口部分
91 | ├── assets/ # less目录
92 | ├── components/ # 组件目录
93 | ├── constatns/
94 | │ ├── ActionTypes.js/ # redux-sage action-type
95 | │ ├── ConstTypes.js/ # next 页面title 配置
96 | │ └── CustomTheme.js # 主题样式配置
97 | ├── pages # 主页面
98 | │ ├── _app.js/ # App根组件自定义
99 | │ ├── _document.js/ # document组件自定义
100 | ├── redux # redux目录
101 | ├── static # 静态资源引用目录
102 | ├── .editorconfig # 编辑器配置
103 | ├── .eslintrc # ESlint配置
104 | ├── .gitignore # Git忽略文件配置
105 | ├── .prettierignore # Prettier忽略文件配置
106 | ├── .prettierrc # Prettier配置
107 | ├── next.config.js # next配置
108 | ├── pm2.config.js # pm2配置
109 | ├── server # next服务配置
110 | ```
111 |
112 | ## 功能模块
113 |
114 | - [x] 注册
115 | - [x] 登录(持久化)
116 | - [x] 修改密码
117 | - [x] 修改资料
118 | - [x] 发布主题
119 | - [x] 评论主题
120 | - [x] 频道切换
121 | - [x] 点赞
122 | - [x] 响应式布局
123 | - [x] 收藏
124 | - [x] 分享(待开发)
125 | - [x] 积分(待开发)
126 |
--------------------------------------------------------------------------------
/api/index.ts:
--------------------------------------------------------------------------------
1 | import { AxiosResponse} from 'axios';
2 | import fetch from '../utils/fetch';
3 |
4 | export const userLogin = params => {
5 | return fetch({
6 | method: 'post',
7 | url: '/api/user/login',
8 | data: params,
9 | });
10 | };
11 | export const userLogOut = () => {
12 | return fetch({
13 | method: 'get',
14 | useToken: true,
15 | url: '/api/user/logout',
16 | });
17 | };
18 |
19 | export const userRegister = params => {
20 | return fetch({
21 | method: 'post',
22 | url: '/api/user/createUser',
23 | data: params,
24 | });
25 | };
26 | export const changePassApi = params => {
27 | return fetch({
28 | method: 'post',
29 | useToken: true,
30 | url: '/api/user/changePass',
31 | data: params,
32 | });
33 | };
34 | export const modifyUserApi = params => {
35 | return fetch({
36 | method: 'post',
37 | url: '/api/user/updateUser',
38 | data: params,
39 | });
40 | };
41 |
42 | export const getUserInfo = params => {
43 | return fetch({
44 | method: 'get',
45 | url: '/api/user/getUserInfo',
46 | useToken: true,
47 | params: params,
48 | });
49 | };
50 |
51 | export const fetchTopicList = params => {
52 | return fetch({
53 | method: 'get',
54 | url: '/api/topic/getTopicList',
55 | params: params,
56 | });
57 | };
58 | export const updateTopicItem = params => {
59 | return fetch({
60 | method: 'get',
61 | url: '/topic/updateTopic',
62 | params: params,
63 | });
64 | };
65 |
66 | export const createTopic = params => {
67 | return fetch({
68 | method: 'post',
69 | useToken: true,
70 | url: '/api/topic/createTopic',
71 | data: params,
72 | });
73 | };
74 |
75 | export const fetchChannelList = (params:any): Promise => {
76 | return fetch({
77 | method: 'get',
78 | url: '/api/category/getCategoryList',
79 | params: params,
80 | });
81 | };
82 |
83 | export const fetchCommentList = params => {
84 | return fetch({
85 | method: 'get',
86 | url: '/api/comment/getCommentList',
87 | params: params,
88 | });
89 | };
90 |
91 | export const addComment = params => {
92 | return fetch({
93 | method: 'post',
94 | useToken: true,
95 | url: '/api/comment/addComment',
96 | data: params,
97 | });
98 | };
99 |
100 | export const actionPraise = params => {
101 | return fetch({
102 | method: 'post',
103 | useToken: true,
104 | url: '/api/praise/praiseAction',
105 | data: params,
106 | });
107 | };
108 |
109 | export const fetchPraiseInfo = params => {
110 | return fetch({
111 | method: 'get',
112 | url: '/api/praise/getPraiseInfo',
113 | params: params,
114 | });
115 | };
116 |
117 | export const actionFavoriteTopic = params => {
118 | return fetch({
119 | method: 'post',
120 | useToken: true,
121 | url: '/api/favorite/favoriteAction',
122 | data: params,
123 | });
124 | };
125 |
126 | export const getFavoriteTopic = params => {
127 | return fetch({
128 | method: 'get',
129 | useToken: true,
130 | url: '/api/favorite/getFavoriteList',
131 | params: params,
132 | });
133 | };
--------------------------------------------------------------------------------
/pages/login.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { NextPage } from 'next';
3 | import { useDispatch } from 'react-redux'
4 | import { UserOutlined, LockOutlined } from '@ant-design/icons';
5 | import { Form, Input, Checkbox, Button, Breadcrumb } from 'antd';
6 | import { connect } from 'react-redux';
7 | import { userLogin } from '../api';
8 | import { message } from 'antd';
9 | import Router from 'next/router';
10 | import Link from 'next/link';
11 | import md5 from 'md5';
12 |
13 |
14 | const Login: NextPage<{}> = () => {
15 | const [form] = Form.useForm();
16 | const dispatch = useDispatch()
17 | const handleSubmit = async (values) => {
18 | const _userInfo = await form.validateFields()
19 | _userInfo.password = md5(_userInfo.password);
20 | window.localStorage.setItem('userName', _userInfo.username);
21 | const { success, data } = await userLogin(_userInfo);
22 | if (success) {
23 | message.success('来了,您呐');
24 | window.localStorage.setItem('Token', data.token);
25 | dispatch({
26 | type: 'GET_USER_INFO',
27 | payload: {
28 | userName: _userInfo.username,
29 | },
30 | });
31 | Router.push('/');
32 | }
33 | };
34 |
35 | return (
36 |
37 | login
38 |
39 |
40 |
41 | 首页
42 |
43 |
44 |
45 |
46 |
47 | 登录
48 |
49 |
50 |
51 |
52 |
96 |
97 | );
98 | }
99 |
100 |
101 |
102 |
103 | export default connect(state => state)(Login)
104 |
105 |
--------------------------------------------------------------------------------
/pages/topicEdit.tsx:
--------------------------------------------------------------------------------
1 |
2 | import React, { useState } from 'react';
3 | import { NextPage } from 'next';
4 | import { Form, Input, Select, Button, message, Breadcrumb } from 'antd';
5 | import { connect } from 'react-redux';
6 | import Router from 'next/router';
7 | import Link from 'next/link';
8 | import { createTopic } from '../api';
9 | import TopicEditor from '../components/TopicEditor';
10 | import { AppStateType } from '../redux/reducers'
11 | import { User, Topic } from '../@types'
12 |
13 |
14 | interface TopicEditProps {
15 | userInfo: User,
16 | channelList: object[]
17 | }
18 |
19 | const TopicEdit: NextPage = (props) => {
20 | const [form] = Form.useForm();
21 | const [content, setContent] = useState(null)
22 | const handleSubmit = async () => {
23 | const fieldsValue = await form.validateFields()
24 | const { userInfo } = props;
25 | const _params = fieldsValue;
26 | _params.userName = userInfo.userName;
27 | _params.userAvatar = userInfo.avatar;
28 | _params.status = "PUBLISH";
29 | if (!content) {
30 | message.error('请输入内容');
31 | return;
32 | }
33 | _params.desc = content.toRAW(true).blocks[0].text.slice(0, 50);
34 | _params.content = content.toHTML();
35 | const { success } = await createTopic(_params);
36 | if (success) {
37 | message.success('发布成功');
38 | Router.push('/');
39 | }
40 | }
41 | const handleEditorChange = (e: any) => {
42 | setContent(e)
43 | }
44 | const { channelList } = props;
45 | return (
46 |
47 |
48 |
49 |
50 | 首页
51 |
52 |
53 |
54 |
55 | 发布主题
56 |
57 |
58 |
59 |
60 |
68 |
71 |
72 |
76 |
86 |
87 |
88 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 | );
100 | }
101 |
102 | const mapStateToProps = (state: AppStateType) => ({
103 | userInfo: state.user,
104 | channelList: state.channel.list,
105 | });
106 |
107 | export default connect(mapStateToProps)(TopicEdit);
--------------------------------------------------------------------------------
/components/CommentList/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { NextPage } from 'next';
3 | import { Comment, List, message } from 'antd';
4 | import Router from 'next/router';
5 | import Editor from '../Editor';
6 | import { fetchCommentList, addComment } from '../../api';
7 | import timer from '../../utils/timer';
8 | import { connect } from 'react-redux';
9 | import NoAvatar from '../NoAvatar';
10 |
11 | import { AppStateType } from '../../redux/reducers'
12 | import { User } from '../../@types'
13 |
14 | interface CommentListProps {
15 | topicTitle: string,
16 | topicId: string,
17 | userInfo: User
18 | }
19 |
20 | const CommentList: NextPage = (props) => {
21 | const [comments, setComments] = useState([])
22 | const [submitting, setSubmitting] = useState(false)
23 | const [content, setContent] = useState('')
24 | const { userInfo } = props;
25 |
26 | useEffect(() => {
27 | getCommentList();
28 | }, [])
29 |
30 | const getCommentList = async () => {
31 | const { topicId } = props;
32 | const { data } = await fetchCommentList({
33 | topicId: topicId,
34 | });
35 | if (data.list.length) setComments(initComment(data.list))
36 | }
37 |
38 | const handleSubmit = async () => {
39 | if (
40 | typeof window !== 'undefined' &&
41 | !window.localStorage.getItem('Token')
42 | ) {
43 | Router.push('/login');
44 | return;
45 | }
46 | const { topicTitle, topicId, userInfo } = props;
47 | if (!content) {
48 | return;
49 | }
50 | setSubmitting(true)
51 |
52 | const { success } = await addComment({
53 | topicTitle: topicTitle,
54 | topicId: topicId,
55 | userName: userInfo.userName,
56 | userAvatar: userInfo.avatar,
57 | content: content
58 | });
59 | setSubmitting(false)
60 | if (success) {
61 | message.success('bibi成功啦!');
62 | getCommentList();
63 | }
64 | };
65 |
66 | const initComment = list => {
67 | return list.map(e => {
68 | return {
69 | author: e.userName,
70 | avatar: e.userAvatar,
71 | content: e.content,
72 | datetime: timer(Date.parse(e.updateTime)),
73 | };
74 | });
75 | };
76 |
77 | const handleChange = e => {
78 | setContent(e.target.value)
79 | };
80 |
81 |
82 | return (
83 |
84 | {comments.length > 0 && (
85 | (
90 |
97 | }
98 | author={item.author}
99 | content={item.content}
100 | datetime={item.datetime}
101 | />
102 | )}
103 | />
104 | )}
105 |
112 | }
113 | content={
114 |
120 | }
121 | />
122 |
123 | );
124 | }
125 |
126 | const mapStateToProps = (state: AppStateType) => ({
127 | userInfo: state.user,
128 | });
129 |
130 | export default connect(mapStateToProps)(CommentList);
131 |
--------------------------------------------------------------------------------
/pages/register.tsx:
--------------------------------------------------------------------------------
1 | import { NextPage } from 'next';
2 | import { Form, Input, Button, Breadcrumb } from 'antd';
3 | import { UserOutlined, LockOutlined, MailOutlined } from '@ant-design/icons';
4 | import { connect } from 'react-redux';
5 | import md5 from 'md5';
6 | import { userRegister } from '../api';
7 | import { message } from 'antd';
8 | import Router from 'next/router';
9 | import { User } from '../@types'
10 | import { getRandomColor } from '../utils';
11 | import Link from 'next/link';
12 |
13 |
14 | const Register: NextPage<{}> = () => {
15 | const [form] = Form.useForm();
16 | const handleSubmit = async () => {
17 | const fieldsValue: User = await form.validateFields()
18 | fieldsValue.avatar = getRandomColor();
19 | fieldsValue.password = md5(fieldsValue.password)
20 | const data = await userRegister(fieldsValue);
21 | if (data.success) {
22 | message.success('注册成功,欢迎来到Nobibi,也请别瞎bibi');
23 | Router.push('/login');
24 | }
25 | }
26 |
27 | return (
28 |
101 | )
102 |
103 | };
104 |
105 | export default connect(state => state)(Register);
--------------------------------------------------------------------------------
/pages/modifyUser.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { NextPage } from 'next';
3 | import { UserOutlined } from '@ant-design/icons';
4 | import { Form, Upload, message, Input, Button, Breadcrumb } from 'antd';
5 | import { connect } from 'react-redux';
6 | import { getBase64, beforeUpload } from '../utils'
7 | import NoAvatar from '../components/NoAvatar';
8 | import { modifyUserApi } from '../api';
9 | import { User } from '../@types'
10 | import { AppStateType } from '../redux/reducers'
11 | import Link from 'next/link';
12 |
13 |
14 |
15 |
16 |
17 | interface ModifyUserProps {
18 | userInfo: User
19 | }
20 |
21 |
22 | const ModifyUser: NextPage = (props) => {
23 | const [form] = Form.useForm();
24 | const { userInfo } = props
25 | const [imageUrl, setImageUrl] = useState('')
26 | const [loading, setLoading] = useState(false)
27 |
28 | const handleChange = (info: any) => {
29 | if (info.file.status === 'uploading') {
30 | setLoading(true);
31 | getBase64(info.file.originFileObj, imageUrl => {
32 | setImageUrl(imageUrl)
33 | setLoading(false);
34 | })
35 | }
36 | };
37 |
38 | const handleSubmit = async () => {
39 | const fieldsValue = await form.validateFields()
40 | const _params: User = { ...fieldsValue, _id: userInfo._id };
41 | if (imageUrl) {
42 | _params.avatar = imageUrl;
43 | }
44 | const { success } = await modifyUserApi(_params);
45 | if (success) {
46 | message.success('修改成功!');
47 | }
48 | }
49 |
50 | const uploadButton = () => (
51 |
52 |
57 |
58 | );
59 |
60 | return (
61 |
62 |
63 |
64 |
65 | 首页
66 |
67 |
68 |
69 |
70 |
71 | 修改资料
72 |
73 |
74 |
75 |
76 |
122 |
123 |
124 | );
125 | }
126 |
127 | const mapStateToProps = (state: AppStateType) => ({
128 | userInfo: state.user
129 | });
130 |
131 | export default connect(mapStateToProps)(ModifyUser);
--------------------------------------------------------------------------------
/pages/changePass.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { NextPage } from 'next';
3 | import { useDispatch } from 'react-redux'
4 | import { useRouter } from 'next/router';
5 | import { changePassApi } from '../api';
6 | import { Form, Input, Button, message, Breadcrumb } from 'antd';
7 | import { LockOutlined } from '@ant-design/icons';
8 | import { connect } from 'react-redux';
9 | import md5 from 'md5';
10 | import Link from 'next/link';
11 | import { AppStateType } from '../redux/reducers'
12 | import { User } from '../@types'
13 |
14 | interface changePassProps {
15 | userInfo: User
16 | }
17 |
18 | const changePass: NextPage = (props) => {
19 | const [form] = Form.useForm();
20 | const dispatch = useDispatch()
21 | const router = useRouter()
22 | const handleSubmit = async () => {
23 | const { userInfo } = props;
24 | const fieldsValue = await form.validateFields()
25 | const _oldPass = md5(fieldsValue.oldPass);
26 | const _newPass = md5(fieldsValue.newPass);
27 | const data = await changePassApi({
28 | oldPass: _oldPass,
29 | newPass: _newPass,
30 | userName: userInfo.userName,
31 | });
32 | if (data.success) {
33 | message.success('修改成功,可以重新登陆bibi了');
34 | dispatch({
35 | type: 'USER_SIGN_OUT',
36 | });
37 | window.localStorage.removeItem('userName');
38 | window.localStorage.removeItem('Token');
39 | router.push('/login');
40 | } else {
41 | message.error(data.message);
42 | }
43 | }
44 |
45 | const compareToFirstPassword = (rule, value, callback) => {
46 | if (value && value !== form.getFieldValue('newPass')) {
47 | callback('Two passwords that you enter is inconsistent!');
48 | } else {
49 | callback();
50 | }
51 | };
52 |
53 | return (
54 |
55 |
56 |
57 |
58 | 首页
59 |
60 |
61 |
62 |
63 |
64 | 修改密码
65 |
66 |
67 |
68 |
69 |
70 |
77 |
80 | }
81 | type='password'
82 | placeholder='输入旧密码'
83 | />
84 |
85 |
88 |
91 | }
92 | type='password'
93 | placeholder='输入新密码'
94 | />
95 |
96 |
104 |
107 | }
108 | type='confirm'
109 | placeholder='确认新密码'
110 | />
111 |
112 |
113 |
120 |
121 |
122 |
123 |
124 | );
125 | };
126 |
127 | const mapStateToProps = (state: AppStateType) => ({
128 | userInfo: state.user,
129 | });
130 |
131 | export default connect(mapStateToProps)(changePass)
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | const withLess = require('@zeit/next-less');
3 | const lessToJS = require('less-vars-to-js');
4 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
5 | const TerserPlugin = require('terser-webpack-plugin');
6 | const fs = require('fs');
7 | const path = require('path');
8 | const withCSS = require('@zeit/next-css');
9 | const { DefinePlugin } = require('webpack');
10 | const withTypescript = require('@zeit/next-typescript');
11 | const { parsed } = require('dotenv').config();
12 | const { BASE_URL } = parsed;
13 | // Where your antd-custom.less file lives
14 | const themeVariables = lessToJS(
15 | fs.readFileSync(path.resolve(__dirname, './assets/antd-custom.less'), 'utf8'),
16 | );
17 |
18 | const isDev = process.env.NODE_ENV !== 'production';
19 |
20 | // fix antd bug in dev development
21 | const devAntd = '@import "~antd/dist/antd.less";\n';
22 | const stylesData = fs.readFileSync(
23 | path.resolve(__dirname, './assets/_styles.less'),
24 | 'utf-8',
25 | );
26 | fs.writeFileSync(
27 | path.resolve(__dirname, './assets/self-styles.less'),
28 | isDev ? `${devAntd}${stylesData}` : stylesData,
29 | 'utf-8',
30 | );
31 |
32 | // fix: prevents error when .css files are required by node
33 | if (typeof require !== 'undefined') {
34 | require.extensions['.less'] = file => {};
35 | }
36 |
37 | module.exports = withTypescript(
38 | withLess(
39 | withCSS({
40 | lessLoaderOptions: {
41 | javascriptEnabled: true,
42 | modifyVars: themeVariables,
43 | localIdentName: '[local]___[hash:base64:5]',
44 | },
45 |
46 | webpack: (config, { buildId, dev, isServer, defaultLoaders }) => {
47 | if (!dev) {
48 | config.plugins.push(
49 | ...[
50 | new BundleAnalyzerPlugin({
51 | analyzerMode: 'disabled',
52 | // For all options see https://github.com/th0r/webpack-bundle-analyzer#as-plugin
53 | generateStatsFile: true,
54 | // Will be available at `.next/stats.json`
55 | statsFilename: 'stats.json',
56 | }),
57 | // 代替uglyJsPlugin
58 | new TerserPlugin({
59 | terserOptions: {
60 | ecma: 6,
61 | warnings: false,
62 | extractComments: false, // remove comment
63 | compress: {
64 | drop_console: true, // remove console
65 | },
66 | ie8: false,
67 | },
68 | }),
69 | new DefinePlugin({
70 | 'process.env': {
71 | BASE_URL: JSON.stringify(BASE_URL),
72 | },
73 | }),
74 | ],
75 | );
76 | config.devtool = 'source-map';
77 | } else {
78 | config.module.rules.push({
79 | test: /\.js$/,
80 | enforce: 'pre',
81 | include: [
82 | path.resolve('components'),
83 | path.resolve('pages'),
84 | path.resolve('utils'),
85 | path.resolve('constants'),
86 | path.resolve('redux'),
87 | path.resolve('containers'),
88 | ],
89 | options: {
90 | configFile: path.resolve('.eslintrc'),
91 | eslint: {
92 | configFile: path.resolve(__dirname, '.eslintrc'),
93 | },
94 | },
95 | loader: 'eslint-loader',
96 | });
97 | config.plugins.push(
98 | ...[
99 | new DefinePlugin({
100 | 'process.env': {
101 | BASE_URL: JSON.stringify('http://localhost:3001'),
102 | },
103 | }),
104 | ],
105 | );
106 |
107 | config.devtool = 'cheap-module-inline-source-map';
108 | }
109 | return config;
110 | },
111 |
112 | webpackDevMiddleware: config => {
113 | // Perform customizations to webpack dev middleware config
114 | // console.log(config, '@@')
115 | // Important: return the modified config
116 | return config;
117 | },
118 | serverRuntimeConfig: {
119 | // Will only be available on the server side
120 | rootDir: path.join(__dirname, './'),
121 | PORT: isDev ? 3006 : process.env.PORT || 3006,
122 | },
123 | publicRuntimeConfig: {
124 | // Will be available on both server and client
125 | staticFolder: '/static',
126 | isDev, // Pass through env variables
127 | },
128 | }),
129 | ),
130 | );
131 |
--------------------------------------------------------------------------------
/components/NoLayout/index.less:
--------------------------------------------------------------------------------
1 | .main-container {
2 | display: flex;
3 | justify-content: center;
4 | }
5 |
6 | .topic-pagenation-container {
7 | display: flex;
8 | margin: 30px 0;
9 | justify-content: center;
10 | }
11 |
12 | .main-inside-container {
13 | // display: flex;
14 | max-width: 1100px;
15 | padding: 0 15px;
16 | width: 100%;
17 | /* height: 2000px; */
18 | /* background-color: aqua */
19 | }
20 | .menu-group-left {
21 | display: none;
22 |
23 | }
24 | .ant-menu-inline {
25 | min-width: 236px !important;
26 | }
27 | .ant-drawer-body {
28 | padding:0 !important;
29 | }
30 | /* Home Part CSS */
31 | .home-container{
32 |
33 | &::after {
34 | display: block;
35 | content: '';
36 | clear: both;
37 | }
38 | .list-item-container {
39 | width: 100%;
40 | float: left;
41 | padding: 10px 0;
42 | max-width: 740px;
43 | background: #ffffff;
44 | .topic-channel-button {
45 | margin-right: 5px;
46 | margin-bottom: 5px;
47 | height: 28px;
48 | font-size: 14px
49 | }
50 | }
51 | .home-right-container {
52 | margin-top: 30px;
53 | width: 300px;
54 | display: flex;
55 | float: right;
56 | flex-direction: column;
57 | .user-info-container {
58 | display: flex;
59 | padding-bottom: 10px;
60 | align-items: center;
61 | border-bottom: 1px solid #dddddd;
62 | &--right {
63 | display: flex;
64 | flex-direction: column;
65 | }
66 | }
67 |
68 | .hot-topic-container {
69 | margin-top: 20px;
70 | padding-left: 5px;
71 | display: flex;
72 | flex-direction: column;
73 | border-bottom: 1px solid #dddddd;
74 | .hot-title-container {
75 | color: #52c41a;
76 | display: flex;
77 | font-size: 22px;
78 | font-weight: bold;
79 | margin-bottom: 5px;
80 | .title-start-block {
81 | height: 26px;
82 | width: 5px;
83 | background-color: #52c41a;
84 | margin-right: 10px;
85 | }
86 | }
87 | .hot-list-container {
88 | list-style: none;
89 | padding-left: 15px;
90 | }
91 | }
92 | .ad-container {
93 | width: 100%;
94 |
95 | height: 100px;
96 | display: flex;
97 | align-items: center;
98 | justify-content: center;
99 | }
100 | }
101 | .topic-container {
102 | position: relative;
103 | display: flex;
104 | align-items: center;
105 | padding: 10px 0;
106 | .left-item {
107 | margin-right: 15px;
108 | }
109 | .right-item {
110 | max-width: 72%;
111 | display: flex;
112 | flex-direction: column;
113 | h1 {
114 | font-size: 18px;
115 | margin-bottom: 5px;
116 | overflow: hidden;
117 | text-overflow:ellipsis;
118 | white-space: nowrap;
119 | }
120 | .desc {
121 | color: #979797;
122 | font-size: 16px;
123 | margin-bottom: 5px;
124 | overflow: hidden;
125 | text-overflow:ellipsis;
126 | white-space: nowrap;
127 | }
128 | .bottom-info {
129 | display: flex;
130 | align-items: center;
131 | font-size: 12px;
132 | .bottom-tag {
133 | border-radius: 3px;
134 | }
135 | .info-item {
136 | margin: 0 5px;
137 | }
138 | }
139 | }
140 | .comment-info-container {
141 | position: absolute;
142 | right: 0;
143 | border-radius: 25px;
144 | padding: 0 15px;
145 | height: 25px;
146 | line-height: 25px;
147 | background-color: #dddddd;
148 | color: #ffffff;
149 | }
150 | }
151 | }
152 |
153 | /* TopicDetail Part CSS */
154 | .topic-detail-container {
155 | .detail-title {
156 | margin: 30px 0;
157 | font-size: 46px;
158 | word-wrap: break-word;
159 | }
160 | .main-info-container {
161 | display: flex;
162 | position: relative;
163 | .user-avatar {
164 | margin-right: 10px;
165 |
166 | }
167 | .praise-control-container {
168 | position: absolute;
169 | right: 0;
170 | top: 0;
171 | display: flex;
172 | flex-direction: column;
173 | }
174 | }
175 | .main-control-container {
176 | padding-left: 60px;
177 |
178 | }
179 | .topic-content-container {
180 | font-size: 18px;
181 | color: black;
182 | word-wrap: break-word;
183 | }
184 | .comment-list {
185 | margin-top: 20px;
186 | }
187 | .share-icon {
188 | margin-left: 10px;
189 | }
190 | }
191 |
192 |
193 | /* TopicEdit Part CSS */
194 | .editer-container {
195 | width: 740px;
196 | margin-top: 40px !important;
197 | }
198 |
199 |
200 | @media screen and (max-width: 768px) {
201 | .home-container .topic-container .right-item .bottom-info .hide-item {
202 | display: none;
203 | }
204 | .menu-group-left {
205 | display: block;
206 | }
207 | }
208 |
209 |
210 |
211 |
212 | @media screen and (max-width: 1100px) {
213 | .home-container .home-right-container {
214 | display: none;
215 | }
216 | }
217 |
218 | .login-form {
219 | margin-top: 150px !important;
220 | width: 300px;
221 |
222 | }
223 | .login-form-forgot {
224 | float: right;
225 | }
226 | .login-form-button {
227 | width: 100%;
228 | }
229 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 |
2 | // import PropTypes from 'prop-types';
3 | import React, { useState, useEffect } from 'react'
4 | import { Pagination, Button, Breadcrumb } from 'antd';
5 | import { NextPage, NextJSContext } from 'next-redux-wrapper';
6 | import { connect, useDispatch } from 'react-redux';
7 | import Link from 'next/link';
8 | import TopicItem from '../components/TopicItem';
9 | import { fetchTopicList } from '../api';
10 | import { fetchTopiclList } from '../redux/actions/topic';
11 | import Router from 'next/router';
12 | import NoAvatar from '../components/NoAvatar';
13 | import { User, Topic } from '../@types/index'
14 | import { AppStateType } from '../redux/reducers'
15 |
16 | interface HomeProps {
17 | topicInfo: Topic,
18 | userInfo: User,
19 | channelList: [],
20 | breadCrumbList: []
21 | }
22 |
23 | const Home: NextPage = (props) => {
24 |
25 | const { userInfo, channelList, topicInfo, breadCrumbList } = props;
26 | const [hotTopicList, setHotTopicList] = useState([])
27 | const dispatch = useDispatch();
28 |
29 |
30 | useEffect(() => {
31 | handleGetHotTopicList()
32 | }, [])
33 |
34 | const handleGetHotTopicList = async () => {
35 | const { data } = await fetchTopicList({
36 | hot: true
37 | });
38 | setHotTopicList(data.list);
39 | }
40 |
41 | const handleGetTopicList = async ({
42 | showAll = false,
43 | type = '',
44 | categoryName = '',
45 | page = 1
46 | }) => {
47 |
48 | const _type = type || topicInfo.type;
49 | let _categoryName = categoryName || topicInfo.categoryName;
50 | if (showAll) {
51 | _categoryName = '';
52 | }
53 | if (!type && categoryName && categoryName === topicInfo.categoryName) return;
54 | const params = { _type, _categoryName, page };
55 | Router.push(`/?type=${_type}&categoryName=${_categoryName}&page=${page}`);
56 | dispatch(fetchTopiclList(params));
57 | }
58 |
59 |
60 |
61 | return (
62 |
63 |
64 |
65 |
66 |
67 |
68 | 首页
69 |
70 |
71 |
72 | {
73 | breadCrumbList.map((e, i) => (
74 |
76 | {e}
77 |
78 | ))
79 |
80 | }
81 |
82 |
83 |
84 | {channelList.map((e: any) => {
85 | return (
86 |
87 | );
88 | })}
89 |
90 | {topicInfo.list.map((e, i) => {
91 | return
;
92 | })}
93 |
94 |
95 |
96 | {userInfo.userName ?
97 |
102 |
103 |
104 | {userInfo.userName}
105 |
106 |
107 |
108 |
109 |
110 |
111 |
: ' '}
112 |
113 |
114 |
118 |
119 | {hotTopicList.map(item => (
120 | -
121 |
122 | {item.topicTitle}
123 |
124 |
125 |
126 | ))}
127 |
128 |
129 |
广告位招租
130 |
131 |
132 |
133 |
134 | {topicInfo.total > 10 ? (
135 |
handleGetTopicList({ page: e })}
139 | showQuickJumper
140 | />
141 | ) : (
142 |
143 | )}
144 |
145 |
146 | );
147 | }
148 |
149 | Home.getInitialProps = ({ store, query }: NextJSContext) => {
150 | // const { store, query, isServer } = ctx;
151 | // if (isServer) {
152 | store.dispatch(fetchTopiclList(query));
153 | return {
154 | breadCrumbList: Object.keys(query).filter(e => !!query[e] && e !== 'page').map(e => {
155 | return query[e];
156 | }),
157 | userInfo: {}
158 | };
159 | }
160 |
161 | const mapStateToProps = (state: AppStateType) => ({
162 | topicInfo: state.topic,
163 | channelList: state.channel.list,
164 | userInfo: state.user,
165 | });
166 |
167 |
168 | export default connect(mapStateToProps)(Home);
169 |
--------------------------------------------------------------------------------
/pages/topicDetail.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { connect } from 'react-redux';
3 | import { useRouter } from 'next/router';
4 | import Link from 'next/link';
5 | import { message, Button, Breadcrumb, Popover } from 'antd';
6 | import { UpOutlined, DownOutlined, StarOutlined, StarFilled, ShareAltOutlined } from '@ant-design/icons';
7 | import { NextJSContext } from 'next-redux-wrapper';
8 | import {
9 | FacebookShareButton,
10 | FacebookIcon,
11 | EmailShareButton,
12 | EmailIcon,
13 | TwitterShareButton,
14 | TwitterIcon,
15 | WeiboShareButton,
16 | WeiboIcon
17 | } from "react-share";
18 | import { getFavoriteTopic } from '../api'
19 | import { AppStateType } from '../redux/reducers'
20 | import NoAvatar from '../components/NoAvatar';
21 | import CommentList from '../components/CommentList';
22 | import { fetchTopicList, fetchPraiseInfo, actionPraise, actionFavoriteTopic } from '../api';
23 | import timer from '../utils/timer';
24 | import Head from 'next/head';
25 | import { User, Topic } from '../@types'
26 |
27 |
28 | interface TopicDetailProps {
29 | topicInfo: Topic,
30 | userInfo: User
31 | }
32 |
33 | const TopicDetail = (props: TopicDetailProps) => {
34 | const { topicInfo, userInfo } = props;
35 | const router = useRouter()
36 | const [praiseNum, setPraiseNum] = useState(0)
37 | const [isFavorite, setIsFavorite] = useState(false)
38 |
39 | useEffect(() => {
40 | handleGetPraiseInfo();
41 | handleGetFavoriteInfo()
42 | }, [])
43 |
44 | const getUserName = () => {
45 | return userInfo.userName || window.localStorage.getItem('userName')
46 | }
47 |
48 | const handleGetPraiseInfo = async () => {
49 | const { data } = await fetchPraiseInfo({
50 | topicId: topicInfo._id,
51 | });
52 | setPraiseNum(data)
53 | };
54 |
55 | const handleGetFavoriteInfo = async () => {
56 | const { data } = await getFavoriteTopic({
57 | userName: getUserName()
58 | })
59 | if (!data.list.length) {
60 | setIsFavorite(false)
61 | }
62 | data.list.forEach(element => {
63 | if (element._id === topicInfo._id) {
64 | setIsFavorite(true)
65 | }
66 | });
67 |
68 |
69 | }
70 |
71 | const handleControlPraise = async type => {
72 | if (!window.localStorage.getItem('userName')) {
73 | router.push('/login');
74 | return;
75 | }
76 | const data = await actionPraise({
77 | type: type,
78 | topicId: topicInfo._id,
79 | userName: getUserName()
80 | });
81 | if (data.success) {
82 | message.success(data.message);
83 | handleGetPraiseInfo();
84 | }
85 | };
86 |
87 | const handleCellectTopic = async () => {
88 | if (!window.localStorage.getItem('userName')) {
89 | router.push('/login');
90 | return;
91 | }
92 | const data = await actionFavoriteTopic({
93 | userName: getUserName(),
94 | topicId: topicInfo._id,
95 | type: isFavorite ? 'isCancel' : 'isFavorite'
96 | });
97 | if (data.success) {
98 | message.success(data.message);
99 | handleGetFavoriteInfo()
100 | }
101 | }
102 |
103 |
104 | const renderShareContent = () => {
105 | const url = 'http://www.baidu.com'
106 | const title = '111'
107 | console.log('[router]',router)
108 | return (
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 | )
124 | }
125 |
126 | return (
127 | <>
128 |
129 | {topicInfo.topicTitle}
130 |
131 |
132 |
133 |
134 |
135 | 首页
136 |
137 |
138 |
139 |
142 | {topicInfo.topicTitle}
143 |
144 |
145 |
146 |
{topicInfo.topicTitle}
147 |
148 |
149 |
153 |
154 |
155 |
156 | {topicInfo.userName} {timer(Date.parse(topicInfo.updateTime))}
157 |
158 |
162 |
163 |
164 |
{ handleControlPraise('up'); }}>
165 |
166 |
167 |
168 | {praiseNum}
169 |
170 |
{ handleControlPraise('down') }}>
171 |
172 |
173 |
174 |
175 |
176 |
177 |
:
} />
178 | {/*
} /> */}
179 |
180 | } />
181 |
182 |
183 |
184 |
185 |
189 |
190 | >
191 | );
192 | }
193 |
194 | TopicDetail.getInitialProps = async ({ query }: NextJSContext) => {
195 | const _topic = await fetchTopicList({
196 | _id: query.id,
197 | });
198 | return { topicInfo: _topic.data.list[0] };
199 | }
200 |
201 | const mapStateToProps = (state: AppStateType) => ({
202 | userInfo: state.user,
203 | });
204 |
205 | export default connect(mapStateToProps)(TopicDetail);
206 |
--------------------------------------------------------------------------------