diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml new file mode 100644 index 0000000..ef9a6ef --- /dev/null +++ b/.github/workflows/nodejs.yml @@ -0,0 +1,46 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: Node.js CI + +on: + push: + branches: + - main + - master + pull_request: + branches: + - main + - master + schedule: + - cron: '0 2 * * *' + +jobs: + build: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + node-version: [16.x] + os: [ubuntu-latest] + + steps: + - name: Checkout Git Source + uses: actions/checkout@v2 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - name: Install Dependencies + run: npm i -g npminstall && npminstall + + - name: Continuous Integration + run: npm run ci + + - name: Code Coverage + uses: codecov/codecov-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2998a29 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Actions Release + +on: + push: + branches: [ master ] + +jobs: + build: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + node-version: [16.x] + os: [ubuntu-latest] + + steps: + - name: Checkout Git Source + uses: actions/checkout@v2 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - name: Install Dependencies + run: npm i -g npminstall && npminstall + + - name: Continuous integration + run: npm run ci + + - name: Semantic Release + run: npm run build && npm run build:zip && npm run semantic-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index bee1cf6..ebeec23 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ # production /dist +dist.zip # misc .DS_Store diff --git a/.prettierignore b/.prettierignore index 0d4222f..35cf785 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,6 +3,9 @@ **/*.ejs **/*.html package.json + .umi .umi-production .umi-test + +dist diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..95a7894 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 CNodejs.org + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2950304..140a5f9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# react-cnode +# React-CNode.js -CNode 社区 React 版本 +> Frontend Powered By React For CNode.js -## Getting Started +## Development Install dependencies, @@ -13,5 +13,9 @@ $ yarn Start the dev server, ```bash -$ yarn start +$ yarn dev ``` + +## Contributors + +[![contributors](https://ergatejs.implements.io/badges/contributors/cnodejs/react-cnode.svg?owner=cnodejs&repo=react-cnode&type=svg&width=1232&size=64&padding=8)](https://github.com/cnodejs/react-cnode/graphs/contributors) diff --git a/config/basic.ts b/config/basic.ts index 763c7bf..a770d9d 100644 --- a/config/basic.ts +++ b/config/basic.ts @@ -2,4 +2,6 @@ export default { logo: '/images/cnodejs.svg', title: 'CNode.js', description: 'Node.js 专业中文社区', + concept: + 'CNode 社区为国内最专业的 Node.js 开源技术社区,致力于 Node.js 的技术研究。', }; diff --git a/config/config.ts b/config/config.ts index 77ed2d8..d3c3211 100644 --- a/config/config.ts +++ b/config/config.ts @@ -3,9 +3,81 @@ import routes from './routes'; import { defineConfig } from 'umi'; export default defineConfig({ + // cnodejs.org + favicon: '/images/favicon.ico', + metas: [ + { + name: 'keywords', + content: 'nodejs, node, express, connect, socket.io', + }, + { + name: 'referrer', + content: 'always', + }, + { + name: 'author', + content: 'EDP@TaoBao', + }, + { + name: 'wb:webmaster', + content: '617be6bd946c6b96', + }, + { + name: 'wb:webmaster', + content: '617be6bd946c6b96', + }, + ], + links: [ + { + type: 'image/x-icon', + rel: 'icon', + href: '//static2.cnodejs.org/public/images/cnode_icon_32.png', + }, + { + title: 'RSS', + type: 'application/rss+xml', + rel: 'alternate', + href: 'https://cnodejs.org/rss', + }, + ], + + analytics: { + ga: 'UA-41753901-5', + }, + + // umi.js singular: true, + fastRefresh: {}, - // mfsu: {}, + + mfsu: {}, + + externals: { + react: 'window.React', + 'react-dom': 'ReactDOM', + antd: 'antd', + dayjs: 'dayjs', + }, + + styles: + process.env.NODE_ENV === 'development' + ? ['//unpkg.com/antd@4.x/dist/antd.css'] + : ['//unpkg.com/antd@4.x/dist/antd.min.css'], + + scripts: + process.env.NODE_ENV === 'development' + ? [ + '//unpkg.com/react@17.x/umd/react.development.js', + '//unpkg.com/react-dom@17.x/umd/react-dom.development.js', + '//unpkg.com/antd@4.x/dist/antd.js', + '//unpkg.com/dayjs@1.x/dayjs.min.js', + ] + : [ + '//unpkg.com/react@17.x/umd/react.production.min.js', + '//unpkg.com/react-dom@17.x/umd/react-dom.production.min.js', + '//unpkg.com/antd@4.x/dist/antd.min.js', + '//unpkg.com/dayjs@1.x/dayjs.min.js', + ], nodeModulesTransform: { type: 'none', diff --git a/config/routes.ts b/config/routes.ts index a8ce1fb..e885962 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -17,9 +17,32 @@ const routes: IRoute[] = [ exact: true, icon: 'home', name: '主页', + description: + 'CNode 社区为国内最专业的 Node.js 开源技术社区,致力于 Node.js 的技术研究。', component: '@/page/topic', }, - + { + path: '/my/messages', + exact: true, + icon: 'message', + title: '未读消息', + access: 'canReadMessage', + component: '@/page/message', + }, + { + path: '/about', + exact: true, + icon: 'info', + name: '关于我们', + component: '@/page/about', + }, + { + path: '/links', + exact: true, + icon: 'link', + name: '友情链接', + component: '@/page/links', + }, { path: '/api', exact: true, @@ -27,11 +50,28 @@ const routes: IRoute[] = [ name: 'API', component: '@/page/api', }, + { + path: '/topic/create', + exact: true, + title: '新建话题', + component: '@/page/topic/create', + access: 'canPostTopic', + }, { path: '/topic/:id', exact: true, component: '@/page/topic/detail', }, + { + path: '/topic/:id/edit', + exact: true, + component: '@/page/topic/create', + }, + { + path: '/user/:loginname', + exact: true, + component: '@/page/user/', + }, ], }, diff --git a/package.json b/package.json index 20d79f7..7639003 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,19 @@ { - "private": true, + "name": "react-cnode", + "description": "Frontend Powered By React For CNode.js", + "version": "development", + "private": false, + "license": "MIT", "scripts": { "dev": "umi dev", "build": "umi build", + "build:zip": "node ./scripts/zip.js", + "ci": "npm run test", "postinstall": "umi generate tmp", "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'", "test": "umi-test", - "test:coverage": "umi-test --coverage" + "test:coverage": "umi-test --coverage", + "semantic-release": "semantic-release" }, "gitHooks": { "pre-commit": "lint-staged" @@ -19,6 +26,37 @@ "prettier --parser=typescript --write" ] }, + "ci": { + "type": "github", + "os": { + "github": "linux" + }, + "version": "16.x" + }, + "release": { + "branche": "master", + "tagFormat": "${version}", + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + [ + "@semantic-release/changelog", + { + "changelogFile": "History.md" + } + ], + [ + "@semantic-release/github", + { + "assets": { + "path": "dist.zip", + "label": "Assets Distribution" + }, + "addReleases": "bottom" + } + ] + ] + }, "dependencies": { "dotenv": "^10.0.0", "react": "17.x", @@ -30,7 +68,9 @@ "@ant-design/pro-layout": "^6.32.1", "@ant-design/pro-list": "^1.21.12", "@ant-design/pro-table": "^2.61.9", + "@semantic-release/changelog": "^6.0.1", "@types/dotenv": "^8.2.0", + "@types/jest": "^27.4.0", "@types/markdown-it": "^12.2.3", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", @@ -39,13 +79,16 @@ "@umijs/preset-react": "1.x", "@umijs/test": "^3.5.20", "ahooks": "^3.1.3", + "compressing": "^1.5.1", "dayjs": "^1.10.7", + "egg-ci": "^1.19.0", "lint-staged": "^10.0.7", "markdown-it": "^12.3.0", "prettier": "^2.2.0", "react-markdown-editor-lite": "^1.3.2", + "semantic-release": "^18.0.1", "typescript": "^4.1.2", "umi": "^3.5.20", "yorkie": "^2.0.0" } -} \ No newline at end of file +} diff --git a/public/images/cnode_icon_32.png b/public/images/cnode_icon_32.png new file mode 100644 index 0000000..9e69485 Binary files /dev/null and b/public/images/cnode_icon_32.png differ diff --git a/public/images/cnode_icon_64.png b/public/images/cnode_icon_64.png new file mode 100644 index 0000000..dbef980 Binary files /dev/null and b/public/images/cnode_icon_64.png differ diff --git a/scripts/zip.js b/scripts/zip.js new file mode 100644 index 0000000..568fb2b --- /dev/null +++ b/scripts/zip.js @@ -0,0 +1,16 @@ +const path = require('path'); +const compressing = require('compressing'); + +const source = path.resolve(__dirname, '../dist'); +const target = path.resolve(__dirname, '../dist.zip'); + +const run = async () => { + try { + await compressing.zip.compressDir(source, target); + console.log('compressing:zip:done'); + } catch (error) { + console.log(error); + } +}; + +run(); diff --git a/src/access.ts b/src/access.ts index 8233b3c..9b2e8c8 100644 --- a/src/access.ts +++ b/src/access.ts @@ -2,6 +2,8 @@ export default function (initialState: InitialState) { const { token } = initialState; return { + canPostTopic: !!token, canPostComment: !!token, + canReadMessage: !!token, }; } diff --git a/src/app.tsx b/src/app.tsx index 1cd9b31..bf1ff5e 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -71,7 +71,7 @@ export const patchRoutes = ({ routes }: { routes: Array }) => { }; export const request: RequestConfig = { - timeout: 1000, + timeout: 6 * 1000, errorConfig: {}, middlewares: [], requestInterceptors: [], diff --git a/src/component/Brand/index.tsx b/src/component/Brand/index.tsx index 0f4409e..f0e2723 100644 --- a/src/component/Brand/index.tsx +++ b/src/component/Brand/index.tsx @@ -1,18 +1,25 @@ -import React from 'react'; +import React, { useContext } from 'react'; +import { RouteContext } from '@ant-design/pro-layout'; + import * as styles from './index.less'; -const Brand: React.FC = ({ logo, title, description }) => ( -
- logo - {/*

{title}

*/} -

{description}

-
-); +const Brand: React.FC = ({ logo, title, description }) => { + const { collapsed, isMobile } = useContext(RouteContext); + + return ( +
+ logo + {collapsed || isMobile ? null : ( +

{description}

+ )} +
+ ); +}; export default Brand; interface Props { - title: string; - description: string; logo?: string; + title?: string; + description: string; } diff --git a/src/page/topic/component/CommentForm.tsx b/src/component/CommentForm/index.tsx similarity index 97% rename from src/page/topic/component/CommentForm.tsx rename to src/component/CommentForm/index.tsx index e32a4e4..13853be 100644 --- a/src/page/topic/component/CommentForm.tsx +++ b/src/component/CommentForm/index.tsx @@ -28,6 +28,7 @@ const CommentForm: React.FC = (props) => { } await onSubmit(value); + setValue(''); }} > {onSubmitText} diff --git a/src/page/topic/component/CommentList/index.less b/src/component/CommentList/index.less similarity index 100% rename from src/page/topic/component/CommentList/index.less rename to src/component/CommentList/index.less diff --git a/src/page/topic/component/CommentList/index.tsx b/src/component/CommentList/index.tsx similarity index 72% rename from src/page/topic/component/CommentList/index.tsx rename to src/component/CommentList/index.tsx index d4b022a..f1fc184 100644 --- a/src/page/topic/component/CommentList/index.tsx +++ b/src/component/CommentList/index.tsx @@ -1,5 +1,6 @@ import React, { Fragment } from 'react'; import dayjs from 'dayjs'; +import { Link } from 'umi'; import Markdown from '@/component/Markdown'; import { Comment, Avatar, Divider } from 'antd'; @@ -31,13 +32,14 @@ const unflatten = (array: Node[], parent?: Node, tree?: Node[]) => { }; const CommentList: React.FC = (props) => { - const { list, onReply, replyRender } = props; + const { list, onLike, onReply, replyRender } = props; const tree = unflatten(list); - const ComentDetail: React.FC<{ + const CommentDetail: React.FC<{ data: Node; }> = ({ data }) => { const { id, author, content, create_at, children } = data; + const { loginname, avatar_url } = author; return ( @@ -46,12 +48,16 @@ const CommentList: React.FC = (props) => { , + { + onLike && onLike(data); + }} + />, , , { - onReply(data); + onReply && onReply(data); }} />, ]} @@ -59,7 +65,11 @@ const CommentList: React.FC = (props) => { datetime={ {dayjs(create_at).format('YYYY-MM-DD hh:mm:ss')} } - avatar={} + avatar={ + + + + } content={
@@ -69,7 +79,7 @@ const CommentList: React.FC = (props) => { {replyRender(id)} {children?.map((item) => ( - + ))} @@ -79,7 +89,7 @@ const CommentList: React.FC = (props) => { return (
{tree.map((item) => ( - + ))}
); @@ -90,7 +100,11 @@ export default CommentList; interface Props { list: ReplyModel[]; - onReply: (record: Node) => void; + onLike?: (record: Node) => void; + onEdit?: (record: Node) => void; + onReply?: (record: Node) => void; + onDelete?: (record: Node) => void; + replyRender: (id: string) => React.ReactNode; } diff --git a/src/component/Markdown/index.tsx b/src/component/Markdown/index.tsx index 98467f9..fec2caa 100644 --- a/src/component/Markdown/index.tsx +++ b/src/component/Markdown/index.tsx @@ -8,7 +8,7 @@ import * as styles from './index.less'; const mdParser = new MarkdownIt(); const Markdown: React.FC = (props) => { - const { value, type, onChange } = props; + const { value = '', type, onChange, customClassName = '' } = props; let view; let classname = styles.markdown; @@ -33,6 +33,10 @@ const Markdown: React.FC = (props) => { classname += ` ${styles.markdown_editor}`; } + if (customClassName) { + classname += ` ${customClassName}`; + } + return ( void; } diff --git a/src/component/MessageList/index.less b/src/component/MessageList/index.less new file mode 100644 index 0000000..2655d9e --- /dev/null +++ b/src/component/MessageList/index.less @@ -0,0 +1,20 @@ +@import '~antd/dist/antd.less'; + +.list { + :global { + .ant-card { + padding: 0; + > .ant-card-body { + padding: 0; + } + } + } +} + +.link { + color: @text-color; + + &:hover { + color: @primary-color; + } +} diff --git a/src/component/MessageList/index.tsx b/src/component/MessageList/index.tsx new file mode 100644 index 0000000..6847eae --- /dev/null +++ b/src/component/MessageList/index.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import dayjs from 'dayjs'; +import { useHistory, Link } from 'umi'; +import { Space, Avatar, Tag } from 'antd'; +import { ListToolBarProps } from '@ant-design/pro-table'; +import ProList, { ProListMetas } from '@ant-design/pro-list'; + +import { MESSAGE_TYPE_MAP, MessageType } from '@/constants'; + +import * as styles from './index.less'; + +const MessageList: React.FC = ({ + dataSource, + loading, + toolbar, + onClick, +}) => { + const metas: ProListMetas = { + avatar: { + dataIndex: 'author.avatar_url', + render: (_, entity: MessageModel) => { + const { type: _type, author } = entity; + const type = MESSAGE_TYPE_MAP[_type as MessageType]; + const { loginname, avatar_url } = author; + + return ( + +
+ + + + {loginname} + + +
+ + {type.name} +
+ ); + }, + }, + title: { + dataIndex: 'title', + valueType: 'text', + render: (_, entity: MessageModel) => { + const { + id: messageId, + topic: { id, title }, + } = entity; + return ( + { + onClick && onClick(messageId); + }} + > + {title} + + ); + }, + }, + actions: { + render: (_, entity: MessageModel) => { + return dayjs(entity.create_at).fromNow(); + }, + }, + }; + + return ( + + ); +}; + +export default MessageList; + +interface Props { + dataSource?: MessageModel[]; + loading?: boolean; + toolbar?: ListToolBarProps; + onClick?: (id: string) => void; +} diff --git a/src/component/RightContent/index.tsx b/src/component/RightContent/index.tsx new file mode 100644 index 0000000..a0f6fb4 --- /dev/null +++ b/src/component/RightContent/index.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { history, useModel, Link } from 'umi'; +import { Avatar, Space, Button, Badge, Menu, Dropdown } from 'antd'; + +const RightContent: React.FC = (props) => { + const { user, logout } = useModel('user'); + const { count } = useModel('message'); + + if (!user) { + return ( +
+ +
+ ); + } + + const { loginname, avatar_url } = user; + + const menu = ( + + + 个人资料 + + + + 未读消息 + + + + + { + e.preventDefault(); + logout(); + }} + > + 退出登录 + + + + ); + + return ( +
+ + + + + {loginname} + + + +
+ ); +}; + +export default RightContent; + +interface Props {} diff --git a/src/component/TopicList/index.less b/src/component/TopicList/index.less new file mode 100644 index 0000000..2655d9e --- /dev/null +++ b/src/component/TopicList/index.less @@ -0,0 +1,20 @@ +@import '~antd/dist/antd.less'; + +.list { + :global { + .ant-card { + padding: 0; + > .ant-card-body { + padding: 0; + } + } + } +} + +.link { + color: @text-color; + + &:hover { + color: @primary-color; + } +} diff --git a/src/component/TopicList/index.tsx b/src/component/TopicList/index.tsx new file mode 100644 index 0000000..c6c6692 --- /dev/null +++ b/src/component/TopicList/index.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import dayjs from 'dayjs'; +import { Link } from 'umi'; +import { Space, Avatar, Tag } from 'antd'; +import { ListToolBarProps } from '@ant-design/pro-table'; +import ProList, { ProListMetas } from '@ant-design/pro-list'; + +import { TABS_MAP, TabType } from '@/constants'; + +import * as styles from './index.less'; + +const TopicList: React.FC = ({ dataSource, loading, toolbar }) => { + const metas: ProListMetas = { + avatar: { + dataIndex: 'author.avatar_url', + render: (_, entity: TopicModel) => { + const { tab: _tab, author, reply_count, visit_count, top } = entity; + + const category = TABS_MAP[_tab as TabType]; + const { loginname, avatar_url } = author; + + const renderReplyVisit = () => + typeof visit_count === 'number' && ( +
+ + {reply_count} + + /{visit_count} +
+ ); + + return ( + + + + + {renderReplyVisit()} + {top ? ( + 置顶 + ) : ( + category && {category.name} + )} + + ); + }, + }, + title: { + dataIndex: 'title', + valueType: 'text', + render: (_, entity: TopicModel) => { + const { id, title } = entity; + return ( + + {title} + + ); + }, + }, + actions: { + render: (_, entity: TopicModel) => { + const { last_reply_at } = entity; + return dayjs(last_reply_at).fromNow(); + }, + }, + }; + + return ( + + ); +}; + +export default TopicList; + +interface Props { + dataSource?: TopicModel[]; + loading?: boolean; + toolbar?: ListToolBarProps; +} diff --git a/src/constants/index.ts b/src/constants/index.ts index 540faa1..34e8782 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -3,28 +3,41 @@ export const BASE_URL = 'https://cnodejs.org'; export const TABS_MAP = { good: { name: '精华', - color: '#5BD8A6', + color: '#87d068', }, share: { name: '分享', - color: '#5BD8A6', + color: '#2db7f5', }, ask: { name: '问答', - color: '#5BD8A6', + color: '#999', }, job: { name: '招聘', - color: '#5BD8A6', + color: '#108ee9', }, dev: { name: '客户端测试', - color: '#5BD8A6', + color: 'green', }, }; export type TabType = keyof typeof TABS_MAP; +export const MESSAGE_TYPE_MAP = { + at: { + name: '提到了你', + color: '#108ee9', + }, + reply: { + name: '回复了你', + color: 'green', + }, +}; + +export type MessageType = keyof typeof MESSAGE_TYPE_MAP; + export enum FORM_TYPE { LOGIN = 'login', REGISTER = 'register', diff --git a/src/layout.tsx b/src/layout.tsx index 5675bd3..09eccb5 100644 --- a/src/layout.tsx +++ b/src/layout.tsx @@ -10,36 +10,37 @@ import { import config from '../config/basic'; import Brand from './component/Brand'; - -const RightContent: React.FC<{ - user?: UserModel; -}> = (props) => { - const user = props?.user; - - if (!user) { - return ( -
- -
- ); - } - - const { loginname, avatar_url } = user; - return ( -
- - - -
- ); -}; +import RightContent from './component/RightContent'; + +// const RightContent: React.FC<{ +// user?: UserModel; +// }> = (props) => { +// const user = props?.user; + +// if (!user) { +// return ( +//
+// +//
+// ); +// } + +// const { loginname, avatar_url } = user; +// return ( +//
+// +// +// +//
+// ); +// }; const layoutConfig = ({ initialState, @@ -90,7 +91,7 @@ const layoutConfig = ({ item.path && {item.name}, rightContentRender: () => { - return ; + return ; }, footerRender: () => ( @@ -99,6 +100,8 @@ const layoutConfig = ({ copyright={`${new Date().getFullYear()} - CNodejs.org`} /> ), + + onPageChange: () => {}, }; }; diff --git a/src/layout/component/AppQrcode.tsx b/src/layout/component/AppQrcode.tsx new file mode 100644 index 0000000..5b7b93c --- /dev/null +++ b/src/layout/component/AppQrcode.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import ProCard from '@ant-design/pro-card'; + +const AppQrcode: React.FC = (props) => { + return ( + +
+ 客户端二维码 + 客户端源码地址 +
+
+ ); +}; + +export default AppQrcode; + +interface Props {} diff --git a/src/layout/component/UserInfo.tsx b/src/layout/component/UserInfo.tsx index eb2adc3..422926e 100644 --- a/src/layout/component/UserInfo.tsx +++ b/src/layout/component/UserInfo.tsx @@ -42,7 +42,7 @@ const UserInfo: React.FC = (props) => { }; return ( - + {loginname} diff --git a/src/layout/index.tsx b/src/layout/index.tsx index 4a28c4d..7800d89 100644 --- a/src/layout/index.tsx +++ b/src/layout/index.tsx @@ -1,10 +1,11 @@ import React from 'react'; import ProCard from '@ant-design/pro-card'; -import { IRoute, Link } from 'umi'; import { PageContainer } from '@ant-design/pro-layout'; -import { Affix, Button } from 'antd'; +import { BackTop, Space } from 'antd'; +import { IRoute, Link } from 'umi'; +import AppQrcode from './component/AppQrcode'; import UserInfo from './component/UserInfo'; const getCurrentRoute = (route: IRoute, path: string): IRoute | undefined => { @@ -28,16 +29,50 @@ const getCurrentRoute = (route: IRoute, path: string): IRoute | undefined => { return target; }; +const BREADCRUMB_NAME_MAP = { + user: '用户', + topic: '话题', +}; + const Layout: React.FC> = (props) => { const { route, location } = props; const currentRoute = getCurrentRoute(route, location.pathname); let headerConfig: any = { title: currentRoute?.title || currentRoute?.name, + subTitle: currentRoute?.description, }; - if (location.pathname.startsWith('/topic/')) { - const topicBreadcrumbName = location.pathname.split('/').pop(); + const detailPaths = location.pathname.match(/\/(topic|user)\/(\w+)(\/\w+)?/); + + if (detailPaths) { + const [, category, id, status] = detailPaths; + + const isEdit = status === '/edit'; + + const routes = [ + { + path: '/', + breadcrumbName: '主页', + }, + { + path: '/', + breadcrumbName: BREADCRUMB_NAME_MAP[category as 'user' | 'topic'], + }, + { + path: isEdit + ? location.pathname.replace(status, '') + : location.pathname, + breadcrumbName: id, + }, + ]; + + if (isEdit) { + routes.push({ + path: location.pathname, + breadcrumbName: '编辑', + }); + } headerConfig = { title: null, @@ -45,16 +80,7 @@ const Layout: React.FC> = (props) => { itemRender: (route: { path: string; breadcrumbName: string }) => { return {route.breadcrumbName}; }, - routes: [ - { - path: '/', - breadcrumbName: '主页', - }, - { - path: location.pathname, - breadcrumbName: topicBreadcrumbName, - }, - ], + routes, }, }; } @@ -68,32 +94,18 @@ const Layout: React.FC> = (props) => { bordered={false} ghost colSpan={{ - xs: '50px', - sm: '100px', - md: '200px', - lg: '300px', - xl: '400px', + sm: '200px', + md: '320px', }} > - + + + + - - - + + ); }; diff --git a/src/model/message.ts b/src/model/message.ts new file mode 100644 index 0000000..888a641 --- /dev/null +++ b/src/model/message.ts @@ -0,0 +1,78 @@ +import { useState, useCallback, useEffect } from 'react'; +import { loadInitialState } from '@/util'; +import * as API from '@/service/message'; + +export default () => { + const initialState = loadInitialState(); + const { token } = initialState; + + const [count, setCount] = useState(0); + const [message, setMessage] = useState>(); + const [unreadMessage, setUnreadMessage] = useState>(); + + useEffect(() => { + if (!token) { + return; + } + + load(); + fetch(); + }, [token]); + + const load = useCallback(async () => { + if (!token) { + return; + } + + const { data } = await API.getMessageCount({ + accesstoken: token, + }); + + setCount(data); + }, [token]); + + const fetch = useCallback(async () => { + if (!token) { + return; + } + + const { data } = await API.getMessages({ + accesstoken: token, + mdrender: false, + }); + + setMessage(data.has_read_messages); + setUnreadMessage(data.hasnot_read_messages); + }, [token]); + + const mark = useCallback( + async (id: string) => { + if (!token) { + return; + } + + await API.markMessage(id, { + accesstoken: token, + }); + + load(); + fetch(); + }, + [token], + ); + + const markAll = useCallback(async () => { + if (!token) { + return; + } + + await API.markAllMessage({ + accesstoken: token, + }); + + load(); + fetch(); + }, [token]); + + return { count, message, unreadMessage, load, fetch, mark, markAll }; +}; diff --git a/src/page/about/index.tsx b/src/page/about/index.tsx new file mode 100644 index 0000000..f4a76a9 --- /dev/null +++ b/src/page/about/index.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import Markdown from '@/component/Markdown'; + +const content = ` +## 关于 + +CNode 社区为国内最大最具影响力的 Node.js 开源技术社区,致力于 Node.js 的技术研究。 + +CNode 社区由一批热爱 Node.js 技术的工程师发起,目前已经吸引了互联网各个公司的专业技术人员加入,我们非常欢迎更多对 Node.js 感兴趣的朋友。 + +CNode 的 SLA 保证是,一个9,即 90.000000%。 + +社区目前由 [@alsotang](http://cnodejs.org/user/alsotang) 在维护,有问题请联系:[https://github.com/alsotang](https://github.com/alsotang) + +请关注我们的官方微博:[http://weibo.com/cnodejs](http://weibo.com/cnodejs) + + +## 客户端 + +客户端由 [@soliury](https://cnodejs.org/user/soliury) 开发维护。 + +源码地址: [https://github.com/soliury/noder-react-native](https://github.com/soliury/noder-react-native) 。 + +立即体验 CNode 客户端,直接扫描页面右侧二维码。 + +另,安卓用户同时可选择:[https://github.com/TakWolf/CNode-Material-Design](https://github.com/TakWolf/CNode-Material-Design) ,这是 Java 原生开发的安卓客户端。 + + +## 贡献者 + +> egg-cnode + +[![contributors](https://ergatejs.implements.io/badges/contributors/cnodejs/egg-cnode.svg?owner=cnodejs&repo=egg-cnode&type=svg&width=1232&size=64&padding=8)](https://github.com/cnodejs/egg-cnode/graphs/contributors) +`; + +const AboutPage: React.FC = (props) => { + return ; +}; + +export default AboutPage; + +interface Props {} diff --git a/src/page/api.tsx b/src/page/api.tsx deleted file mode 100644 index 9108333..0000000 --- a/src/page/api.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -const API: React.FC = (props) => { - return
api
; -}; - -export default API; - -interface Props {} diff --git a/src/page/api/index.tsx b/src/page/api/index.tsx new file mode 100644 index 0000000..64527a0 --- /dev/null +++ b/src/page/api/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +const ApiPage: React.FC = (props) => { + return
TODO.
; +}; + +export default ApiPage; + +interface Props {} diff --git a/src/page/home/index.tsx b/src/page/home/index.tsx index dd3a305..20ce6b7 100644 --- a/src/page/home/index.tsx +++ b/src/page/home/index.tsx @@ -1,9 +1,9 @@ import React from 'react'; -const Home: React.FC = () => { +const HomePage: React.FC = () => { return null; }; -export default Home; +export default HomePage; interface Props {} diff --git a/src/page/links/index.tsx b/src/page/links/index.tsx new file mode 100644 index 0000000..cd57bbb --- /dev/null +++ b/src/page/links/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +const LinksPage: React.FC = (props) => { + return
TODO.
; +}; + +export default LinksPage; + +interface Props {} diff --git a/src/page/message/index.tsx b/src/page/message/index.tsx new file mode 100644 index 0000000..ae36802 --- /dev/null +++ b/src/page/message/index.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { useModel } from 'umi'; +import { Divider, Button } from 'antd'; +import ProCard from '@ant-design/pro-card'; +import MessageList from '@/component/MessageList'; + +const MessagePage: React.FC = (props) => { + const { message, unreadMessage, mark, markAll } = useModel('message'); + + console.debug('===message', message); + console.debug('===unreadMessage', unreadMessage); + + const renderUnreadMessage = () => { + if (unreadMessage?.length === 0) { + return 暂无新消息; + } + + return ( + mark(id)} /> + ); + }; + + return ( +
+ { + markAll(); + }} + > + 标记全部 + + } + > + {renderUnreadMessage()} + + + + + +
+ ); +}; + +export default MessagePage; + +interface Props {} diff --git a/src/page/topic/component/SubTitle.tsx b/src/page/topic/component/SubTitle.tsx index 2a4180d..88caa0f 100644 --- a/src/page/topic/component/SubTitle.tsx +++ b/src/page/topic/component/SubTitle.tsx @@ -1,16 +1,32 @@ import React from 'react'; import dayjs from 'dayjs'; -import { Avatar, Divider, Space } from 'antd'; +import { Avatar, Divider, Space, Button } from 'antd'; +import { Link, useModel } from 'umi'; +import { FormOutlined } from '@ant-design/icons'; const SubTitle: React.FC = (props) => { - const { author, create_at, visit_count, reply_count } = props; + const { author, create_at, visit_count, reply_count, author_id } = props; + + const { user } = useModel('user'); + + const renderEdit = () => + user?.id === author_id && ( + + + + ); + return ( }> - + + + 发布:{dayjs(create_at).format('YYYY-MM-DD hh:mm:ss')} 浏览:{visit_count} 回复:{reply_count} + + {renderEdit()} ); }; @@ -26,4 +42,6 @@ interface Props { loginname: string; avatar_url: string; }; + + author_id: string; } diff --git a/src/page/topic/create/index.less b/src/page/topic/create/index.less new file mode 100644 index 0000000..244f6d7 --- /dev/null +++ b/src/page/topic/create/index.less @@ -0,0 +1,3 @@ +.editor_create { + min-height: 600px; +} diff --git a/src/page/topic/create/index.tsx b/src/page/topic/create/index.tsx new file mode 100644 index 0000000..1f38f54 --- /dev/null +++ b/src/page/topic/create/index.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { useModel, useHistory, useParams } from 'umi'; +import { Form, Input, Select, Button, Space } from 'antd'; +import { TABS_MAP } from '@/constants'; + +import Markdown from '@/component/Markdown'; + +import * as API from '@/service/topic'; +import * as styles from './index.less'; +import { useRequest } from 'ahooks'; + +const TopicEditPage: React.FC = (props) => { + const history = useHistory(); + const [form] = Form.useForm(); + const { initialState } = useModel('@@initialState'); + const { user } = useModel('user'); + + const token = initialState?.token; + + const { id } = useParams<{ id?: string }>(); + + useRequest( + async () => { + if (!id) return; + const { data } = await API.readTopic({ + id, + mdrender: false, + }); + + if (data.author_id !== user?.id) { + history.push(location.pathname.replace(/\/edit$/, '')); + return; + } + + form.setFieldsValue({ + title: data.title, + content: data.content, + tab: data.tab, + }); + }, + { + ready: !!id, + }, + ); + + const onFinish = async (values: any) => { + console.debug('===create.values', values); + + if (!token) { + return; + } + + if (id) { + await API.updateTopic({ + topic_id: id, + ...values, + accesstoken: token, + }); + } else { + await API.createTopic({ + ...values, + accesstoken: token, + }); + } + + onReset(); + + history.push('/'); + }; + + const onReset = () => { + form.resetFields(); + }; + + const tabs = Object.entries(TABS_MAP).map(([value, info]) => { + return { + label: info.name, + value, + }; + }); + + return ( +
+
+ + + + +