diff --git a/.babelrc b/.babelrc index 90541c3..8f4cafb 100644 --- a/.babelrc +++ b/.babelrc @@ -12,18 +12,7 @@ ["transform-imports", { "@material-ui/core": { "transform": "@material-ui/core/${member}" }, "lodash-es": { "transform": "lodash-es/${member}" } - }] - ], - "env": { - "development": { - "plugins": [ - ["emotion", { "sourceMap": true, "autoLabel": true }] - ] - }, - "production": { - "plugins": [ - ["emotion", { "hoist": true }] - ] - } - } + }], + ["babel-plugin-styled-components", { "ssr": false }] + ] } diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b911e00 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ + +language: node_js + +node_js: + - "10" + +install: + - npm install + +script: + - npm run type-check + - npm run build diff --git a/README.md b/README.md index 158c937..8e598dc 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ CC98 Forum PWA version. ## 项目技术栈 -- react@latest +- react@16.7.0-alpha.2 - @reach/router - ​@material-ui/core@3 -- emotion +- styled-component@4 - webpack@4 diff --git a/config/webpack.common.js b/config/webpack.common.js index 753268b..cf44cd5 100644 --- a/config/webpack.common.js +++ b/config/webpack.common.js @@ -59,6 +59,13 @@ module.exports = { outputPath: 'static/', }, }, + { + test: /\.(ttf|eot|woff|woff2)$/, + loader: 'file-loader', + options: { + name: 'fonts/[name].[ext]', + }, + }, ] }, @@ -79,9 +86,14 @@ module.exports = { inject: true, }), - new CopyWebpackPlugin([ - { from: 'public/manifest.json', to: 'manifest.json' }, - { from: 'public/icons/', to: 'icons/' }, + new CopyWebpackPlugin([{ + from: 'public/manifest.json', + to: 'manifest.json' + }, + { + from: 'public/icons/', + to: 'icons/' + }, ]), // workbox: https://developers.google.com/web/tools/workbox/modules/workbox-webpack-plugin @@ -92,4 +104,3 @@ module.exports = { }), ], } - diff --git a/docs/error.md b/docs/error.md new file mode 100644 index 0000000..d6edd7e --- /dev/null +++ b/docs/error.md @@ -0,0 +1,69 @@ +# 记录页面的报错处理情况 + +## 看帖页面(/topic) +对index发出的第一个请求,即请求topicinfo进行处理 + +**navigate** +* 401 -用户未登录、用户权限不够、用户使用代理查看某些帖子 +* 403 - +* 404 -用户输错路由 +* 50* -服务器内部错误 + +评分操作进行处理(通过message) + +**notification** +* 'cannot_rate_yourself' - '您不能给自己评分' +* 'has_rated_tody' -'您今天已经评过分了,请明天再来' +* 'you_cannot_rate' -'您发帖还不足500,不能评分' +* 'has_rated_this_post' -'您已经给这个贴评过分了' +* 'post_user_not_exists' -'这个回复的账号已经不存在了' + +回帖处理 + +TODO + +编辑处理 + +TODO + +## 版面页面(/board) + +**navigate** + +对index发出的第一个请求,即请求boardInfo进行处理 +* 401 -用户未登录、用户权限不够、用户使用代理查看某些帖子 +* 403 - +* 404 -用户输错路由 +* 50* -服务器内部错误 + +## 版面列表 (/boardList) + +**navigate** + +* 50* -服务器内部错误 + +## 主页 (/) + +**notification** + +* 50* -服务器内部错误 + +## 热门(/hottopic) +**notification** + +* 50* -服务器内部错误 + +## 登陆 (/login) + +**notification** + +* 401 -用户名密码错误 +* 50* -服务器内部错误 + +## 关注 (/myfollow)新帖 (/newTopics)搜索(/search) +对获取关注信息的api进行处理 + +**navigate** + +* 401 -用户未登录 +* 50* -服务器内部错误 diff --git a/package.json b/package.json index 256d93b..3e8c86d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc98-pwa", - "version": "0.98.0-alpha", + "version": "1.0.0", "description": "CC98 Forum PWA version.", "author": "Hydrogen", "license": "MIT", @@ -8,69 +8,73 @@ "dev": "cross-env NODE_ENV=development webpack-dev-server --config config/webpack.dev.js --progress --hot", "build": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js", "bundle-analyze": "cross-env NODE_ENV=production webpack --config config/webpack.bundle-analyze.js", + "type-check": "tsc --noEmit", "beautify-all": "yarn run prettier --config .prettierrc --write 'src/**/*.{ts,tsx}'", "lint:ts": "tslint -c tslint.json --project tsconfig.json 'src/**/*.{ts,tsx}'", "lint:ts-fix": "tslint -c tslint.json --project tsconfig.json 'src/**/*.{ts,tsx}' --fix" }, "dependencies": { - "@aspnet/signalr": "^1.0.4", - "@cc98/state": "^1.0.0", - "@cc98/ubb-react": "^1.0.0", - "@material-ui/core": "^3.4.0", + "@aspnet/signalr": "^1.1.0", + "@cc98/ubb-core": "1.1.0", + "@material-ui/core": "^3.7.0", "@material-ui/icons": "^3.0.1", "@reach/router": "^1.2.1", - "dayjs": "^1.6.10", - "emotion": "^9.2.12", + "@sentry/browser": "^4.4.2", + "copy-to-clipboard": "^3.0.8", + "dayjs": "^1.7.8", "lodash-es": "^4.17.11", "prop-types": "^15.6.2", - "react": "16.7.0-alpha.0", - "react-dom": "16.7.0-alpha.0", - "react-emotion": "^9.2.12", - "remark": "^10.0.0", - "remark-react": "^4.0.3" + "react": "^16.7.0-alpha.2", + "react-dom": "16.7.0-alpha.2", + "remark": "^10.0.1", + "remark-react": "^5.0.1", + "styled-components": "^4.1.3", + "typeface-roboto": "^0.0.54", + "url-parse": "^1.4.4" }, "devDependencies": { - "@babel/core": "^7.1.5", - "@babel/plugin-proposal-class-properties": "^7.1.0", - "@babel/preset-env": "^7.1.5", + "@babel/core": "^7.2.2", + "@babel/plugin-proposal-class-properties": "^7.2.1", + "@babel/preset-env": "^7.2.0", "@babel/preset-react": "^7.0.0", "@babel/preset-typescript": "^7.1.0", "@types/lodash-es": "^4.17.1", "@types/node": "^10.12.3", - "@types/reach__router": "^1.2.0", - "@types/react": "16.4.18", - "@types/react-dom": "^16.0.9", + "@types/reach__router": "^1.2.2", + "@types/react": "^16.7.17", + "@types/react-dom": "^16.0.11", + "@types/styled-components": "4.0.3", + "@types/url-parse": "^1.4.1", "babel-loader": "^8.0.4", - "babel-plugin-emotion": "^9.2.11", + "babel-plugin-styled-components": "^1.9.4", "babel-plugin-transform-imports": "^1.5.1", - "clean-webpack-plugin": "^0.1.19", - "copy-webpack-plugin": "^4.5.3", + "clean-webpack-plugin": "^1.0.0", + "copy-webpack-plugin": "^4.6.0", "cross-env": "^5.2.0", - "css-loader": "^1.0.0", - "eslint-plugin-emotion": "^9.2.6", + "css-loader": "^2.0.0", "file-loader": "^2.0.0", - "html-webpack-plugin": "^4.0.0-beta.2", - "husky": "^1.1.2", - "lint-staged": "^7.3.0", - "prettier": "^1.15.1", - "prettier-tslint": "^0.4.0", + "html-webpack-plugin": "^4.0.0-beta.5", + "husky": "^1.2.1", + "lint-staged": "^8.1.0", + "prettier": "^1.15.3", + "prettier-tslint": "^0.4.1", "style-loader": "^0.23.1", "terser-webpack-plugin": "^1.1.0", - "tslint": "^5.11.0", - "tslint-config-airbnb": "^5.11.0", - "tslint-consistent-codestyle": "^1.13.3", + "tslint": "^5.12.0", + "tslint-config-airbnb": "^5.11.1", + "tslint-consistent-codestyle": "^1.14.1", "tslint-eslint-rules": "^5.4.0", "tslint-lines-between-class-members": "^1.3.1", - "tslint-microsoft-contrib": "^5.2.1", + "tslint-microsoft-contrib": "^6.0.0", "tslint-react": "^3.6.0", - "typescript": "^3.1.3", + "typescript": "^3.2.2", "url-loader": "^1.1.2", - "webpack": "^4.21.0", + "webpack": "^4.27.1", "webpack-bundle-analyzer": "^3.0.3", "webpack-cli": "^3.1.2", - "webpack-dev-server": "^3.1.9", - "webpack-merge": "^4.1.4", - "workbox-webpack-plugin": "^3.6.2" + "webpack-dev-server": "^3.1.10", + "webpack-merge": "^4.1.5", + "workbox-webpack-plugin": "^3.6.3" }, "lint-staged": { "**/*.{ts,tsx}": [ @@ -80,7 +84,7 @@ }, "husky": { "hooks": { - "pre-commit": "lint-staged" + "pre-commit": "tsc --noEmit && lint-staged" } } } diff --git a/public/index.html b/public/index.html index 8ef3cce..7daec48 100644 --- a/public/index.html +++ b/public/index.html @@ -1,33 +1,37 @@ - - - - - - - - - - - - - CC98 PWA - - - -
- + CC98 PWA + + + + +
+
+ - + + diff --git a/src/@types/@cc98/IBasicTopic.d.ts b/src/@types/@cc98/IBasicTopic.d.ts new file mode 100644 index 0000000..201ae3a --- /dev/null +++ b/src/@types/@cc98/IBasicTopic.d.ts @@ -0,0 +1,28 @@ +declare module '@cc98/api' { + export interface IBasicTopic { + /** + * 帖子id + */ + id: number + /** + * 版面id + */ + boardId: number + /** + * 是否内网可见 + */ + isInternalOnly: boolean + /** + * 是否投票贴 + */ + isVote: boolean + /** + * 帖子状态 + */ + status: number + /** + * 帖子类型 + */ + type: number + } +} diff --git a/src/@types/@cc98/IBoard.d.ts b/src/@types/@cc98/IBoard.d.ts index 4b7940d..e61352c 100644 --- a/src/@types/@cc98/IBoard.d.ts +++ b/src/@types/@cc98/IBoard.d.ts @@ -32,5 +32,29 @@ declare module '@cc98/api' { * 是否关注 */ isUserCustomBoard?: boolean + /** + * 是否仅内网可见 + */ + internalState: number + /** + * 是否已锁定 + */ + isLock: boolean + /** + * 父版面id + */ + parentId: number + /** + * 是否匿名 + */ + anonymousState: number + /** + * 是否能进入 + */ + canEntry: boolean + /** + * 是否能投票 + */ + canVote: boolean } } diff --git a/src/@types/@cc98/IBaseBoard.d.ts b/src/@types/@cc98/IBoardGroup.ts similarity index 89% rename from src/@types/@cc98/IBaseBoard.d.ts rename to src/@types/@cc98/IBoardGroup.ts index 967e391..17b0a3a 100644 --- a/src/@types/@cc98/IBaseBoard.d.ts +++ b/src/@types/@cc98/IBoardGroup.ts @@ -1,5 +1,5 @@ declare module '@cc98/api' { - export interface IBaseBoard { + export interface IBoardGroup { /** * 版面id */ diff --git a/src/@types/@cc98/IBoardMasterTitle.d.ts b/src/@types/@cc98/IBoardMasterTitle.d.ts new file mode 100644 index 0000000..a8aab34 --- /dev/null +++ b/src/@types/@cc98/IBoardMasterTitle.d.ts @@ -0,0 +1,28 @@ +declare module '@cc98/api' { + export interface IBoardMasterTitle { + /** + * 用户id + */ + userId: number + /** + * 用户名 + */ + userName: string + /** + * 版面id + */ + boardId: number + /** + * 版面名 + */ + boardName: string + /** + * 头衔名 + */ + title: string + /** + * FIXME: 不知道有什么用 + */ + boardMasterLevel: number + } +} diff --git a/src/@types/@cc98/IConfig.d.ts b/src/@types/@cc98/IConfig.d.ts new file mode 100644 index 0000000..0531c7d --- /dev/null +++ b/src/@types/@cc98/IConfig.d.ts @@ -0,0 +1,70 @@ +declare module '@cc98/api' { + export interface IConfig { + /** + * 学术贴 + */ + academics: IBasicTopic[] + /** + * 公告 + */ + announcement: string + /** + * 感性 + */ + emotion: IBasicTopic[] + /** + * 交易 + */ + fleaMarket: IBasicTopic[] + /** + * 十大 + */ + hotTopic: IBasicTopic[] + lastUpdateTime: string + lastUpdateUser: string + /** + * 在线用户数 + */ + onlineUserCount: number + /** + * 兼职 + */ + partTimeJob: IBasicTopic[] + /** + * 总帖数 + */ + postCount: number + /** + * 推荐功能 + */ + recommendationFunction: Array + /** + * 推荐阅读 + */ + recommendationReading: IRecommendationReading[] + /** + * 校园活动 + */ + schoolEvent: IBasicTopic[] + /** + * 校园新闻 + */ + schoolNews: IBasicTopic[] + /** + * 学习天地 + */ + study: IBasicTopic[] + /** + * 今日贴数 + */ + todayCount: number + /** + * 总主题 + */ + topicCount: number + /** + * 总用户 + */ + userCount: number + } +} diff --git a/src/@types/@cc98/IHotTopic.d.ts b/src/@types/@cc98/IHotTopic.d.ts index 520ffbd..1c075ba 100644 --- a/src/@types/@cc98/IHotTopic.d.ts +++ b/src/@types/@cc98/IHotTopic.d.ts @@ -37,8 +37,8 @@ declare module '@cc98/api' { */ title: string /** + * 帖子类型 * 0 普通帖子 1 校园活动 2 学术信息 - * FIXME: 不确定 */ type: number } diff --git a/src/@types/@cc98/ILike.d.ts b/src/@types/@cc98/ILike.d.ts index 10a90ba..f464bc9 100644 --- a/src/@types/@cc98/ILike.d.ts +++ b/src/@types/@cc98/ILike.d.ts @@ -1,7 +1,22 @@ declare module '@cc98/api' { + type NONE = 0 + type LIKE = 1 + type DISLIKE = 2 + + export type ILikeState = NONE | LIKE | DISLIKE + export interface ILike { + /** + * 踩数量 + */ dislikeCount: number + /** + * 赞数量 + */ likeCount: number - likeState: number + /** + * 赞/踩状态 + */ + likeState: ILikeState } } diff --git a/src/@types/@cc98/IMessageContent.d.ts b/src/@types/@cc98/IMessageContent.d.ts index d0698d9..07555c8 100644 --- a/src/@types/@cc98/IMessageContent.d.ts +++ b/src/@types/@cc98/IMessageContent.d.ts @@ -1,14 +1,25 @@ -/** - * @author dongyansong - * @date 2018-10-26 - */ declare module '@cc98/api' { export interface IMessageContent { id: number + /** + * 发送方 ID + */ senderId: number + /** + * 接收方 ID + */ receiverId: number + /** + * 发信时间 + */ time: string + /** + * 是否已读 + */ isRead: boolean + /** + * 发信内容 + */ content: string } } diff --git a/src/@types/@cc98/IPost.d.ts b/src/@types/@cc98/IPost.d.ts index 2b07361..923a533 100644 --- a/src/@types/@cc98/IPost.d.ts +++ b/src/@types/@cc98/IPost.d.ts @@ -3,20 +3,26 @@ declare module '@cc98/api' { allowedViewers: any awardInfo: any - + /** + * 风评 + */ awards: IAward[] /** * 帖子内容 */ content: string - - contentType: number + /** + * 内容类型 + * UBB 0 + * markdown 1 + */ + contentType: 0 | 1 /** * 楼层数 */ floor: number /** - * 用户 ID + * post ID */ id: number /** @@ -48,15 +54,17 @@ declare module '@cc98/api' { */ lastUpdateTime: any /** - * 赞同数 + * 赞数量 */ likeCount: number /** - * 反对数量 + * 踩数量 */ dislikeCount: number - - likeState: number + /** + * 赞/踩状态 + */ + likeState: ILikeState /** * 总楼层数 */ @@ -89,9 +97,5 @@ declare module '@cc98/api' { * 用户名 */ userName: string - /** - * 是否热帖 - */ - isHot?: boolean } } diff --git a/src/@types/@cc98/IRecentMessage.d.ts b/src/@types/@cc98/IRecentMessage.d.ts index 302589f..01debf8 100644 --- a/src/@types/@cc98/IRecentMessage.d.ts +++ b/src/@types/@cc98/IRecentMessage.d.ts @@ -1,12 +1,20 @@ -/** - * @author dongyansong - * @date 2018-10-26 - */ declare module '@cc98/api' { export interface IRecentMessage { + /** + * 用户 ID + */ userId: number + /** + * 发信时间 + */ time: string + /** + * 是否已读 + */ isRead: boolean + /** + * 最后一条消息 + */ lastContent: string } } diff --git a/src/@types/@cc98/IRecommendationReading.d.ts b/src/@types/@cc98/IRecommendationReading.d.ts new file mode 100644 index 0000000..fd1c0f9 --- /dev/null +++ b/src/@types/@cc98/IRecommendationReading.d.ts @@ -0,0 +1,30 @@ +declare module '@cc98/api' { + export interface IRecommendationReading { + /** + * 标题 + */ + title: string + /** + * 内容 + */ + content: string + /** + * 帖子链接 + */ + url: string + /** + * 图标链接 + */ + imageUrl: string + + id: number + + type: number + + enable: boolean + + time: string + + expiredTime: string + } +} diff --git a/src/@types/@cc98/ISite.d.ts b/src/@types/@cc98/ISite.d.ts new file mode 100644 index 0000000..514fda6 --- /dev/null +++ b/src/@types/@cc98/ISite.d.ts @@ -0,0 +1,76 @@ +declare module '@cc98/api' { + export interface ISite { + /** + * 全站公告 + */ + announcement: string + /** + * 全站管理员 + */ + anonymityAdmin: string[] + /** + * 是否开启生日提醒 + */ + birthdayActivityIsEnabled: boolean + /** + * 生日提醒奖励配置 + */ + birthdayActivitySetting: string + /** + * 是否维护中 + */ + isMaintaining: boolean + /** + * + */ + lastBirthdayActivityDay: string + /** + * 最新注册用户 + */ + lastUserName: string + /** + * 最高在线人数 + */ + maxOnlineCount: number + /** + * 最高在线时间 + */ + maxOnlineDate: string + /** + * 最高单日发帖数 + */ + maxPostCount: number + /** + * 最高单日发帖时间 + */ + maxPostDate: string + /** + * 全站贴总数 + */ + postCount: number + /** + * 是否开启签到 + */ + signInEnabled: boolean + /** + * 签到奖励区间 + */ + signInRewards: string[] + /** + * 签到楼id + */ + signInTopicId: number + /** + * 今日主题数 + */ + todayCount: number + /** + * 今日发帖数 + */ + topicCount: number + /** + * 用户总数 + */ + userCount: number + } +} diff --git a/src/@types/@cc98/ITag.d.ts b/src/@types/@cc98/ITag.d.ts index 92c9c6f..7e6ae1c 100644 --- a/src/@types/@cc98/ITag.d.ts +++ b/src/@types/@cc98/ITag.d.ts @@ -1,9 +1,11 @@ declare module '@cc98/api' { export interface ITag { + id: number + name: string + } + + export interface ITagGroup { layer: number - tags: { - name: string - id: number - }[] + tags: ITag[] } } diff --git a/src/@types/@cc98/ITopic.d.ts b/src/@types/@cc98/ITopic.d.ts index 8196198..b6bb493 100644 --- a/src/@types/@cc98/ITopic.d.ts +++ b/src/@types/@cc98/ITopic.d.ts @@ -85,7 +85,5 @@ declare module '@cc98/api' { tag2: number isInternalOnly: boolean - - boardName?: string } } diff --git a/src/@types/@cc98/IUser.d.ts b/src/@types/@cc98/IUser.d.ts index 9af50d8..aadfaf1 100644 --- a/src/@types/@cc98/IUser.d.ts +++ b/src/@types/@cc98/IUser.d.ts @@ -6,12 +6,14 @@ declare module '@cc98/api' { name: string /** * 用户性别 + * 男:1 + * 女:0 */ gender: 0 | 1 /** * 用户生日 */ - birthday: string + birthday: string | null /** * 用户个人简介图片 */ @@ -25,7 +27,7 @@ declare module '@cc98/api' { */ signatureCode: string /** - * id + * 用户 ID */ id: number /** @@ -114,9 +116,8 @@ declare module '@cc98/api' { levelTitle: string /** * 用户版主头衔信息 - * FIXME: BoardMasterTitle[] */ - boardMasterTitles: any + boardMasterTitles: IBoardMasterTitle[] /** * 被删除的数量 */ diff --git a/src/@types/react-16-6.d.ts b/src/@types/react-16-6.d.ts deleted file mode 100644 index af57fbc..0000000 --- a/src/@types/react-16-6.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @author dongyansong - * @see {@link https://gist.github.com/xiaoxiangmoe/edc5c1674b0ec273f0be9e2f323f90dc} - * @date 2018-11-05 - */ -import * as React from 'react' - -declare module 'react' { - function memo

( - Component: React.SFC

, - propsAreEqual?: (( - prevProps: Readonly

, - nextProps: Readonly

- ) => boolean) - ): React.SFC

- // tslint:disable-next-line - function lazy Promise<{ default: React.ComponentType }>>( - importFunction: T - ): T extends () => Promise<{ default: React.ComponentType }> - ? React.ComponentType

- : React.ComponentType -} diff --git a/src/@types/react-hooks.d.ts b/src/@types/react-hooks.d.ts deleted file mode 100644 index 18706ee..0000000 --- a/src/@types/react-hooks.d.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @author dongyansong - * @see {@link https://gist.github.com/xiaoxiangmoe/edc5c1674b0ec273f0be9e2f323f90dc} - * @date 2018-11-05 - */ -import * as React from 'react' - -declare module 'react' { - function useState(initialState: T | (() => T)): [T, (newState: T) => void] - function useEffect( - create: () => void | Promise | (() => void) | Promise<() => void>, - inputs?: ReadonlyArray - ): void - function useContext(foo: React.Context): T - function useReducer( - reducer: (state: S, action: A) => S, - initialState: S - ): [S, (action: A) => void] - function useCallback unknown>( - callback: F, - inputs?: ReadonlyArray - ): F - function useMemo(create: () => T, inputs?: ReadonlyArray): T - function useRef(initialValue?: T): React.RefObject - function useImperativeMethods( - ref: React.Ref, - createInstance: () => T, - inputs?: ReadonlyArray - ): void - const useMutationEffect: typeof useEffect - const useLayoutEffect: typeof useEffect -} diff --git a/src/@types/third.d.ts b/src/@types/third.d.ts index e3a7fff..16ac528 100644 --- a/src/@types/third.d.ts +++ b/src/@types/third.d.ts @@ -1,11 +1,8 @@ -// FIXME: - declare module 'remark' declare module 'remark-react' + +declare module 'dayjs' +declare module 'dayjs/locale/zh-cn' declare module 'dayjs/plugin/relativeTime' -declare module 'dayjs' { - import { Dayjs } from 'dayjs' - interface Dayjs { - fromNow(): () => void - } -} +declare module 'react-swipeable-views-utils' +declare module 'react-swipeable-views' diff --git a/src/App.tsx b/src/App.tsx index d2506b8..0314035 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,32 +1,34 @@ import React from 'react' -import { Provider, Subscribe } from '@cc98/state' + +import useContainer from '@/hooks/useContainer' +import settingInstance from '@/containers/setting' import { MuiThemeProvider } from '@material-ui/core/styles' import { dark, light } from './theme' -import global, { GlobalContainer } from '@/model/global' - import TopBar from '@/components/TopBar' +import DrawerMenu from '@/components/DrawerMenu' import BackGround from '@/components/BackGround' import Router from './router' -const App: React.SFC = () => ( +const App = () => ( + ) -const Root: React.SFC = () => ( - - - {(g: GlobalContainer) => ( - - - - )} - - -) +const Root = () => { + const { + state: { theme }, + } = useContainer(settingInstance) + + return ( + + + + ) +} export default Root diff --git a/src/UBB/@types/context.d.ts b/src/UBB/@types/context.d.ts new file mode 100644 index 0000000..c9d4719 --- /dev/null +++ b/src/UBB/@types/context.d.ts @@ -0,0 +1,25 @@ +declare module '@cc98/context' { + import React, { ReactNode } from 'react' + import { TagNode, IContext as ICoreContext } from '@cc98/ubb-core' + + export interface IContext extends ICoreContext { + /** + * 主题 + */ + theme: { + // TODO: + } + /** + * 表情包地址 baseURL + */ + imgBaseURL: string + /** + * 最外层的 [quote] + */ + quoteRoot?: TagNode | null + /** + * 嵌套引用列表 + */ + quotes?: React.ReactNode[] + } +} diff --git a/src/UBB/handlerHub/defaultTagHandler.tsx b/src/UBB/handlerHub/defaultTagHandler.tsx new file mode 100644 index 0000000..8695577 --- /dev/null +++ b/src/UBB/handlerHub/defaultTagHandler.tsx @@ -0,0 +1,27 @@ +import { ITagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +const handler: ITagHandler = { + isRecursive: true, + + render(node: TagNode, context: IContext, children: React.ReactNode[]) { + return ( + <> + {node._isClose ? ( + + {node._rawText} + {children} + {`[/${node.tagName}]`} + + ) : ( + <>{node._rawText} + )} + + ) + }, +} + +export default handler diff --git a/src/UBB/handlerHub/generalTagHandlers/_index.ts b/src/UBB/handlerHub/generalTagHandlers/_index.ts new file mode 100644 index 0000000..2f002fb --- /dev/null +++ b/src/UBB/handlerHub/generalTagHandlers/_index.ts @@ -0,0 +1,26 @@ +import { IHandlerHub } from '@cc98/ubb-core' + +import React from 'react' + +import ac from './ac' +import em from './em' +import mahjong from './mahjong' +import ms from './ms' +import needreply from './needreply' +import tb from './tb' + +import line from './line' + +const generalTagHandlers: IHandlerHub['generalTagHandlers'] = [ + ac, + em, + mahjong, + ms, + needreply, + tb, + + // tag without close + line, +] + +export default generalTagHandlers diff --git a/src/UBB/handlerHub/generalTagHandlers/ac.tsx b/src/UBB/handlerHub/generalTagHandlers/ac.tsx new file mode 100644 index 0000000..cb9cea3 --- /dev/null +++ b/src/UBB/handlerHub/generalTagHandlers/ac.tsx @@ -0,0 +1,21 @@ +import { IGeneralTagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +const handler: IGeneralTagHandler = { + isRecursive: false, + + match: /ac\d{2}/i, + + render(node: TagNode, context: IContext) { + const acID = node.tagData.__tagName__.slice(2) + + const url = `${context.imgBaseURL}/ac/${acID}.png` + + return {`[ac${acID}]`} + }, +} + +export default handler diff --git a/src/UBB/handlerHub/generalTagHandlers/em.tsx b/src/UBB/handlerHub/generalTagHandlers/em.tsx new file mode 100644 index 0000000..d5fabb8 --- /dev/null +++ b/src/UBB/handlerHub/generalTagHandlers/em.tsx @@ -0,0 +1,21 @@ +import { IGeneralTagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +const handler: IGeneralTagHandler = { + isRecursive: false, + + match: /em\d{2}/i, + + render(node: TagNode, context: IContext) { + const emID = node.tagData.__tagName__.slice(2) + + const url = `${context.imgBaseURL}/em/em${emID}.gif` + + return {`[em${emID}]`} + }, +} + +export default handler diff --git a/src/UBB/handlerHub/generalTagHandlers/line.tsx b/src/UBB/handlerHub/generalTagHandlers/line.tsx new file mode 100644 index 0000000..4f8af9d --- /dev/null +++ b/src/UBB/handlerHub/generalTagHandlers/line.tsx @@ -0,0 +1,17 @@ +import { IGeneralTagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +const handler: IGeneralTagHandler = { + isRecursive: false, + + match: /line/, + + render(node: TagNode, context: IContext) { + return


+ }, +} + +export default handler diff --git a/src/UBB/handlerHub/generalTagHandlers/mahjong.tsx b/src/UBB/handlerHub/generalTagHandlers/mahjong.tsx new file mode 100644 index 0000000..0a8bcb8 --- /dev/null +++ b/src/UBB/handlerHub/generalTagHandlers/mahjong.tsx @@ -0,0 +1,70 @@ +import { IGeneralTagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +const handler: IGeneralTagHandler = { + isRecursive: false, + + match: /[acf]:/i, + + render(node: TagNode, context: IContext) { + const tagName = node.tagData.__tagName__ + const mahjongType = tagName[0] + const mahjongID = tagName.slice(2) + + let url = '' + switch (mahjongType) { + case 'a': + url = getAnimalUrl(mahjongID, context.imgBaseURL) + break + case 'c': + url = getCartoonUrl(mahjongID, context.imgBaseURL) + break + case 'f': + url = getFaceUrl(mahjongID, context.imgBaseURL) + break + } + + return {tagName} + }, +} + +function getAnimalUrl(mahjongId: string, imgBaseURL: string) { + return `${imgBaseURL}/mahjong/animal2017/${mahjongId}.png` +} + +function getCartoonUrl(mahjongId: string, imgBaseURL: string) { + switch (mahjongId) { + case '018': + case '049': + case '096': + return `${imgBaseURL}/mahjong/carton2017/${mahjongId}.gif` + default: + return `${imgBaseURL}/mahjong/carton2017/${mahjongId}.png` + } +} + +function getFaceUrl(mahjongId: string, imgBaseURL: string) { + switch (mahjongId) { + case '004': + case '009': + case '056': + case '061': + case '062': + case '087': + case '115': + case '120': + case '137': + case '168': + case '169': + case '175': + case '206': + return `${imgBaseURL}/mahjong/face2017/${mahjongId}.gif` + default: + return `${imgBaseURL}/mahjong/face2017/${mahjongId}.png` + } +} + +export default handler diff --git a/src/UBB/handlerHub/generalTagHandlers/ms.tsx b/src/UBB/handlerHub/generalTagHandlers/ms.tsx new file mode 100644 index 0000000..b6e27d9 --- /dev/null +++ b/src/UBB/handlerHub/generalTagHandlers/ms.tsx @@ -0,0 +1,21 @@ +import { IGeneralTagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +const handler: IGeneralTagHandler = { + isRecursive: false, + + match: /ms\d{2}/i, + + render(node: TagNode, context: IContext) { + const msID = node.tagData.__tagName__.slice(2) + + const url = `${context.imgBaseURL}/ms/ms${msID}.png` + + return {`[ms${msID}]`} + }, +} + +export default handler diff --git a/src/UBB/handlerHub/generalTagHandlers/needreply.tsx b/src/UBB/handlerHub/generalTagHandlers/needreply.tsx new file mode 100644 index 0000000..75e55ef --- /dev/null +++ b/src/UBB/handlerHub/generalTagHandlers/needreply.tsx @@ -0,0 +1,23 @@ +import { IGeneralTagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +const handler: IGeneralTagHandler = { + isRecursive: true, + + match: /needreply/, + + render(node: TagNode, context: IContext, children: React.ReactNode[]) { + return ( + <> +
+
该内容回复后才可浏览
+
+ + ) + }, +} + +export default handler diff --git a/src/UBB/handlerHub/generalTagHandlers/tb.tsx b/src/UBB/handlerHub/generalTagHandlers/tb.tsx new file mode 100644 index 0000000..dc7af09 --- /dev/null +++ b/src/UBB/handlerHub/generalTagHandlers/tb.tsx @@ -0,0 +1,21 @@ +import { IGeneralTagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +const handler: IGeneralTagHandler = { + isRecursive: false, + + match: /tb\d{2}/i, + + render(node: TagNode, context: IContext) { + const tbID = node.tagData.__tagName__.slice(2) + + const url = `${context.imgBaseURL}/tb/tb${tbID}.png` + + return {`[tb${tbID}]`} + }, +} + +export default handler diff --git a/src/UBB/handlerHub/index.ts b/src/UBB/handlerHub/index.ts new file mode 100644 index 0000000..53f56e8 --- /dev/null +++ b/src/UBB/handlerHub/index.ts @@ -0,0 +1,23 @@ +import { IHandlerHub } from '@cc98/ubb-core' + +import React from 'react' + +import rootHandler from './rootHandler' +import specificTagHandlers from './specificTagHandlers/_index' +import generalTagHandlers from './generalTagHandlers/_index' +import defaultTagHandler from './defaultTagHandler' +import textHandler from './textHandler' + +const handlerHub: IHandlerHub = { + rootHandler, + + specificTagHandlers, + + generalTagHandlers, + + defaultTagHandler, + + textHandler, +} + +export default handlerHub diff --git a/src/UBB/handlerHub/rootHandler.tsx b/src/UBB/handlerHub/rootHandler.tsx new file mode 100644 index 0000000..cccd496 --- /dev/null +++ b/src/UBB/handlerHub/rootHandler.tsx @@ -0,0 +1,21 @@ +import { IRootHandler, RootNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +const handler: IRootHandler = { + enter(node: RootNode, context: IContext) { + // empty + }, + + exit(node: RootNode, context: IContext) { + // empty + }, + + render(node: RootNode, context: IContext, children: React.ReactNode[]) { + return
{children}
+ }, +} + +export default handler diff --git a/src/UBB/handlerHub/specificTagHandlers/_index.ts b/src/UBB/handlerHub/specificTagHandlers/_index.ts new file mode 100644 index 0000000..cc27fd3 --- /dev/null +++ b/src/UBB/handlerHub/specificTagHandlers/_index.ts @@ -0,0 +1,65 @@ +import { IHandlerHub } from '@cc98/ubb-core' + +import React from 'react' + +import align from './align' +import b from './b' +import bili from './bili' +import center from './center' +import color from './color' +import code from './code' +import cursor from './cursor' +import del from './del' +import english from './english' +import font from './font' +import i from './i' +import img from './img' +import left from './left' +import noubb from './noubb' +import quote from './quote' +import right from './right' +import sandbox from './sandbox' +import size from './size' +import table from './table' +import td from './td' +import th from './th' +import tr from './tr' +import u from './u' +import upload from './upload' +import url from './url' + +/* + * TODO: audio, glow?, md, pm, topic, user, video + */ +const tagHandlers: IHandlerHub['specificTagHandlers'] = { + // register handler here + // e.g. "b": handler_for_tag_b + align, + b, + bili, + center, + color, + code, + cursor, + del, + english, + font, + i, + img, + left, + noubb, + quote, + quotex: quote, + right, + sandbox, + size, + table, + td, + th, + tr, + u, + upload, + url, +} + +export default tagHandlers diff --git a/src/UBB/handlerHub/specificTagHandlers/align.tsx b/src/UBB/handlerHub/specificTagHandlers/align.tsx new file mode 100644 index 0000000..45f3818 --- /dev/null +++ b/src/UBB/handlerHub/specificTagHandlers/align.tsx @@ -0,0 +1,21 @@ +import { ITagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +const handler: ITagHandler = { + isRecursive: true, + + render(node: TagNode, context: IContext, children: React.ReactNode[]) { + const { align } = node.tagData + + const style = { + textAlign: align, + } as React.CSSProperties + + return
{children}
+ }, +} + +export default handler diff --git a/src/UBB/handlerHub/specificTagHandlers/b.tsx b/src/UBB/handlerHub/specificTagHandlers/b.tsx new file mode 100644 index 0000000..00d7d41 --- /dev/null +++ b/src/UBB/handlerHub/specificTagHandlers/b.tsx @@ -0,0 +1,15 @@ +import { ITagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +const handler: ITagHandler = { + isRecursive: true, + + render(node: TagNode, context: IContext, children: React.ReactNode[]) { + return {children} + }, +} + +export default handler diff --git a/src/UBB/handlerHub/specificTagHandlers/bili.tsx b/src/UBB/handlerHub/specificTagHandlers/bili.tsx new file mode 100644 index 0000000..e8ee400 --- /dev/null +++ b/src/UBB/handlerHub/specificTagHandlers/bili.tsx @@ -0,0 +1,34 @@ +import { ITagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +const handler: ITagHandler = { + isRecursive: false, + + render(node: TagNode, context: IContext) { + const innerText = node.innerText + const { bili } = node.tagData + const partNumber = parseInt(bili, 10) || 1 + + const props = { + border: 0, + frameborder: 'no', + framespacing: 0, + allowfullscreen: true, + } + + return ( + + ) + }, +} + +export default handler diff --git a/src/UBB/handlerHub/specificTagHandlers/size.tsx b/src/UBB/handlerHub/specificTagHandlers/size.tsx new file mode 100644 index 0000000..3e8e442 --- /dev/null +++ b/src/UBB/handlerHub/specificTagHandlers/size.tsx @@ -0,0 +1,25 @@ +import { ITagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' +import React from 'react' + +const handler: ITagHandler = { + isRecursive: true, + + render(node: TagNode, context: IContext, children: React.ReactNode[]) { + const { size } = node.tagData + let fontSize = parseInt(size, 10) + + // TODO: 调整计算规则 + fontSize = fontSize > 7 ? 3.5 : fontSize / 2 + fontSize /= 1.5 + + const style = { + fontSize: `${fontSize}rem`, + } as React.CSSProperties + + return {children} + }, +} + +export default handler diff --git a/src/UBB/handlerHub/specificTagHandlers/table.tsx b/src/UBB/handlerHub/specificTagHandlers/table.tsx new file mode 100644 index 0000000..e81b92c --- /dev/null +++ b/src/UBB/handlerHub/specificTagHandlers/table.tsx @@ -0,0 +1,39 @@ +import { ITagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +// table相关标签说明: https://www.cc98.org/topic/4070950 +const handler: ITagHandler = { + isRecursive: true, + + render(node: TagNode, context: IContext, children: React.ReactNode[]) { + return {children}
+ }, +} + +export default handler + +/** + * 解析 td, th, tr 的 tagData + * @param rawText + */ +export function parseTableTag(rawText: string) { + let rowSpan: number = 1 + let colSpan: number = 1 + + // FIXME: + const tagContext = rawText.slice(4, rawText.length - 1) + const values = tagContext.split(',') + + if (values.length === 2) { + rowSpan = parseInt(values[0], 10) + colSpan = parseInt(values[1], 10) + } + + return { + rowSpan, + colSpan, + } +} diff --git a/src/UBB/handlerHub/specificTagHandlers/td.tsx b/src/UBB/handlerHub/specificTagHandlers/td.tsx new file mode 100644 index 0000000..608597a --- /dev/null +++ b/src/UBB/handlerHub/specificTagHandlers/td.tsx @@ -0,0 +1,23 @@ +import { ITagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +import { parseTableTag } from './table' + +const handler: ITagHandler = { + isRecursive: true, + + render(node: TagNode, context: IContext, children: React.ReactNode[]) { + const { rowSpan, colSpan } = parseTableTag(node._rawText) + + return ( + + {children} + + ) + }, +} + +export default handler diff --git a/src/UBB/handlerHub/specificTagHandlers/th.tsx b/src/UBB/handlerHub/specificTagHandlers/th.tsx new file mode 100644 index 0000000..c7dbbb5 --- /dev/null +++ b/src/UBB/handlerHub/specificTagHandlers/th.tsx @@ -0,0 +1,23 @@ +import { ITagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +import { parseTableTag } from './table' + +const handler: ITagHandler = { + isRecursive: true, + + render(node: TagNode, context: IContext, children: React.ReactNode[]) { + const { rowSpan, colSpan } = parseTableTag(node._rawText) + + return ( + + {children} + + ) + }, +} + +export default handler diff --git a/src/UBB/handlerHub/specificTagHandlers/tr.tsx b/src/UBB/handlerHub/specificTagHandlers/tr.tsx new file mode 100644 index 0000000..e445084 --- /dev/null +++ b/src/UBB/handlerHub/specificTagHandlers/tr.tsx @@ -0,0 +1,15 @@ +import { ITagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +const handler: ITagHandler = { + isRecursive: true, + + render(node: TagNode, context: IContext, children: React.ReactNode[]) { + return {children} + }, +} + +export default handler diff --git a/src/UBB/handlerHub/specificTagHandlers/u.tsx b/src/UBB/handlerHub/specificTagHandlers/u.tsx new file mode 100644 index 0000000..714f2f7 --- /dev/null +++ b/src/UBB/handlerHub/specificTagHandlers/u.tsx @@ -0,0 +1,15 @@ +import { ITagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +const handler: ITagHandler = { + isRecursive: true, + + render(node: TagNode, context: IContext, children: React.ReactNode[]) { + return {children} + }, +} + +export default handler diff --git a/src/UBB/handlerHub/specificTagHandlers/upload.tsx b/src/UBB/handlerHub/specificTagHandlers/upload.tsx new file mode 100644 index 0000000..f137a65 --- /dev/null +++ b/src/UBB/handlerHub/specificTagHandlers/upload.tsx @@ -0,0 +1,32 @@ +import { ITagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +import InsertDriveFile from '@material-ui/icons/InsertDriveFile' + +const handler: ITagHandler = { + isRecursive: false, + + render(node: TagNode, context: IContext) { + switch (node.tagData.upload) { + case 'jpg': + case 'jpeg': + case 'png': + case 'gif': + case 'bmp': + case 'webp': + return + default: + return ( +
+ + upload文件,请使用电脑网页版下载 +
+ ) + } + }, +} + +export default handler diff --git a/src/UBB/handlerHub/specificTagHandlers/url.tsx b/src/UBB/handlerHub/specificTagHandlers/url.tsx new file mode 100644 index 0000000..dd01d90 --- /dev/null +++ b/src/UBB/handlerHub/specificTagHandlers/url.tsx @@ -0,0 +1,31 @@ +import { ITagHandler, TagNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' +import URL from 'url-parse' + +// Avoid XSS: +// https://medium.com/javascript-security/avoiding-xss-in-react-is-still-hard-d2b5c7ad9412 +export function isSafe(dangerousURL: string) { + const url = URL(dangerousURL.trim(), {}) + if (url.protocol === 'http:') return true + if (url.protocol === 'https:') return true + + return false +} + +const handler: ITagHandler = { + isRecursive: false, + + render(node: TagNode, context: IContext) { + const innerText = node.innerText + const dangerousURL = node.tagData.url || innerText + + const safeURL = isSafe(dangerousURL) ? dangerousURL : undefined + + return {innerText} + }, +} + +export default handler diff --git a/src/UBB/handlerHub/textHandler.tsx b/src/UBB/handlerHub/textHandler.tsx new file mode 100644 index 0000000..9a1b925 --- /dev/null +++ b/src/UBB/handlerHub/textHandler.tsx @@ -0,0 +1,13 @@ +import { ITextHandler, TextNode } from '@cc98/ubb-core' + +import { IContext } from '@cc98/context' + +import React from 'react' + +const handler: ITextHandler = { + render(node: TextNode, context: IContext) { + return node.text + }, +} + +export default handler diff --git a/src/UBB/index.ts b/src/UBB/index.ts new file mode 100644 index 0000000..0a2a129 --- /dev/null +++ b/src/UBB/index.ts @@ -0,0 +1,21 @@ +import React from 'react' +import UBB from '@cc98/ubb-core' + +import handlerHub from './handlerHub' +import { IContext } from '@cc98/context' + +import './style.css' + +const defaultContext: IContext = { + theme: { + // TODO: + }, + imgBaseURL: 'https://www.cc98.org/static/images', +} + +export default function UBBReact(ubbText: string, options?: Partial) { + return UBB(ubbText, handlerHub, { + ...defaultContext, + ...options, + }) +} diff --git a/src/UBB/style.css b/src/UBB/style.css new file mode 100644 index 0000000..91d3562 --- /dev/null +++ b/src/UBB/style.css @@ -0,0 +1,147 @@ +.ubb-root { + white-space: pre-wrap; + word-break: break-all; +} + +/* specificTagHandlers */ + +.ubb-tag-b { + font-weight: bold; +} + + +.ubb-tag-bili { + width: 80%; + margin-left: 10%; + border: none; +} + + +.ubb-tag-center { + text-align: center; +} + + +.ubb-tag-code { + list-style: none; + counter-reset: li; + padding: 0; + font-family: Consolas, Monaco; + overflow-x: auto; + white-space: pre; +} + +.ubb-tag-code-line { + counter-increment: li; +} + +.ubb-tag-code-line::before { + content: counter(li); + display: inline-flex; + width: 50px; + margin: 0 10px; + justify-content: center; + /* FIXME: hardcode color */ + background-color: #ebebeb; +} + + +.ubb-tag-del { + text-decoration: line-through; +} + + +.ubb-tag-english { + font-family: Arial; +} + + +.ubb-tag-i { + font-style: italic; +} + + +.ubb-tag-img { + max-width: 100%; +} + + +.ubb-tag-left { + text-align: left; +} + + +.ubb-tag-quote-container { + padding: 10px; + /* background-color: #f5faff; */ + border: 1px solid #cccccc; + max-height: 270px; + overflow-y: scroll; +} + +.ubb-tag-quote-item { + margin-top: 1.2em; +} + + +.ubb-tag-right { + text-align: right; +} + + +.ubb-tag-sandbox { + max-width: 100%; + border: none; +} + + +.ubb-tag-table { + border-collapse: collapse; +} + +.ubb-tag-td, +.ubb-tag-th { + /* TODO: theme */ + border: 1px solid rgb(0, 0, 0); +} + + +.ubb-tag-u { + text-decoration: underline; +} + + +.ubb-tag-upload { + display: inline-flex; + justify-content: space-around; + align-items: center; + border: 1px solid; + border-radius: 1px; + padding: 6px 9px; + margin: 6px 0; +} + + +/* generalTagHandlers */ + +.ubb-tag-ac, +.ubb-tag-em, +.ubb-tag-ms { + width: 60px; +} + + +.ubb-tag-mahjong { + width: 32px; +} + + +.ubb-tag-needreply { + padding: 6px 15px; + margin: 6px 0; +} + + +.ubb-tag-tb { + width: 30px; +} diff --git a/src/assets/404.png b/src/assets/404.png deleted file mode 100644 index e894af2..0000000 Binary files a/src/assets/404.png and /dev/null differ diff --git a/src/assets/asuka.jpg b/src/assets/asuka.jpg deleted file mode 100644 index cfe1de0..0000000 Binary files a/src/assets/asuka.jpg and /dev/null differ diff --git a/src/assets/dongfang/erxiaojie.png b/src/assets/dongfang/erxiaojie.png deleted file mode 100644 index b9fe395..0000000 Binary files a/src/assets/dongfang/erxiaojie.png and /dev/null differ diff --git a/src/assets/dongfang/lingmeng.png b/src/assets/dongfang/lingmeng.png deleted file mode 100644 index 8efe093..0000000 Binary files a/src/assets/dongfang/lingmeng.png and /dev/null differ diff --git a/src/assets/dongfang/molisha.png b/src/assets/dongfang/molisha.png deleted file mode 100644 index c69618c..0000000 Binary files a/src/assets/dongfang/molisha.png and /dev/null differ diff --git a/src/assets/error-on-touch.png b/src/assets/error-on-touch.png new file mode 100644 index 0000000..fa35d80 Binary files /dev/null and b/src/assets/error-on-touch.png differ diff --git a/src/assets/error.png b/src/assets/error.png new file mode 100644 index 0000000..a060995 Binary files /dev/null and b/src/assets/error.png differ diff --git a/src/assets/pwa.jpg b/src/assets/pwa.jpg new file mode 100644 index 0000000..aa22cbe Binary files /dev/null and b/src/assets/pwa.jpg differ diff --git a/src/assets/vpn.jpg b/src/assets/vpn.jpg new file mode 100644 index 0000000..92c2df7 Binary files /dev/null and b/src/assets/vpn.jpg differ diff --git a/src/components/BackGround/index.tsx b/src/components/BackGround/index.tsx index 23ba202..233c0a1 100644 --- a/src/components/BackGround/index.tsx +++ b/src/components/BackGround/index.tsx @@ -1,9 +1,12 @@ import React from 'react' -import styled from 'react-emotion' +import styled from 'styled-components' import { Paper } from '@material-ui/core' -const Background = styled(Paper)` +const Background = styled(Paper).attrs({ + square: true, + elevation: 0, +})` position: fixed; width: 100%; height: 100%; @@ -19,9 +22,9 @@ const Placeholder = styled.div` } ` -const BackGround: React.SFC = ({ children }) => ( +const BackGround: React.FunctionComponent = ({ children }) => ( <> - + {children} diff --git a/src/components/TopBar/UserInfo.tsx b/src/components/DrawerMenu/UserInfo.tsx similarity index 65% rename from src/components/TopBar/UserInfo.tsx rename to src/components/DrawerMenu/UserInfo.tsx index ea0b7b0..eb5d326 100644 --- a/src/components/TopBar/UserInfo.tsx +++ b/src/components/DrawerMenu/UserInfo.tsx @@ -1,32 +1,33 @@ import React from 'react' -import { navigate } from '@reach/router' -import { css } from 'emotion' +import { navigate } from '@/utils/history' +import styled from 'styled-components' import { Avatar, Typography } from '@material-ui/core' import defaultAvatarImg from '@/assets/9.png' import { IUser } from '@cc98/api' -const root = css` +const WrapperDiv = styled.div` display: flex; flex-direction: column; justify-content: center; align-items: center; width: 100%; margin: 12px 0; - /** has style padding-top: 8px */ padding-bottom: 5px; ` -const avatar = css` +const AvatarS = styled(Avatar)` && { width: 80px; height: 80px; } ` -const username = css` +const Username = styled(Typography).attrs({ + variant: 'body1', +})` && { margin-top: 8px; margin-bottom: -8px; @@ -40,17 +41,14 @@ interface Props { info: IUser | null } -const UserInfo: React.SFC = ({ isLogIn, info }) => ( -
- = ({ isLogIn, info }) => ( + + navigate('/userCenter') : () => navigate('/logIn')} /> - - {isLogIn ? info && info.name : '未登录'} - -
+ {isLogIn ? info && info.name : '未登录'} + ) export default UserInfo diff --git a/src/components/DrawerMenu/index.tsx b/src/components/DrawerMenu/index.tsx new file mode 100644 index 0000000..d9db75c --- /dev/null +++ b/src/components/DrawerMenu/index.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import { navigate } from '@/utils/history' +import styled from 'styled-components' + +import useContainer from '@/hooks/useContainer' +import userInstance from '@/containers/user' +import stateInstance from '@/containers/state' + +import { Divider, Drawer, List, ListItem, ListItemIcon, ListItemText } from '@material-ui/core' + +import AspectRatio from '@material-ui/icons/AspectRatio' +import Book from '@material-ui/icons/Book' +import ExitToApp from '@material-ui/icons/ExitToApp' +import FiberNew from '@material-ui/icons/FiberNew' +import HomeIcon from '@material-ui/icons/Home' +import Search from '@material-ui/icons/Search' +import Settings from '@material-ui/icons/Settings' +import Help from '@material-ui/icons/Help' +import SpeakerNotes from '@material-ui/icons/SpeakerNotes' +import Whatshot from '@material-ui/icons/Whatshot' + +import UserInfo from './UserInfo' + +interface ItemProps { + /** + * 图标 + */ + // tslint:disable-next-line:no-any + icon: React.ReactElement + /** + * 文字 + */ + text: string + /** + * 单击回调 + */ + onClick: () => void +} + +const Item: React.FunctionComponent = ({ icon, text, onClick }) => ( + + {icon} + + +) + +const ListS = styled(List)` + && { + width: 190px; + } +` + +const DividerS = styled(Divider)` + && { + margin: 0 16px; + height: 1.5px; + } +` + +const jump = (link: string) => () => navigate(link) + +const DrawerMenu: React.FunctionComponent = () => { + const { state: user, LOG_OUT } = useContainer(userInstance) + const { state, CLOSE_DRAWER } = useContainer(stateInstance) + + return ( + + + + + } text="主页" onClick={jump('/')} /> + } text="热门" onClick={jump('/hotTopics')} /> + } text="新帖" onClick={jump('/newTopics')} /> + } text="版面" onClick={jump('/boardList')} /> + {user.isLogIn && ( + <> + } text="关注" onClick={jump('/myFollow')} /> + } text="搜索" onClick={jump('/search')} /> + } text="私信" onClick={jump('/messageList')} /> + + )} + } text="设置" onClick={jump('/setting')} /> + } text="帮助" onClick={jump('/help')} /> + {user.isLogIn && ( + <> + } text="登出" onClick={LOG_OUT} /> + + )} + + + ) +} + +export default DrawerMenu diff --git a/src/components/Editor/ImageList.tsx b/src/components/Editor/ImageList.tsx deleted file mode 100644 index f907848..0000000 --- a/src/components/Editor/ImageList.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { - GridList, - GridListTile, - GridListTileBar} from '@material-ui/core' -import IconButton from '@material-ui/core/IconButton' -import { StyleRules, withStyles } from '@material-ui/core/styles'; -import { ClassNameMap } from '@material-ui/core/styles/withStyles' -import Close from '@material-ui/icons/Close' -import React from 'react' -import { SPL } from './type' -interface Props { - replyMode?: boolean | null, - imgList: SPL[], - deletePic: (id: string) => void, - theme: string, -} -const styles: StyleRules = { - tileBarRoot: { - backgroundColor: 'rgba(0,0,0,0)', - }, - actionIcon: { - borderRadius: '100%', - backgroundColor: '#13121266', - }, -} -const imgListStyle = { - padding: '0px 15px 0px 15px', - margin: '0px', - // backgroundColor: 'white', -} -const replyImgListStyle = { - padding: '0px 15px 0px 15px', - margin: '0px', - backgroundColor: 'white', - maxHeight: '200px', -} -export default withStyles(styles)( - class extends React.Component { - - render() { - const { replyMode, imgList, classes, deletePic, theme } = this.props - const wrapStyle = Object - .assign({}, - replyMode ? replyImgListStyle : imgListStyle, - theme === 'light' ? { backgroundColor: 'white' } : { backgroundColor:'#424242' }) - - return( - - { - imgList.map(e => ( - - {e.name} - - deletePic(e.id) - } - > - - - } - actionPosition="right" - /> - - ) - ) - } - - ) - } - - } -) diff --git a/src/components/Editor/TextField.tsx b/src/components/Editor/TextField.tsx deleted file mode 100644 index 97ee8f8..0000000 --- a/src/components/Editor/TextField.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import global, { GlobalContainer } from '@/model/global' -import { Subscribe } from '@cc98/state' -import { InputBase } from '@material-ui/core' -import { StyleRules, withStyles } from '@material-ui/core/styles'; -import { ClassNameMap } from '@material-ui/core/styles/withStyles' - -import React from 'react' -interface Props { - replyMode?: boolean | null, - onChange: (e: React.ChangeEvent) => void, - content?: string, - theme: string, -} - -const styles: StyleRules = { - inputMultiline: { - minHeight: '200px', - }, - replyInputMultiline: { - minHeight: '200px', - maxHeight: '200px', - }, -} -const baseInputStyle = { - padding: '15px 15px 0px 15px', - backgroundColor: 'white', - marginTop:'5px', -} -const darkInputStyle = { - padding: '15px 15px 0px 15px', - backgroundColor: '#424242', - marginTop:'5px', -} -export default withStyles(styles)( -class extends React.PureComponent { - - render() { - const { replyMode, classes, onChange, theme } = this.props - - return( - - ) - } -} -) diff --git a/src/components/Editor/ToolBar.tsx b/src/components/Editor/ToolBar.tsx deleted file mode 100644 index ca884a6..0000000 --- a/src/components/Editor/ToolBar.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { - BottomNavigation, - BottomNavigationAction, -} from '@material-ui/core' -import CircularProgress from '@material-ui/core/CircularProgress' -import Photo from '@material-ui/icons/Photo' -import Send from '@material-ui/icons/Send' -import React from 'react' -interface Props { - sendLoading: boolean, - handlePic: (e: EventTarget & HTMLInputElement) => void, - onPost: () => Promise -} -const bottomBar: React.CSSProperties = { - position: 'fixed', - bottom: '0px', - width: '100%', - justifyContent: 'space-between', -} -const bottomButton = { - maxWidth: '100px', -} -export default class extends React.PureComponent { - - clickUpload = () => { - this.refs.uploadfile.click() - } - - render() { - const { handlePic, onPost } = this.props - const sendIcon = this.props.sendLoading - ? - : - - return( - <> - ) => handlePic(e.target)} - style={{ display: 'none' }} - - // FIXME: - ref="uploadfile" - multiple - accept="image/*" - /> - - } - style={bottomButton} - onClick={this.clickUpload} - /> - - - - ) - } -} diff --git a/src/components/Editor/fileHandle.ts b/src/components/Editor/fileHandle.ts deleted file mode 100644 index 16056c3..0000000 --- a/src/components/Editor/fileHandle.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { POST } from '@/utils/fetch' - -type imgList = string[] - -export async function uploadFile(file: File): Promise { - const formData = new FormData() - - formData.append('files', file, file.name) - - const res = await POST('file', { - headers: { - // Content-Type 置空 - }, - requestInit: { - body: formData, - }, - }) - - let returl = '' - - res.fail().succeed(picurl => { - returl = picurl[0] - }) - - return returl -} diff --git a/src/components/Editor/index.tsx b/src/components/Editor/index.tsx deleted file mode 100644 index 95dbb54..0000000 --- a/src/components/Editor/index.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import global, { GlobalContainer } from '@/model/global' -import { uploadFile } from './fileHandle' -import { Subscribe } from '@cc98/state' -import { StyleRules, withStyles } from '@material-ui/core/styles' - -import React from 'react' -import ImageList from './ImageList' -import TextBase from './TextField' -import ToolBar from './ToolBar' -import { SPL } from './type' -interface Props { - /** - * 按下发送后的回调函数 - * content为文本内容 - * files是已经上传好的图片url列表 - */ - sendCallBack: (content: string, files?: string[]) => void - maxHeight?: number - replyMode?: boolean - defaultContent?: string - content?: string -} - -interface State { - showPicList: SPL[] - sendLoading: boolean - content: string - lastDefaultContent?: string | null -} - -const imgListStyle = { - padding: '0px 15px 0px 15px', - margin: '0px', - backgroundColor: 'white', -} -const replyImgListStyle = { - padding: '0px 15px 0px 15px', - margin: '0px', - backgroundColor: 'white', - maxHeight: '200px', -} - -const styles: StyleRules = { - tileBarRoot: { - backgroundColor: 'rgba(0,0,0,0)', - }, - actionIcon: { - borderRadius: '100%', - backgroundColor: '#13121266', - }, -} - -function randomString(l: number) { - const len = l || 32 - const $chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678' - /****默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1****/ - const maxPos = $chars.length - let pwd = '' - for (let i = 0; i < len; i += 1) { - pwd += $chars.charAt(Math.floor(Math.random() * maxPos)) - } - - return pwd -} -export default withStyles(styles)( - class Editor extends React.Component { - state: State = { - showPicList: [], - content: '', - sendLoading: false, - lastDefaultContent: null, - } - - static getDerivedStateFromProps(props: Props, state: State) { - if (props.defaultContent !== state.lastDefaultContent) { - return { - content: props.defaultContent, - lastDefaultContent: props.defaultContent, - } - } - - return null - } - - uploadPic = (event: EventTarget & HTMLInputElement) => { - const files = event.files - if (files) { - for (const file of files) { - const reader = new FileReader() - reader.readAsDataURL(file) - reader.onload = (e: ProgressEvent) => { - const showPiclist = this.state.showPicList - if (e.target) { - showPiclist.push({ - file, - // @ts-ignore - base64: e.target.result, - name: file.name, - id: randomString(10), - }) - } - this.setState({ - showPicList: showPiclist, - }) - } - } - } - } - - deletePic = (id: string): void => { - const { showPicList } = this.state - this.setState({ - showPicList: showPicList.filter((e: SPL) => e.id !== id), - }) - } - - bindText = (event: React.ChangeEvent) => { - this.setState({ - content: event.target.value, - }) - } - - onPost = async () => { - const { sendCallBack } = this.props - const urlList = await this.postPicAndCall() - sendCallBack(this.state.content, urlList) - this.setState({ content: '' }) - } - - postPicAndCall = async () => { - this.setState({ - sendLoading: true, - }) - const urlList: string[] = [] - const { showPicList } = this.state - if (showPicList) { - for (const i of showPicList) { - const s = await uploadFile(i.file) - urlList.push(s) - } - } - this.setState({ - sendLoading: false, - }) - - return urlList - } - - render() { - const { replyMode } = this.props - const { showPicList } = this.state - - return ( - - {(g: GlobalContainer) => ( - <> - - -
- - - )} - - ) - } - } -) diff --git a/src/components/Editor/type.ts b/src/components/Editor/type.ts deleted file mode 100644 index 75c5acd..0000000 --- a/src/components/Editor/type.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface SPL { - base64: string, - name: string, - id: string, - file: File, -} diff --git a/src/components/FixFab/index.tsx b/src/components/FixFab/index.tsx new file mode 100644 index 0000000..e66be13 --- /dev/null +++ b/src/components/FixFab/index.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import styled from 'styled-components' + +import { Fab } from '@material-ui/core' + +const FabS = styled(Fab).attrs({ + size: 'small', + color: 'primary', +})<{ bottom: number }>` + && { + position: fixed; + bottom: ${props => (props.bottom ? `${props.bottom}px` : '15px')}; + right: 15px; + z-index: 20; + } +` + +interface Props { + onClick?: () => void + bottom?: number +} + +const FixFab: React.FunctionComponent = ({ onClick, bottom, children }) => ( + // FIXME: waiting @types/styled-components to upgrade + // @ts-ignore https://www.styled-components.com/docs/advanced#refs + + {children} + +) + +export default FixFab diff --git a/src/components/InfiniteList/index.tsx b/src/components/InfiniteList/index.tsx index 23ac108..9776420 100644 --- a/src/components/InfiniteList/index.tsx +++ b/src/components/InfiniteList/index.tsx @@ -1,15 +1,30 @@ -import { debounce } from 'lodash-es' -import React from 'react' +import React, { useEffect, useRef } from 'react' +import styled from 'styled-components' import LoadingCircle from '@/components/LoadingCircle' +import { debounce } from 'lodash-es' + +// TODO: move to utils +import { bindURL } from '@/router' + +const WrapperDiv = styled.div<{ + reverse: boolean +}>` + display: flex; + flex-direction: ${props => (props.reverse ? 'column-reverse' : 'column')}; + width: 100%; + max-height: 100%; + overflow-y: auto; +` + interface Props { /** - * 列表正在加载中,回调不会重复触发 + * 新数据加载中,回调不会重复触发 */ isLoading: boolean /** - * 列表已加载完成,不需要再触发回调 + * 已全部加载完成,不需要再触发回调 */ isEnd: boolean /** @@ -18,66 +33,87 @@ interface Props { // tslint:disable-next-line callback: Function /** - * loadingCircle 的位置 + * 是否翻转列表(且 Loading 将出现在上面) */ - loadingPosition?: 'top' | 'bottom' -} - -class InfiniteList extends React.PureComponent { - /** - * 存储 debounce 之后的函数 - */ - bindFunc: () => void + reverse?: boolean /** - * loading 图标 + * 是否相对一个定高容器滚动(需要组件外层容器定高) */ - loadingDom = React.createRef() - - componentDidMount() { - const func = () => { - const { isLoading, isEnd, callback } = this.props - if (isLoading || isEnd) return - - // loadingDom 出现在可视区域 - const distance = - this.loadingDom.current && - window.innerHeight - this.loadingDom.current.getBoundingClientRect().top - if (distance === null || distance < 0) return - callback() - } + inFixedContainer?: boolean +} - this.bindFunc = debounce(func, 250) - window.addEventListener('scroll', this.bindFunc) - } - - componentWillUnmount() { - window.removeEventListener('scroll', this.bindFunc) - } - - render() { - const { isEnd, loadingPosition = 'bottom', children } = this.props - - return ( - <> - {loadingPosition === 'top' && - !isEnd && ( -
- -
- )} - - {children} - - {loadingPosition === 'bottom' && - !isEnd && ( - // TODO: forwordRef -
- -
- )} - +const InfiniteList: React.FunctionComponent = props => { + const wrapperDom = useRef(null) + const loadingDom = useRef(null) + // 保证 bindFunc 取到最新的值 + const refProps = useRef(props) + + useEffect(() => { + refProps.current = props + }) + + useEffect(() => { + const bindFunc = debounce( + bindURL(() => { + const { isEnd, isLoading, callback, inFixedContainer = false } = refProps.current + if (isLoading || isEnd) { + return + } + if (loadingDom.current === null || wrapperDom.current === null) { + return + } + + // 判断 loadingDom 是否出现在容器内部 + let isInViewport: boolean + const loadingRect = loadingDom.current.getBoundingClientRect() + + if (inFixedContainer) { + // 相对 wrapperDom + const wrapperRect = wrapperDom.current.getBoundingClientRect() + isInViewport = + loadingRect.top < wrapperRect.bottom && loadingRect.bottom > wrapperRect.top + } else { + // 相对 windows + isInViewport = loadingRect.top < window.innerHeight && loadingRect.bottom > 0 + } + + if (isInViewport) { + callback() + } + }, window.location.href), + 250 ) - } + + const { inFixedContainer = false } = refProps.current + + if (inFixedContainer) { + if (!wrapperDom.current) { + return + } + wrapperDom.current.onscroll = bindFunc + } else { + window.addEventListener('scroll', bindFunc) + + return () => { + window.removeEventListener('scroll', bindFunc) + } + } + }, []) + + const { isEnd, reverse = false, children } = props + + return ( + // FIXME: waiting @types/styled-components to upgrade + // @ts-ignore https://www.styled-components.com/docs/advanced#refs + + {children} + {!isEnd && ( +
+ +
+ )} +
+ ) } export default InfiniteList diff --git a/src/components/LayoutCenter/index.tsx b/src/components/LayoutCenter/index.tsx index 46c6449..895e0e1 100644 --- a/src/components/LayoutCenter/index.tsx +++ b/src/components/LayoutCenter/index.tsx @@ -1,7 +1,7 @@ -import { css } from 'emotion' -import React from 'react' +// import React from 'react' +import styled from 'styled-components' -const root = css` +const LayoutCenter = styled.div` position: absolute; top: 0; left: 0; @@ -12,6 +12,4 @@ const root = css` align-items: center; ` -const LayoutCenter: React.SFC = ({ children }) =>
{children}
- export default LayoutCenter diff --git a/src/components/LoadingCircle/index.tsx b/src/components/LoadingCircle/index.tsx index feb5a1a..a008173 100644 --- a/src/components/LoadingCircle/index.tsx +++ b/src/components/LoadingCircle/index.tsx @@ -1,18 +1,18 @@ -import { css } from 'emotion' import React from 'react' +import styled from 'styled-components' import { CircularProgress } from '@material-ui/core' -const loading = css` +const WrapperDiv = styled.div` display: flex; justify-content: center; padding: 15px 0; ` -const LoadingCircle: React.SFC = () => ( -
+const LoadingCircle: React.FunctionComponent = () => ( + -
+ ) export default LoadingCircle diff --git a/src/components/TopBar/DrawerMenu.tsx b/src/components/TopBar/DrawerMenu.tsx deleted file mode 100644 index 6dbb291..0000000 --- a/src/components/TopBar/DrawerMenu.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React from 'react' -import { navigate } from '@reach/router' -import { css } from 'emotion' - -import { Divider, Drawer, List, ListItem, ListItemIcon, ListItemText } from '@material-ui/core' - -import AspectRatio from '@material-ui/icons/AspectRatio' -import Book from '@material-ui/icons/Book' -import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline' -import ExitToApp from '@material-ui/icons/ExitToApp' -import FiberNew from '@material-ui/icons/FiberNew' -import HomeIcon from '@material-ui/icons/Home' -import Search from '@material-ui/icons/Search' -import Settings from '@material-ui/icons/Settings' -import SpeakerNotes from '@material-ui/icons/SpeakerNotes' -import Whatshot from '@material-ui/icons/Whatshot' - -const jump = (link: string) => () => { - navigate(link) -} - -const list = css` - width: 190px; -` - -const divider = css` - && { - margin: 0 16px; - height: 1.5px; - } -` - -interface ItemProps { - // tslint:disable-next-line:no-any - icon: React.ReactElement - text: string - onClick: () => void -} - -const Item: React.SFC = ({ icon, text, onClick }) => ( - - {icon} - - -) - -interface Props { - isLogIn: boolean - open: boolean - onClose: () => void - onLogout: () => void -} - -const TopBar: React.SFC = ({ isLogIn, open, onClose, onLogout, children }) => ( - - - {children} - - } text="主页" onClick={jump('/')} /> - } text="热门" onClick={jump('/hotTopics')} /> - } text="新帖" onClick={jump('/newTopics')} /> - } text="版面" onClick={jump('/boardList')} /> - } text="设置" onClick={jump('/setting')} /> - {isLogIn && ( - <> - } text="关注" onClick={jump('/myFollow')} /> - } text="私信" onClick={jump('/messageList')} /> - } text="搜索" onClick={jump('/search')} /> - } text="签到" onClick={jump('/signin')} /> - } text="登出" onClick={onLogout} /> - - )} - - -) - -export default TopBar diff --git a/src/components/TopBar/index.tsx b/src/components/TopBar/index.tsx index 9460073..bac59ad 100644 --- a/src/components/TopBar/index.tsx +++ b/src/components/TopBar/index.tsx @@ -1,71 +1,51 @@ import React from 'react' -import { css } from 'emotion' +import styled from 'styled-components' -import { Subscribe } from '@cc98/state' -import global, { GlobalContainer } from '@/model/global' +import stateInstance from '@/containers/state' import { AppBar, Button, IconButton, Toolbar, Typography } from '@material-ui/core' import MenuIcon from '@material-ui/icons/Menu' -import DrawerMenu from './DrawerMenu' -import UserInfo from './UserInfo' - import version from '@/version' -const grow = css` - flex-grow: 1; -` - -const icon = css` +const IconButtonS = styled(IconButton).attrs({ + color: 'inherit', +})` && { margin-left: -12px; margin-right: 5px; } ` -const button = css` +const MainText = styled(Typography).attrs({ + color: 'inherit', +})` + && { + flex-grow: 1; + } +` + +const Version = styled(Button).attrs({ + color: 'inherit', + size: 'small', +})` && { margin-right: -12px; } ` -const TopBar: React.SFC<{ - onClick: () => void -}> = ({ onClick }) => ( +const TopBar: React.FunctionComponent = () => ( - + - - - - CC98 - + - + CC98 + {version} ) -const Wrapper: React.SFC = () => ( - - {(g: GlobalContainer) => ( - <> - - - - - - )} - -) - -export default Wrapper +export default TopBar diff --git a/src/components/TopicItem/index.tsx b/src/components/TopicItem/index.tsx deleted file mode 100644 index 91faddd..0000000 --- a/src/components/TopicItem/index.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react' -// import { css } from 'emotion' -import styled from 'react-emotion' -import { navigate } from '@reach/router' - -import dayjs from 'dayjs' - -import { ListItem, ListItemText, ListItemSecondaryAction } from '@material-ui/core' - -import { StyleRules, withStyles } from '@material-ui/core/styles' -import { ClassNameMap } from '@material-ui/core/styles/withStyles' - -import { ITopic } from '@cc98/api' - -interface Props { - data: ITopic - place: 'inboard' | 'newtopic' | 'usercenter' | 'follow' | 'search' -} - -const styles: StyleRules = { - root: { - width: '100%', - }, - primary: { - fontSize: '0.875rem', - opacity: 0.54, - textAlign: 'right', - }, - secondary: { - textAlign: 'right', - }, -} - -const Text = styled.span` - display: block; - max-width: 80%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -` -export default withStyles(styles)( - class extends React.PureComponent { - render() { - const { data, place, classes } = this.props - const title = data.title - const userName = `${data.userName ? data.userName : '匿名'}` - const time = dayjs(data.lastPostTime).fromNow() - const createTime = dayjs(data.time).fromNow() - const reply = `回复:${data.replyCount}` - const boardName = data.boardName as string - let text1 = userName - let text2 = reply - switch (place) { - case 'inboard': - break - case 'usercenter': - text1 = boardName - break - case 'newtopic': - case 'follow': - case 'search': - text2 = boardName - break - } - - return ( - navigate(`/topic/${data.id}`)} button divider> - {title}} - secondary={text1} - /> - - - - - ) - } - } -) diff --git a/src/components/TopicList/TopicList.tsx b/src/components/TopicList/TopicList.tsx new file mode 100644 index 0000000..9293609 --- /dev/null +++ b/src/components/TopicList/TopicList.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import styled from 'styled-components' + +import { List } from '@material-ui/core' + +import TopicListItem, { Place } from './TopicListItem' + +import { ITopic } from '@cc98/api' + +const ListS = styled(List)` + && { + width: 100%; + } +` + +interface Props { + topics: ITopic[] + place: Place +} + +const TopicList: React.FunctionComponent = ({ topics, place }) => ( + + {topics.map(info => ( + + ))} + +) + +export default TopicList diff --git a/src/components/TopicList/TopicListItem.tsx b/src/components/TopicList/TopicListItem.tsx new file mode 100644 index 0000000..f34d332 --- /dev/null +++ b/src/components/TopicList/TopicListItem.tsx @@ -0,0 +1,141 @@ +import React, { useState } from 'react' +import styled from 'styled-components' +import { navigate } from '@/utils/history' + +import { ListItem, Typography } from '@material-ui/core' + +import { ITopic } from '@cc98/api' +import { getBoardNameById } from '@/services/board' + +import dayjs from 'dayjs' + +const ListItemS = styled(ListItem)` + && { + display: flex; + justify-content: space-between; + align-items: flex-end; + width: 100%; + } +` + +const TitleArea = styled.div` + max-width: 80%; + flex-grow: 1; +` + +const InfoArea = styled.div` + width: 70px; + flex-shrink: 0; + text-align: right; +` + +const Title = styled(Typography)` + && { + /* 多行截断,兼容性不好 */ + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + + line-height: 1.25; + } +` + +const SubTitle = styled(Typography)` + && { + margin-top: 3px; + } +` + +const Info = SubTitle + +/** + * 布局: + * title info1 + * subtitle info2 + */ +interface ItemProps { + title: string + subtitle: string + info1: string + info2: string + onClick: () => void +} + +export const TopicItem: React.FunctionComponent = ({ + onClick, + title, + subtitle, + info1, + info2, +}) => ( + + + {title} + {subtitle} + + + + {info1} + {info2} + + +) + +export type Place = 'inboard' | 'newtopic' | 'usercenter' | 'follow' | 'search' | 'hot' + +interface Props { + data: ITopic + place: Place +} + +export default ({ data, place }: Props) => { + const [boardName, setBoardName] = useState('') + + function getBoardName() { + getBoardNameById(data.boardId).then(boardName => setBoardName(boardName)) + } + + const title = data.title + let subtitle = data.userName ? data.userName : '[匿名]' + let info1 = dayjs(data.lastPostTime).fromNow() + let info2 = `回帖: ${data.replyCount}` + + switch (place) { + case 'usercenter': + if (!boardName) { + getBoardName() + } + subtitle = boardName + break + + case 'hot': + if (!boardName) { + getBoardName() + } + info1 = boardName + break + + case 'newtopic': + info1 = dayjs(data.time).fromNow() + case 'follow': + case 'search': + if (!boardName) { + getBoardName() + } + info2 = boardName + break + + // case 'inboard': + } + + return ( + navigate(`/topic/${data.id}`)} + title={title} + subtitle={subtitle} + info1={info1} + info2={info2} + /> + ) +} diff --git a/src/components/TopicList/index.tsx b/src/components/TopicList/index.tsx new file mode 100644 index 0000000..7051297 --- /dev/null +++ b/src/components/TopicList/index.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import styled from 'styled-components' + +import useInfList, { Service as InfService } from '@/hooks/useInfList' +import useFetcher, { Service as FinService } from '@/hooks/useFetcher' + +import TopicList from './TopicList' +import { Place } from './TopicListItem' +import img404 from '@/assets/error.png' + +import InfiniteList from '@/components/InfiniteList' +import LoadingCircle from '@/components/LoadingCircle' + +import { navigateHandler } from '@/services/utils/errorHandler' + +import { ITopic } from '@cc98/api' + +const Img = styled.img` + width: 60%; + max-width: 600px; +` +const CenterDiv = styled.div` + display: flex; + width: 100%; + justify-content: center; + align-items: center; +` + +/** + * 空列表占位,表示 InfList 什么都没有 + */ +const EmtpyList = () => ( + + + +) + +interface InfProps { + service: InfService + place: Place +} + +const InfTopicList: React.FunctionComponent = ({ service, place }) => { + const [topics, state, callback] = useInfList(service, { fail: navigateHandler }) + const { isLoading, isEnd } = state + + return ( + <> + {!isLoading && topics.length === 0 && } + + + + + ) +} + +interface FinProps { + service: FinService + noLoading?: boolean + place: Place +} + +const FinTopicList: React.FunctionComponent = ({ service, noLoading, place }) => { + const [topics] = useFetcher(service, { fail: navigateHandler }) + + if (topics === null) { + return noLoading ? null : + } + + return +} + +export { InfTopicList, FinTopicList } diff --git a/src/config/host.ts b/src/config/host.ts index 243797f..5597169 100644 --- a/src/config/host.ts +++ b/src/config/host.ts @@ -1,9 +1,3 @@ -/** - * @author dongyansong - * @date 2018-11-05 - */ -import { getLocalStorage } from '@/utils/storage' - export enum HostType { Proxy, Default, @@ -21,12 +15,22 @@ const defaultHost: IHost = { type: HostType.Default, } -const proxy: IHost = { +const proxyHost: IHost = { oauth: 'https://openid1.cc98.vaynetian.com/connect/token', api: 'https://api1.cc98.vaynetian.com', type: HostType.Proxy, } -const host = getLocalStorage('proxy') ? proxy : defaultHost +/** + * 代理设置 + */ +let host = process.env.PROXY ? proxyHost : defaultHost + +/** + * 切换代理 + */ +const changeHost = (hostType: HostType) => { + host = hostType === HostType.Default ? defaultHost : proxyHost +} -export default host +export { host as default, changeHost } diff --git a/src/config/proxy.ts b/src/config/proxy.ts index 9a7a8c8..8c46143 100644 --- a/src/config/proxy.ts +++ b/src/config/proxy.ts @@ -1 +1,10 @@ -export default ['Dearkano', '董松松松', 'Argon', 'adddna', 'Madridista', 'matrixqc'] +// prettier-ignore +export default [ + 'Dearkano', + '董松松松', + 'Argon', + 'adddna', + 'Madridista', + 'matrixqc', + 'u63', +] diff --git a/src/containers/setting.ts b/src/containers/setting.ts new file mode 100644 index 0000000..ab81996 --- /dev/null +++ b/src/containers/setting.ts @@ -0,0 +1,84 @@ +import { Container } from '@/hooks/useContainer' + +import { HostType, changeHost } from '@/config/host' +import { getLocalStorage, setLocalStorage } from '@/utils/storage' + +interface State { + /** + * 主题 + */ + theme: 'light' | 'dark' + /** + * 是否使用代理 + */ + useProxy: boolean + /** + * 是否开启实时通知 + */ + useSignalr: boolean + /** + * 缓存页数 + */ + routerCacheSize: number +} + +class SettingContainer extends Container { + constructor() { + super() + + this.state = { + theme: 'light', + useProxy: false, + useSignalr: false, + routerCacheSize: 3, + } + + const setting = getLocalStorage('setting') as State | null + + if (setting && setting.useProxy) { + changeHost(HostType.Proxy) + } + + this.setState(setting) + } + + SYNC_SETTING = () => { + setLocalStorage('setting', this.state) + } + + TOGGLE_THEME = () => { + this.setState( + state => ({ + theme: state.theme === 'light' ? 'dark' : 'light', + }), + this.SYNC_SETTING + ) + } + + TOGGLE_PROXY = () => { + this.setState(state => { + changeHost(state.useProxy ? HostType.Default : HostType.Proxy) + + return { + useProxy: !state.useProxy, + } + }, this.SYNC_SETTING) + } + + TOGGLE_SIGNALR = () => { + this.setState( + state => ({ + useSignalr: !state.useSignalr, + }), + this.SYNC_SETTING + ) + } + + CHANGE_CACHE = (size: number) => { + this.setState({ + routerCacheSize: size, + }) + } +} + +export default new SettingContainer() diff --git a/src/containers/state.ts b/src/containers/state.ts new file mode 100644 index 0000000..0c3fffb --- /dev/null +++ b/src/containers/state.ts @@ -0,0 +1,28 @@ +import { Container } from '@/hooks/useContainer' + +interface State { + /** + * 侧边栏是否展开 + */ + isDrawerOpen: boolean +} + +class StateContainer extends Container { + state: State = { + isDrawerOpen: false, + } + + OPEN_DRAWER = () => { + this.setState({ + isDrawerOpen: true, + }) + } + + CLOSE_DRAWER = () => { + this.setState({ + isDrawerOpen: false, + }) + } +} + +export default new StateContainer() diff --git a/src/containers/user.ts b/src/containers/user.ts new file mode 100644 index 0000000..24c93cf --- /dev/null +++ b/src/containers/user.ts @@ -0,0 +1,68 @@ +import { Container } from '@/hooks/useContainer' + +import { GET } from '@/utils/fetch' +import { logIn, logOut, isLogIn } from '@/utils/logIn' +import { IUser } from '@cc98/api' + +interface State { + /** + * 是否登录 + */ + isLogIn: boolean + /** + * 个人账户信息 + */ + myInfo: IUser | null +} + +class UserContainer extends Container { + constructor() { + super() + + this.state = { + isLogIn: isLogIn(), + myInfo: null, + } + + this.FRESH_INFO() + } + + LOG_IN = async (username: string, password: string) => { + const token = await logIn(username, password) + + token.fail().succeed(_ => { + this.setState( + { + isLogIn: true, + }, + this.FRESH_INFO + ) + }) + + return token + } + + LOG_OUT = () => { + logOut() + + this.setState({ + isLogIn: false, + myInfo: null, + }) + } + + FRESH_INFO = async () => { + if (!this.state.isLogIn) { + return + } + + const myInfo = await GET('me') + myInfo.fail().succeed(myInfo => { + this.setState({ + myInfo, + }) + }) + } +} + +export default new UserContainer() diff --git a/src/hooks/useContainer.ts b/src/hooks/useContainer.ts new file mode 100644 index 0000000..ddc96c5 --- /dev/null +++ b/src/hooks/useContainer.ts @@ -0,0 +1,73 @@ +/* tslint:disable */ +import { useState, useEffect } from 'react' + +type Listener = () => void + +export class Container { + namespace: string + state: Readonly + _listeners: Listener[] = [] + + _subscribe(fn: Listener) { + this._listeners.push(fn) + } + + _unsubscribe(fn: Listener) { + const listeners = this._listeners + listeners.splice(listeners.indexOf(fn), 1) + } + + /** + * just like setState in react + * @param state_or_updater + * @param callback + */ + setState( + updater: + | ((prevState: Readonly) => Pick | State | null) + | (Pick | State | null), + callback?: () => void + ) { + let nextState: Pick | State | null + + if (typeof updater === 'function') { + nextState = (updater as Function)(this.state) + } else { + nextState = updater + } + + // (v == null) equal to (v === null || v === undefined) + // this will prevent broadcast + if (nextState == null) { + if (callback) callback() + return Promise.resolve() + } + + this.state = Object.assign({}, this.state, nextState) + + const promises = this._listeners.map(listener => listener()) + + return Promise.all(promises).then(() => { + if (callback) { + return callback() + } + }) + } +} + +/** + * 注入一个全局 Container + * @param containerInstance 全局 container 实例 + */ +export default function useContainer(containerInstance: T) { + const forceUpdate = useState(null)[1] + + useEffect(() => { + const listener = () => forceUpdate(null) + containerInstance._subscribe(listener) + + return () => containerInstance._unsubscribe(listener) + }, []) + + return containerInstance +} diff --git a/src/hooks/useFetcher.ts b/src/hooks/useFetcher.ts new file mode 100644 index 0000000..f312daf --- /dev/null +++ b/src/hooks/useFetcher.ts @@ -0,0 +1,45 @@ +/* tslint:disable */ +import { useState, useEffect } from 'react' +import { Try } from '@/utils/fp/Try' +import { FetchError } from '@/utils/fetch' + +export type Service = () => Promise> + +interface FetchOption { + /** + * Try fail callback + */ + fail?: (err: FetchError) => void + /** + * Try success callback + */ + success?: (data: T) => void +} + +/** + * 初始化网络请求数据 + * @param service 获取数据的服务 + * @param options 成功/失败时的追加回调 + */ +export default function useFetcher(service: Service | null, options: FetchOption = {}) { + const [state, setState] = useState(null) + + useEffect(() => { + if (service === null) { + return + } + + service().then(res => { + res + .fail(err => { + options.fail && options.fail(err) + }) + .succeed(data => { + options.success && options.success(data) + setState(data) + }) + }) + }, []) + + return [state, setState] as [typeof state, typeof setState] +} diff --git a/src/hooks/useInfList.ts b/src/hooks/useInfList.ts new file mode 100644 index 0000000..ed7c698 --- /dev/null +++ b/src/hooks/useInfList.ts @@ -0,0 +1,73 @@ +/* tslint:disable */ +import { useState, useEffect } from 'react' + +import { Try } from '@/utils/fp/Try' +import { FetchError } from '@/utils/fetch' + +export type Service = (from: number) => Promise> + +interface InfListState { + isLoading: boolean + isEnd: boolean + from: number +} + +interface Options { + /** + * 初始 from,默认 0 + */ + initFrom?: number + /** + * 步长,默认 20 + */ + step?: number + /** + * Try fail callback + */ + fail?: (err: FetchError) => void + /** + * Try success callback + */ + success?: (data: T[]) => void +} + +export default function useInfList(service: Service, options: Options = {}) { + const [state, setState] = useState({ + isLoading: false, + isEnd: false, + from: (options && options.initFrom) || 0, + }) + + const [list, setList] = useState([]) + + function callback() { + setState({ + ...state, + isLoading: true, + }) + + service(state.from).then(res => { + res + .fail(err => { + options.fail && options.fail(err) + }) + .succeed(list => { + setList(prevList => prevList.concat(list)) + + setState({ + isLoading: false, + isEnd: list.length !== (options.step || 20), + from: state.from += list.length, + }) + + options.success && options.success(list) + }) + }) + } + + useEffect(() => { + callback() + }, []) + + return [list, state, callback] as [typeof list, typeof state, typeof callback] +} diff --git a/src/index.tsx b/src/index.tsx index 1ea9c6d..82e080f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,64 +1,58 @@ -/* tslint:disable */ -import { injectGlobal } from 'emotion' import React from 'react' import ReactDOM from 'react-dom' import App from './App' -import relativeTime from 'dayjs/plugin/relativeTime' +// config day.js import dayjs from 'dayjs' -// @ts-ignore import zh from 'dayjs/locale/zh-cn' - -// @ts-ignore +import relativeTime from 'dayjs/plugin/relativeTime' dayjs.locale(zh, null, false) -// @ts-ignore dayjs.extend(relativeTime) -injectGlobal` - * { - box-sizing: border-box; - } - html { - height: 100%; - } - body { - margin: 0; - height: 100%; - /* 禁止 Safari 的双击放大 */ - touch-action: manipulation; - /* 平滑滚动 */ - scroll-behavior: smooth; - } - #root { - height: 100%; - } - /* https://stackoverflow.com/questions/2781549/removing-input-background-colour-for-chrome-autocomplete */ - @keyframes autofill { - to { - color: #666; - background: transparent; - } - } - input:-webkit-autofill, - input:-webkit-autofill:hover, - input:-webkit-autofill:focus { - animation-name: autofill; - animation-fill-mode: both; +// global style +import './style.css' +import 'typeface-roboto' + +// Sentry +// NOTE: 它的模块有点问题,不要用默认导入 +import * as Sentry from '@sentry/browser' +import version from './version' + +class ErrorBoundary extends React.Component { + // tslint:disable-next-line + componentDidCatch(err: any, info: any) { + Sentry.captureException(err) } - /* https://stackoverflow.com/questions/5106934/prevent-grey-overlay-on-touchstart-in-mobile-safari-webview */ - div { - -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + + render() { + return this.props.children } -` +} + +ReactDOM.render( + + + , + document.getElementById('root') +) -ReactDOM.render(, document.getElementById('root')) +if (process.env.NODE_ENV === 'production') { + Sentry.init({ + dsn: 'https://d3350c985001442db70dccbc3d6e99c6@sentry.io/1356614', + release: version, + }) +} -// if ('serviceWorker' in navigator) { -// window.addEventListener('load', () => { -// navigator.serviceWorker.register('/service-worker.js').then(registration => { -// console.log('SW registered: ', registration) -// }).catch(registrationError => { -// console.log('SW registration failed: ', registrationError) -// }) -// }) -// } +// service worker +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker + .register('/service-worker.js') + .then(registration => { + // console.log('SW registered: ', registration) + }) + .catch(registrationError => { + // console.log('SW registration failed: ', registrationError) + }) + }) +} diff --git a/src/model/board.ts b/src/model/board.ts deleted file mode 100644 index 5546c1a..0000000 --- a/src/model/board.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { FetchError, GET } from '@/utils/fetch' -import { Success, Try } from '@/utils/fp/Try' -import { getLocalStorage, setLocalStorage } from '@/utils/storage' -import { IBaseBoard, IBoard } from '@cc98/api' -import { Container } from '@cc98/state' - -interface T { - name: string - id: number -} -interface State { - tagData: T[] - boardData: IBaseBoard[] - childBoardData: IBoard[] -} - -export class BoardInfoStore extends Container { - state: State = { - tagData: [], - boardData: [], - childBoardData: [], - } - - constructor() { - super() - this.getInfo() - this.getTagInfo() - } - /** - * 获取版面信息 - * @return {Promise>} - * @memberof BoardInfoStore - */ - getInfo = (): Promise> | undefined => { - if (this.state.boardData.length !== 0) { - return Promise.resolve(Try.of(Success.of(this.state.boardData))) - } - if (getLocalStorage('boardsInfo')) { - this.put(state => { - state.boardData = getLocalStorage('boardsInfo') as IBaseBoard[] - state.childBoardData = getLocalStorage('childBoardsInfo') as IBoard[] - }) - } else { - return this.forceGetInfo() - } - } - /** - * 获取标签信息 - * @return {Promise>} - * @memberof BoardInfoStore - */ - getTagInfo = (): Promise> | undefined => { - if (this.state.tagData.length !== 0) { - return Promise.resolve(Try.of(Success.of(this.state.tagData))) - } - - if (getLocalStorage('tagsInfo')) { - this.put(state => (state.tagData = getLocalStorage('tagsInfo') as T[])) - } else { - return this.forceGetTagInfo() - } - } - /** - * 强制从服务器获取全部信息 - * @return {Promise>} - * @memberof BoardInfoStore - */ - forceGetInfo = async (): Promise> => { - const res = await GET('board/all') - res.fail().succeed(info => this.setInfo(info)) - - return res - } - /** - * 强制从服务器获取全部信息 - * @return {Promise>} - * @memberof BoardInfoStore - */ - forceGetTagInfo = async (): Promise> => { - const res = await GET('config/global/alltag') - - res.fail().succeed(info => this.setTagInfo(info)) - - return res - } - setInfo = (data: IBaseBoard[]) => { - let cd: IBoard[] = [] - setLocalStorage('boardsInfo', data, 3600 * 24 * 7) - for (const baseBoard of data) { - cd = cd.concat(baseBoard.boards) - } - setLocalStorage('childBoardsInfo', cd, 3600 * 24 * 7) - this.put(state => { - state.boardData = data - state.childBoardData = cd - }) - } - - setTagInfo = (data: T[]) => { - setLocalStorage('tagsInfo', data, 3600 * 24 * 7) - this.put(state => (state.tagData = data)) - } -} - -export default new BoardInfoStore() diff --git a/src/model/global.ts b/src/model/global.ts deleted file mode 100644 index 556a72b..0000000 --- a/src/model/global.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Container } from '@cc98/state' - -import { GET, logIn } from '@/utils/fetch' -import { getLocalStorage, removeLocalStorage } from '@/utils/storage' -import { IUser } from '@cc98/api' - -import user from './user' - -interface State { - /** - * 侧边栏是否展开 - */ - isDrawerOpen: boolean - /** - * 是否登录 - */ - isLogIn: boolean - /** - * 个人账户信息 - */ - myInfo: IUser | null - /** - * 主题 - */ - theme: 'light' | 'dark' -} - -class GlobalContainer extends Container { - state: State = { - isDrawerOpen: false, - isLogIn: !!getLocalStorage('refresh_token'), - myInfo: null, - theme: 'light', - } - - constructor() { - super() - this.FRESH_INFO() - } - - FRESH_INFO = async () => { - if (!this.state.isLogIn) return - - const myInfo = await GET('me') - myInfo.fail().succeed(info => { - this.put(state => { - state.myInfo = info - }) - - user.setInfo(info) - }) - } - - LOG_IN = async (username: string, password: string) => { - const token = await logIn(username, password) - - token.fail().succeed(_ => { - this.put(state => { - state.isLogIn = true - }) - }) - - this.FRESH_INFO() - - return token - } - - LOG_OUT = () => { - removeLocalStorage('access_token') - removeLocalStorage('refresh_token') - - this.put(state => { - state.isLogIn = false - }) - } - - OPEN_DRAWER = () => { - this.put(state => { - state.isDrawerOpen = true - }) - } - - CLOSE_DRAWER = () => { - this.put(state => { - state.isDrawerOpen = false - }) - } - - CHANGE_THEME = () => { - switch (this.state.theme) { - case 'light': - this.put(state => (state.theme = 'dark')) - break - case 'dark': - this.put(state => (state.theme = 'light')) - break - } - } -} - -const global = new GlobalContainer() - -export { global as default, GlobalContainer } diff --git a/src/model/post.ts b/src/model/post.ts deleted file mode 100644 index b32be9b..0000000 --- a/src/model/post.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { GET } from '@/utils/fetch' -import { IAward, IBasicUser, IPost, IUser } from '@cc98/api' -import { Container } from '@cc98/state' - -interface State { - isLoading: boolean - isEnd: boolean - posts: IPost[] - userMap: { - [id: string]: IUser - } - awardsUserMap: { - [name: string]: IBasicUser - } - from: number - topicId: number - isTrace: boolean - // tslint:disable-next-line:no-any - request: any - // tslint:disable-next-line:no-any - [index: string]: any - initEditorContent?: string -} - -export class PostInfoStore extends Container { - state: State = { - isLoading: false, - isEnd: false, - posts: [], - userMap: {}, - awardsUserMap: {}, - from: 0, - topicId: -1, - // tslint:disable-next-line:no-empty - request: null, - isTrace: false, - } - - init = (id: number) => { - this.put(state => { - state.topicId = id - state.request = async () => - await GET(`topic/${this.state.topicId}/post`, { - params: { - from: `${this.state.from}`, - size: '10', - }, - }) - }) - } - - fetchPosts = async () => { - const { topicId, from, posts, isLoading, isEnd, request, isTrace } = this.state - const isFirstGlance = from === 0 - this.put(state => (state.isLoading = true)) - const postTry = await request() - - postTry.fail().succeed(async (postList: IPost[]) => { - this.fetchUsers(postList) - postList.map(post => (post.isHot = false)) - if (isFirstGlance && !isTrace) { - const hotPostTry = await GET(`topic/${this.state.topicId}/hot-post`) - hotPostTry.fail().succeed(async hotPostList => { - hotPostList.map(post => (post.isHot = true)) - let newPostList: IPost[] = [] - newPostList.push(postList[0]) - newPostList = newPostList.concat(hotPostList) - newPostList = newPostList.concat(postList.slice(1, postList.length)) - let allAwards: IAward[] = [] - newPostList.forEach(postItem => { - allAwards = allAwards.concat(postItem.awards) - }) - // const newMap = await this.fetchUsersByName(allAwards) - this.put(state => { - state.posts = newPostList - state.from = from + postList.length - state.isEnd = postList.length !== 10 - state.isLoading = false - }) - }) - } else { - let allAwards: IAward[] = [] - postList.forEach(postItem => { - allAwards = allAwards.concat(postItem.awards) - }) - // const newMap = await this.fetchUsersByName(allAwards) - this.put(state => { - state.from = from + postList.length - state.posts = posts.concat(postList) - state.isEnd = postList.length !== 10 - state.isLoading = false - }) - } - }) - } - - fetchUsersByName = async (awards: IAward[]) => { - let newMap: State['awardsUserMap'] = {} - const query = awards - .map(award => award.operatorName) - .filter(n => n) - .map(n => `name=${n}`) - .join('&') - if (!query) return - const res = await GET(`user/basic/name?${query}`) - res.fail().succeed(users => { - const userMap: State['awardsUserMap'] = {} - users.forEach(u => (userMap[u.name] = u)) - newMap = { ...this.state.awardsUserMap, ...userMap } - }) - - return newMap - } - - fetchUsers = async (postList: IPost[]) => { - const query = postList - .map(p => p.userId) - .filter(u => u) - .map(u => `id=${u}`) - .join('&') - - if (!query) return - - const res = await GET(`user?${query}`) - - res.fail().succeed(users => { - const newUsers: State['userMap'] = {} - users.forEach(user => { - newUsers[user.id] = user - }) - - this.put(state => { - state.userMap = { ...this.state.userMap, ...newUsers } - }) - }) - } - updateSinglePosts = async >(postId: number, postUpdate: T) => { - const dlist = this.state.posts.map(e => { - if (e.id === postId) { - // tslint:disable-next-line:prefer-object-spread - return Object.assign({}, e, postUpdate) - } - - return e - }) - this.put(state => { - state.posts = dlist - }) - } - - wakeUpEditor = (a: string) => { - this.put(state => { - state.initEditorContent = a - }) - } - freshLatestPosts = async () => { - const { topicId, from, posts } = this.state - - this.put(state => (state.isLoading = true)) - - const postTry = await GET(`topic/${topicId}/post?from=${from - 10}&size=10`) - - postTry.fail().succeed(postList => { - this.fetchUsers(postList) - const dlist = posts.slice(0, from - 10) - this.put(state => { - state.posts = dlist.concat(postList) - state.isEnd = postList.length !== 10 - state.isLoading = false - }) - }) - } - - updatePostAward = (data: { id: number; content: string; reason: string }, myInfo: IUser) => { - const newPosts: IPost[] = JSON.parse(JSON.stringify(this.state.posts)) - - newPosts.forEach(post => { - if (post.id === data.id) { - post.awards.push({ - id: 0, - content: data.content, - reason: data.reason, - operatorName: myInfo.name, - time: new Date(), - type: 1, - }) - } - }) - - this.put(state => (state.posts = newPosts)) - } - trace = async ( - topicId: number, - identifyId: number, - // tslint:disable-next-line:align - traceOrNot: boolean, - isAnonymous: boolean = false - ) => { - if (traceOrNot) { - if (!isAnonymous) { - this.put(state => { - state.from = 0 - state.request = async () => - await GET('post/topic/user', { - params: { - topicId: `${topicId}`, - userId: `${identifyId}`, - from: `${this.state.from}`, - size: '10', - }, - }) - state.userMap = {} - state.posts = [] - state.isTrace = true - }) - } else { - this.put(state => { - ;(state.from = 0), - (state.request = async () => - await GET('post/topic/anonymous/user', { - params: { - topicId: `${topicId}`, - postId: `${identifyId}`, - from: `${this.state.from}`, - size: '10', - }, - })), - (state.userMap = {}), - (state.posts = []), - (state.isTrace = true) - }) - } - } else { - this.put(state => { - state.from = 0 - state.request = async () => - await GET(`topic/${this.state.topicId}/post`, { - params: { - from: `${this.state.from}`, - size: '10', - }, - }) - state.userMap = {} - state.posts = [] - state.isTrace = false - }) - } - - this.fetchPosts() - } - - resetInitContent = () => { - this.put(state => { - state.initEditorContent = undefined - }) - } - reset = () => { - const initState: State = { - isLoading: false, - isEnd: false, - posts: [], - userMap: {}, - from: 0, - topicId: -1, - requestUrl: '', - request: null, - awardsUserMap: {}, - isTrace: false, - } - - this.put(state => { - for (const key of Object.keys(state)) { - state[key] = initState[key] - } - }) - } -} - -export default new PostInfoStore() diff --git a/src/model/signalr.ts b/src/model/signalr.ts deleted file mode 100644 index 9583e03..0000000 --- a/src/model/signalr.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @author dongyansong - * @date 2018-10-23 - */ -import { getAccessToken } from '@/utils/fetch' -import { getLocalStorage, removeLocalStorage, setLocalStorage } from '@/utils/storage' -import * as SignalR from '@aspnet/signalr' -import { Container } from '@cc98/state' - -import apiHost, { HostType } from '@/config/host' -import global from './global' - -interface IStore { - isConnect: boolean - shouldUseSignalr: boolean -} - -export class Store extends Container { - connection: SignalR.HubConnection - state: IStore = { - isConnect: false, - shouldUseSignalr: !!getLocalStorage('signalr'), - } - - constructor() { - super() - - this.start() - } - - start = async () => { - const shouldUseSignalr = - global.state.isLogIn && this.state.shouldUseSignalr && apiHost.state.type === HostType.Default - if (!shouldUseSignalr) return - let token = await getAccessToken() - // remove "Bearer " - token = token.slice(7) - - this.connection = new SignalR.HubConnectionBuilder() - .withUrl(`${apiHost.state.api}/signalr/notification`, { - accessTokenFactory: () => token, - transport: SignalR.HttpTransportType.WebSockets, - }) - .build() - - this.put(state => { - state.isConnect = true - }) - - return this.connection.start() - } - - stop = async () => { - if (this.state.isConnect) { - this.put(state => { - state.isConnect = false - }) - } - - return this.connection.stop() - } - - setShouldUseSignalr = (status: boolean) => { - this.put(state => { - state.shouldUseSignalr = status - }) - - if (status) { - setLocalStorage('signalr', 'signalr') - } else { - removeLocalStorage('signalr') - this.stop() - } - } -} - -export default new Store() diff --git a/src/model/topic.ts b/src/model/topic.ts deleted file mode 100644 index cdddc89..0000000 --- a/src/model/topic.ts +++ /dev/null @@ -1,241 +0,0 @@ -import getTagName from '@/services/getTagName' -import { FetchError, GET, PUT, DELETE } from '@/utils/fetch' -import { Try } from '@/utils/fp/Try' -import { IBoard, ITag, ITopic } from '@cc98/api' -import { Container } from '@cc98/state' -interface T { - id: number - name: string -} -interface State { - /** - * 设置初始化相关信息 版面id以及显示位置 - */ - searchMes: { id: string | null; place: string } | null - isLoading: boolean - isEnd: boolean - topics: ITopic[] - tags: ITag[] - tag1: T | null - tag2: T | null - board: IBoard | null - from: number - itags: T[] - // tslint:disable-next-line:no-any - [index: string]: any -} - -export class TopicInfoStore extends Container { - state: State = { - isLoading: false, - isEnd: false, - topics: [], - tag1: null, - tag2: null, - tags: [], - board: null, - from: 0, - itags: [], - searchMes: null, - } - - constructor() { - super() - } - /** - * 初始化函数,设置当前board id,获取版面、标签全局信息 - */ - init = (id: string | null, place: string) => { - if (id) { - this.initBoard(id) - this.initTag(id) - } - if (place !== 'search') { - this.initTopics(id, place) - } - this.setSearchMes(id, place) - } - - /** - * 设置api信息(版面帖子,新帖,搜索帖子,关注帖子) - * @param id 帖子id - * @param place 什么情况下显示的帖子列表 - */ - setSearchMes(id: string | null, place: string) { - this.put(state => (state.searchMes = { id, place })) - } - - initBoard = async (id: string) => { - const boardData = await GET(`board/${id}`) - boardData.map(board => this.put(state => (state.board = board))) - } - - initTag = async (id: string) => { - const tagsData = await GET(`board/${id}/tag`) - tagsData.fail().succeed(tags => { - // 初始化标签 - if (tags.length !== 0) { - const tag1 = { id: -1, name: '全部' } - let tag2: T | null = null - if (tags.length === 2) { - tag2 = { id: -1, name: '全部' } - } - this.put(state => { - ;(state.tag1 = tag1), (state.tag2 = tag2), (state.tags = tags) - }) - } - }) - } - - initTopTopics = async (id: string) => { - const topTopicsData = await GET(`topic/toptopics?boardid=${id}`) - topTopicsData.map(topTopics => { - this.put(state => (state.topics = state.topics.concat(topTopics))) - }) - } - - initTopics = async (id: string | null, place: string) => { - if (id) { - await this.initTopTopics(id) - } - await this.getTopics(id, place) - } - - getTopics = async (id: string | null, place: string, searchItem?: string) => { - const { tag1, tag2, from } = this.state - this.put(state => (state.isLoading = true)) - let res: Try | null = null - switch (place) { - case 'inboard': - // 无标签 - if ((!tag1 || tag1.id < 0) && (!tag2 || tag2.id < 0)) { - res = await GET(`board/${id}/topic`, { - params: { - from: `${this.state.from}`, - size: '20', - }, - }) - } else if (tag1 && tag1.id >= 0 && (tag2 && tag2.id > 0)) { - res = await GET( - `topic/search/board/${id}/tag?tag1=${tag1.id}&tag2=${tag2.id}&from=${from}&size=20` - ) - } else { - let layer = 1 - let tId = 0 - if (tag1 && tag1.id > 0) { - tId = tag1.id - } - if (tag2 && tag2.id >= 0) { - layer = 2 - tId = tag2.id - } - res = await GET(`topic/search/board/${id}/tag?tag${layer}=${tId}&from=${from}&size=20`) - } - break - case 'newtopic': - res = await GET('topic/new', { - params: { - from: `${this.state.from}`, - size: '20', - }, - }) - break - case 'search': - if (searchItem) { - res = await GET('topic/search', { - params: { - keyword: `${searchItem}`, - from: `${this.state.from}`, - size: '20', - }, - }) - } - break - } - /** - * api在有标签的时候返回的并不是ITopic 而是[].topics - */ - if (res) { - if (place === 'inboard' && !((!tag1 || tag1.id < 0) && (!tag2 || tag2.id < 0))) { - res.fail().succeed( - // tslint:disable-next-line:no-any - (data: any) => - this.put(state => { - state.topics = state.topics.concat(data.topics) - state.isEnd = data.topics.length !== 20 - state.isLoading = false - state.from += data.topics.length - }) - ) - } else { - res.fail().succeed(data => - this.put(state => { - state.topics = state.topics.concat(data) - state.isEnd = data.length !== 20 - state.isLoading = false - state.from += data.length - }) - ) - } - } - } - - handleChange = (index: keyof State) => (event: React.ChangeEvent) => { - const { itags } = this.state - // tslint:disable-next-line:radix - const id = parseInt(event.target.value) - const name = getTagName(itags, id) - if (index === 'tag1') { - this.put(state => { - state.tag1 = { id, name } - state.from = 0 - state.topics = [] - }) - } else { - this.put(state => { - state.tag2 = { id, name } - state.from = 0 - state.topics = [] - }) - } - if (this.state.searchMes) { - this.getTopics(this.state.searchMes.id, this.state.searchMes.place) - } - } - - /** - * 关注 取关版面 - */ - customBoard = async (id: number, opt: number) => { - const url = `me/custom-board/${id}` - let response = null - if (opt === 1) { - response = await PUT(url) - } else { - response = await DELETE(url) - } - response.fail() - } - - reset = () => { - const initState: State = { - isLoading: false, - isEnd: false, - topics: [], - tag1: null, - tag2: null, - tags: [], - board: null, - from: 0, - itags: [], - searchMes: null, - } - this.put(state => { - for (const key of Object.keys(state)) { - state[key] = initState[key] - } - }) - } -} - -export default new TopicInfoStore() diff --git a/src/model/user.ts b/src/model/user.ts deleted file mode 100644 index d2dbc81..0000000 --- a/src/model/user.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * @author dongyansong - * @date 2018-10-26 - */ -import { FetchError, GET } from '@/utils/fetch' -import { Success, Try } from '@/utils/fp/Try' -import { IUser } from '@cc98/api' -import { Container } from '@cc98/state' -import difference from 'lodash-es/difference' -import { getUserInfoById } from '@/services/user' - -interface State { - [id: number]: IUser -} - -export class UserInfoStore extends Container { - state: State = {} - - /** - * 获取用户信息,会优先返回store中的信息 - * @param {string} id 用户id - * @return {Promise>} - * @memberof UserInfoStore - */ - getInfo = (id: number): Promise> => { - if (this.state[id]) { - return Promise.resolve(Try.of(Success.of(this.state[id]))) - } - - return this.forceGetInfo(id) - } - - /** - * 强制从服务器获取用户信息 - * @param {string} id 用户id - * @return {Promise>} - * @memberof UserInfoStore - */ - forceGetInfo = async (id: number): Promise> => { - const res = await getUserInfoById(id) - - res.fail().succeed(info => this.setInfo(info)) - - return res - } - - /** - * 批量获取用户信息,store中存在的信息不会重新请求 - * @param {string} id 用户id - * @return {Promise} - * @memberof UserInfoStore - */ - getInfos = async (ids: number[]): Promise => { - const voidIds = difference(ids, Object.keys(this.state).map(item => parseInt(item, 10))) - if (!voidIds.length) return - const res = await GET(`user?id=${voidIds.join('&id=')}`) - - res.fail().succeed(infos => { - this.put(state => infos.map(item => (state[item.id] = item))) - }) - } - - setInfo = (data: IUser) => { - this.put(state => (state[data.id] = data)) - } -} - -export default new UserInfoStore() diff --git a/src/pages/Board/BaseBoardItem.tsx b/src/pages/Board/BaseBoardItem.tsx deleted file mode 100644 index a6aa93c..0000000 --- a/src/pages/Board/BaseBoardItem.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React from 'react' -import { css } from 'emotion' - -import { - ExpansionPanel, - ExpansionPanelDetails, - ExpansionPanelSummary, - Typography -} from '@material-ui/core' - -import ExpandMoreIcon from '@material-ui/icons/ExpandMore' - -import { StyleRules, withStyles } from '@material-ui/core/styles' -import { ClassNameMap } from '@material-ui/core/styles/withStyles' - -import BoardItem from './BoardItem' - -import { IBaseBoard } from '@cc98/api' - -const notExpandedBoards = [2, 29, 33, 35, 37, 604] - -interface Props { - data: IBaseBoard - classes: ClassNameMap -} - -const baseBoardStyle = css` - display: flex; - justify-content: space-between; - align-items: center; - padding-left: 1rem; - margin: 0 0 0 0; -` -const childBoardStyle = css` - && { - display: flex; - width: 100%; - flex-wrap: wrap; - margin-bottom: 1rem; - padding: 0 0 0 0; - } -` -const baseBoardContainerStyle = css` - && { - max-height: 30px; - min-height: 30px; - margin: 0 0 0 0; - } -` -const styles: StyleRules = { - root: { - width: '100%', - }, - expanded: { - marginTop: '0.5rem', - marginBottom: '0.5rem', - }, -} - -export default withStyles(styles)( - class extends React.Component { - state = { - isExpanded: notExpandedBoards.indexOf(this.props.data.id) === -1, - } - - handleChange = (_: never, status: boolean) => { - this.setState({ - isExpanded: status, - }) - } - - render() { - const { data, classes } = this.props - const { isExpanded } = this.state - - return ( - - } - > - {data.name} - - {isExpanded ? ( - - {data.boards.map(board => ( - - ))} - - ) : null} - - ) - } - } -) diff --git a/src/pages/Board/Board.tsx b/src/pages/Board/Board.tsx deleted file mode 100644 index 1e2353c..0000000 --- a/src/pages/Board/Board.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' - -import Component from './BoardData' - -interface Props { - id: string -} -export default (props: Props) => ( - -) diff --git a/src/pages/Board/BoardData.tsx b/src/pages/Board/BoardData.tsx deleted file mode 100644 index 7d3acbc..0000000 --- a/src/pages/Board/BoardData.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import React from 'react' - -import { css } from 'emotion' -import { Subscribe } from '@cc98/state' - -import { BoardInfoStore } from '@/model/board' -import { TopicInfoStore } from '@/model/topic' - -import InfiniteList from '@/components/InfiniteList' -import TopicItem from '@/components/TopicItem' - -import { FormControl, MenuItem, Paper, Select, List } from '@material-ui/core' - -import { Theme, withStyles } from '@material-ui/core/styles' -import { StyleRulesCallback, ClassNameMap } from '@material-ui/core/styles/withStyles' - -import BoardHead from './BoardHead' - -interface Tag { - name: string - id: number -} -interface Props { - id: string - classes: ClassNameMap -} -interface State { - topicInstance: TopicInfoStore -} -// interface State { -// board: IBoard | null -// topics: ITopic[] -// isLoading: boolean -// isEnd: boolean -// from: number -// tag1: Tag | null -// tag2: Tag | null -// tags: ITag[] -// } - -const styles: StyleRulesCallback = (theme: Theme) => ({ - root: { - display: 'flex', - flexWrap: 'wrap', - }, - formControl: { - margin: theme.spacing.unit, - minWidth: 120, - }, - selectEmpty: { - marginTop: theme.spacing.unit * 2, - }, - selectRoot: { - minWidth: 0, - }, -}) - -const boardStyle = css` - && { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - } -` - -export default withStyles(styles)( - class extends React.PureComponent { - state: State = { - topicInstance: new TopicInfoStore(), - } - - render() { - const { topicInstance } = this.state - - return ( - - {(boardInstance: BoardInfoStore) => { - const { - topics, - isLoading, - isEnd, - board, - tag1, - tag2, - tags, - searchMes, - } = topicInstance.state - const { classes } = this.props - if (!searchMes || searchMes.id !== this.props.id) { - topicInstance.reset() - topicInstance.init(this.props.id, 'inboard') - topicInstance.put(state => (state.itags = boardInstance.state.tagData)) - } - - if (boardInstance.state.boardData.length === 0) { - return null - } - - return ( - - {board && } -
- {tag1 ? ( - - - - ) : null} - {tag2 ? ( - - - - ) : null} -
- - - { - topicInstance.getTopics(this.props.id, 'inboard') - }} - > - {topics.map(topic => ( - - ))} - - -
- ) - }} -
- ) - } - } -) diff --git a/src/pages/Board/BoardHead.tsx b/src/pages/Board/BoardHead.tsx index 4f8bca0..4cf5c2f 100644 --- a/src/pages/Board/BoardHead.tsx +++ b/src/pages/Board/BoardHead.tsx @@ -1,165 +1,104 @@ import React, { useState } from 'react' - -import { navigate } from '@reach/router' -import { css } from 'emotion' - -import { TopicInfoStore } from '@/model/topic' +import styled from 'styled-components' import { - Button, Typography, ExpansionPanel, ExpansionPanelDetails, ExpansionPanelSummary, - Paper, + IconButton, + CircularProgress, } from '@material-ui/core' import ExpandMoreIcon from '@material-ui/icons/ExpandMore' - -import { StyleRules, withStyles } from '@material-ui/core/styles' -import { ClassNameMap } from '@material-ui/core/styles/withStyles' +import FavoriteIcon from '@material-ui/icons/Favorite' import { IBoard } from '@cc98/api' +import { customBoard } from '@/services/board' interface Props { data: IBoard - topicInstance: TopicInfoStore - classes: ClassNameMap } -const styles: StyleRules = { - root: { - width: '100%', - boxShadow: '0 0 0 0', - borderTop: '#eaeaea solid thin', - borderBottom: '#eaeaea solid thin', - }, - expanded: { - marginTop: '0.5rem', - marginBottom: '0.5rem', - }, - summaryRoot: { - height: '0.8rem', - }, -} -const boardHeader = css` - width: 100%; - position: sticky; - top: 0px; - z-index: 1105; +const FlexDiv = styled.div` display: flex; flex-direction: column; - justify-content: center; - align-items: center; - background-color: #fff; -` -const boardTitle = css` - && { - text-align: center; - font-size: 1.8rem; - flex-grow: 2; - display: flex; - } + width: 100%; ` -const boardMessage = css` + +const HeaderDiv = styled.div` display: flex; - font-size: 1rem; - align-items: center; justify-content: space-between; - width: 100%; - height: 100px; -` -const boardTopicNumber = css` - font-size: 1rem; - margin-right: 1rem; + align-items: center; + padding: 16px 10px 10px 24px; ` -const followBtnStyle = css` + +const ExpansionPanelS = styled(ExpansionPanel)` && { - width: 1.5rem; - height: 0.8rem; - margin-right: 0.4rem; + width: 100%; } ` -const boardMasters = css` - display: flex; - width: 100%; - padding-left: 1.5rem; - border-bottom: #eaeaea solid thin; -` -const toolButton = css` - margin-right: 1rem; - display: flex; - flex-direction: column; - justify-content: space-around; - height: 100%; -` -export default withStyles(styles)((props: Props) => { - const { classes, data, topicInstance } = props +export default ({ data }: Props) => { const [state, setState] = useState({ isFollowed: data.isUserCustomBoard, - buttonMessage: data.isUserCustomBoard ? '取关' : '关注', + loading: false, }) - const { isFollowed, buttonMessage } = state - const { id } = data + + const { isFollowed, loading } = state + + async function handleClick() { + if (loading) { + return + } + + setState({ + ...state, + loading: true, + }) + + const res = await customBoard(data.id, isFollowed ? 0 : 1) + res + .fail(() => setState({ ...state, loading: false })) + .succeed(_ => + setState({ + isFollowed: !isFollowed, + loading: false, + }) + ) + } return ( - -
- -
- {data.todayCount}/{data.topicCount} -
-
- - + + +
+ + {data.name} + + + {`${data.todayCount} / ${data.topicCount}`} +
-
- - } - > - - + + {state.loading ? ( + + ) : ( + + )} + + + + + }> + + 版面描述 - {data.description} - -
- {' '} - {data.boardMasters.map(master => ( - - ))} -
- + + {data.description} + + + ) -}) +} diff --git a/src/pages/Board/BoardItem.tsx b/src/pages/Board/BoardItem.tsx deleted file mode 100644 index 8bb05ad..0000000 --- a/src/pages/Board/BoardItem.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react' - -import { navigate } from '@reach/router' -import { css } from 'react-emotion' - -import Button from '@material-ui/core/Button' - -import { IBoard } from '@cc98/api' - -interface Props { - data: IBoard -} - -const cardStyle = css` - && { - margin: 0.25rem 0.25rem 0.25rem 0.25rem; - font-size: 0.8rem; - padding-left: 0.2rem; - padding-right: 0.2rem; - padding-top: 0; - padding-bottom: 0; - min-height: 32px; - min-width: 80px; - } -` -export default class extends React.PureComponent { - render() { - const { data } = this.props - - return ( - - ) - } -} diff --git a/src/pages/Board/BoardList.tsx b/src/pages/Board/BoardList.tsx deleted file mode 100644 index 6440dd7..0000000 --- a/src/pages/Board/BoardList.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react' -import { css } from 'emotion' - -import { TextField } from '@material-ui/core' - -import BaseBoard from './BaseBoardItem' -import BoardItem from './BoardItem' - -import { IBaseBoard, IBoard } from '@cc98/api' - -const searchInput = css` - display: flex; - align-items: center; - height: 70px; -` - -interface Props { - boards: IBoard[] - boardList: IBaseBoard[] -} - -interface State { - // boardList: IBaseBoard[] - isLoading: boolean - searchTerm: string -} - -export default class extends React.Component { - state: State = { - // boardList: [], - isLoading: true, - searchTerm: '', - } - - searchUpdated = (event: React.ChangeEvent) => { - this.setState({ - searchTerm: event.target.value, - }) - } - - render() { - const { searchTerm } = this.state - const { boardList, boards } = this.props - - const filteredBoards = boards.filter(board => board.name.indexOf(searchTerm) !== -1) - - return ( - <> - {/* TODO: 放右下角 */} -
- -
- - {searchTerm ? filteredBoards.map(board => ) : null} - - {searchTerm ? null : boardList.map(data => )} - - ) - } -} diff --git a/src/pages/Board/BoardTags.tsx b/src/pages/Board/BoardTags.tsx new file mode 100644 index 0000000..9c38d1e --- /dev/null +++ b/src/pages/Board/BoardTags.tsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react' +import styled from 'styled-components' + +import { MenuItem, Select } from '@material-ui/core' + +import { ITagGroup } from '@cc98/api' + +const WrapperDiv = styled.div` + margin-left: 24px; +` + +interface SelectProps { + tagGroup: ITagGroup + onChange: (tagID: number) => void +} + +const TagSelect: React.FunctionComponent = ({ tagGroup, onChange }) => { + const [tag, setTag] = useState(-1) + + const selectChange = (event: React.ChangeEvent) => { + const tag = parseInt(event.target.value, 10) + setTag(tag) + onChange(tag) + } + + return ( + + + + ) +} + +const FlexDiv = styled.div` + display: flex; + align-self: flex-start; + margin-top: 8px; +` + +interface Props { + boardTags: ITagGroup[] | null + onChange: (tagID: number, index: number) => void +} + +const Tags: React.FunctionComponent = ({ boardTags, onChange }) => ( + + {boardTags && + boardTags.map((tagGroup, index) => ( + onChange(tagID, index)} + /> + ))} + +) + +export default Tags diff --git a/src/pages/Board/index.tsx b/src/pages/Board/index.tsx index ef06d40..ea5e484 100644 --- a/src/pages/Board/index.tsx +++ b/src/pages/Board/index.tsx @@ -1,16 +1,71 @@ -import React from 'react' -import { Subscribe } from '@cc98/state' +import React, { useState } from 'react' +import styled from 'styled-components' +import { navigate } from '@/utils/history' -import boardInstance, { BoardInfoStore } from '@/model/board' +import useFetcher from '@/hooks/useFetcher' -import Component from './BoardList' +import EditIcon from '@material-ui/icons/Edit' -export default () => ( - - {(store: BoardInfoStore) => - store.state.childBoardData.length !== 0 ? ( - - ) : null +import { InfTopicList, FinTopicList } from '@/components/TopicList' +import FixFab from '@/components/FixFab' + +import BoardHead from './BoardHead' +import BoardTags from './BoardTags' + +import { getBoardInfo, getBoardTags } from '@/services/board' +import { getTopicsInBoard, getTopTopics } from '@/services/topic' +import { navigateHandler } from '@/services/utils/errorHandler' + +const WrapperDiv = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +` + +interface Props { + /** + * 版面 ID + */ + id: string +} + +export default ({ id }: Props) => { + const [board] = useFetcher(() => getBoardInfo(id), { + fail: navigateHandler, + }) + + const [boardTags] = useFetcher(() => getBoardTags(id)) + const [tagIDs, setTagIDs] = useState<[number, number]>([-1, -1]) + + const onTagChange = (tagID: number, index: number) => { + if (index === 0) { + setTagIDs([tagID, tagIDs[1]]) + } else { + setTagIDs([tagIDs[0], tagID]) } - -) + } + + return ( + + {board && ( + <> + + navigate(`/editor/postTopic/${board.id}`)}> + + + + )} + + + + getTopTopics(id)} place="inboard" noLoading /> + + getTopicsInBoard(id, from, 20, tagIDs[0], tagIDs[1])} + place="inboard" + /> + + ) +} diff --git a/src/pages/BoardList/BoardGroup.tsx b/src/pages/BoardList/BoardGroup.tsx new file mode 100644 index 0000000..58cae8e --- /dev/null +++ b/src/pages/BoardList/BoardGroup.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react' +import styled from 'styled-components' + +import { Typography, Collapse, IconButton } from '@material-ui/core' + +import ExpandMoreIcon from '@material-ui/icons/ExpandMore' + +import BoardItem from './BoardItem' + +import { IBoardGroup } from '@cc98/api' + +const WrapperDiv = styled.div` + margin-bottom: 20px; +` + +const HeaderDiv = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 2px 32px; + padding-right: 15px; +` + +const BodyDiv = styled.div` + display: flex; + flex-wrap: wrap; + margin: 0 12px; + margin-bottom: 10px; +` + +const ItemDiv = styled.div` + width: 33%; +` + +interface Props { + data: IBoardGroup +} + +const notExpandedBoards = [2, 29, 33, 35, 37, 604] + +export default (props: Props) => { + const [isExpanded, setIsExpanded] = useState(notExpandedBoards.indexOf(props.data.id) === -1) + const { data } = props + + return ( + + setIsExpanded(!isExpanded)}> + + {data.name} + + + + {/* 因为简单就内联了 */} + + + + + + + {data.boards.map(board => ( + + + + ))} + + + + ) +} diff --git a/src/pages/BoardList/BoardItem.tsx b/src/pages/BoardList/BoardItem.tsx new file mode 100644 index 0000000..ddaddb5 --- /dev/null +++ b/src/pages/BoardList/BoardItem.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { navigate } from '@/utils/history' +import styled from 'styled-components' + +import { Button } from '@material-ui/core' + +import { IBoard } from '@cc98/api' + +interface Props { + data: IBoard +} + +const Item = styled(Button)` + && { + margin: 4px; + } +` + +export default (props: Props) => ( + navigate(`/board/${props.data.id}`)} + > + {props.data.name} + +) diff --git a/src/pages/BoardList/index.tsx b/src/pages/BoardList/index.tsx new file mode 100644 index 0000000..35bf6fe --- /dev/null +++ b/src/pages/BoardList/index.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react' +import styled from 'styled-components' + +import useFetcher from '@/hooks/useFetcher' + +import { TextField, IconButton } from '@material-ui/core' + +import SearchIcon from '@material-ui/icons/Search' + +import BoardGroup from './BoardGroup' +import BoardItem from './BoardItem' + +import { getBoardsInfo } from '@/services/board' +import { navigateHandler } from '@/services/utils/errorHandler' +import { IBoard } from '@cc98/api' + +import { throttle } from 'lodash-es' + +const SearchInputDiv = styled.div` + display: flex; + align-items: center; + width: 100%; + padding: 10px 25px 25px 5px; +` + +interface Props { + value: string + onChange: (e: React.ChangeEvent) => void +} + +const SearchInput: React.FunctionComponent = ({ value, onChange }) => ( + + + + + + +) + +const WrapperDiv = styled.div` + margin: 0 10px; +` + +const EmptyDiv = styled.div` + height: 100px; +` + +export default () => { + const [childBoards, setChildBoards] = useState([]) + const [boardList] = useFetcher(getBoardsInfo, { + success: boards => { + setChildBoards( + boards.map(baseBoard => baseBoard.boards).reduce((prev, cur) => cur.concat(prev)) + ) + }, + fail: navigateHandler, + }) + + // 版面搜索 + const [searchTerm, setSearchTerm] = useState('') + const [filteredBoards, setFilteredBoards] = useState([]) + + const onSearchTermChange = throttle((e: React.ChangeEvent) => { + setSearchTerm(e.target.value) + setFilteredBoards(childBoards.filter(board => board.name.indexOf(e.target.value) !== -1)) + }, 250) + + return ( + <> + + {searchTerm ? ( + + {filteredBoards.map(board => ( + + ))} + + ) : ( + <> + {boardList && + boardList.map(boardGroup => )} + + + )} + + ) +} diff --git a/src/pages/Compose/ScrollTag.tsx b/src/pages/Compose/ScrollTag.tsx deleted file mode 100644 index e082d1a..0000000 --- a/src/pages/Compose/ScrollTag.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from 'react' - -import { css } from 'emotion' - -import toast from './Toast' -import { Chip } from '@material-ui/core' - -interface TagType { - id: number - name: string -} -interface State { - clickTag: TagType[] -} - -interface Props { - tags: TagType[] | null - maxTag: number - tagChange: (tag: TagType[]) => void -} -const taglistbox = css` - background-color: #fff; - margin-bottom: 2px; - padding: 3px; -` - -const scrollbox = css` - overflow: auto; - widows: 100%; - display: flex; - flex-direction: column; - padding: 10px; - height: 70px; - justify-content: space-between; -` - -const insidebox = css` - width: 100%; - white-space: nowrap; - height: 40px; - line-height: 30px; - background: #fff; - overflow-y: hidden; - overflow-x: scroll; - display: inline-block; -` - -class ScrollTag extends React.Component { - state: State = { - clickTag: [], - } - - didChose = (tag: TagType) => { - let ret = false - this.state.clickTag.forEach(e => { - if (e.id === tag.id) ret = true - }) - - return ret - } - - handleClick = (tag: TagType) => { - const { clickTag } = this.state - const { maxTag, tagChange } = this.props - if (this.didChose(tag)) { - // 取消选中表气啊 - const stateTag = clickTag.filter(e => e.id !== tag.id) - tagChange(stateTag) - this.setState({ clickTag: stateTag }) - } else { - // 选中标签 - if (clickTag.length > maxTag - 1) { - toast.info({ content: `最多只能选择${maxTag}个标签` }) - } else { - const stateTag = clickTag.concat([tag]) - tagChange(stateTag) - this.setState({ clickTag: stateTag }) - } - } - } - - render() { - const { tags } = this.props - if (!tags) { - return null - } - - return ( - <> -
-
-
- {tags.map(tag => ( - { - this.handleClick(tag) - }} - color={this.didChose(tag) ? 'primary' : 'default'} - /> - ))} -
-
-
- - ) - } -} - -export default ScrollTag diff --git a/src/pages/Compose/Toast/Queue.tsx b/src/pages/Compose/Toast/Queue.tsx deleted file mode 100644 index 6039ae7..0000000 --- a/src/pages/Compose/Toast/Queue.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react' -import Toast from './toast' -import { NotifFnData } from './type' -interface State { - open: boolean - messageInfo?: NotifFnData | null -} -class ConsecutiveSnackbars extends React.Component<{}, State> { - queue: NotifFnData[] = [] - - state = { - open: false, - messageInfo: null, - } - - addNotice = (w: NotifFnData) => { - this.queue.push(w) - // console.log(this.state.open) - if (this.state.open) { - // immediately begin dismissing current message - // to start showing new one - this.setState({ open: false }) - } else { - this.processQueue() - } - } - - processQueue = () => { - if (this.queue.length > 0) { - this.setState({ - messageInfo: this.queue.shift(), - open: true, - }) - } - } - - // tslint:disable-next-line:no-any - handleClose = (event: React.SyntheticEvent, reason: string) => { - if (reason === 'clickaway') { - return - } - this.setState({ open: false }) - } - - handleExited = () => { - this.processQueue() - } - - render() { - if (this.state.messageInfo) { - const messageInfo = this.state.messageInfo - const duration = messageInfo.duration ? messageInfo.duration : 5000 - - return ( - - ) - } - - return <> - } -} - -export default ConsecutiveSnackbars diff --git a/src/pages/Compose/Toast/index.tsx b/src/pages/Compose/Toast/index.tsx deleted file mode 100644 index 9144e6d..0000000 --- a/src/pages/Compose/Toast/index.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/** - * info({}) - * success({}) - * loading({}) - * error({}) - * 一共导出四个toast函数 - */ -import React from 'react' -import ReactDOM from 'react-dom' -import ConsecutiveSnackbars from './Queue' -import { NotifFnData, ToastData } from './type' - -type NotifFn = ( - type: 'success' | 'error' | 'loading' | 'info', - content: string, - duration?: number, - onClose?: () => void -) => void -function createNotification() { - const div = document.createElement('div') - document.body.appendChild(div) - // tslint:disable-next-line:no-angle-bracket-type-assertion - // const toastNotification = ReactDOM.render(, div) - const toastNotification = ReactDOM.render(, div) - - return { - addNotice(toastNotice: NotifFnData) { - if (toastNotification) { - toastNotification.addNotice(toastNotice) - } - }, - destroy() { - ReactDOM.unmountComponentAtNode(div) - document.body.removeChild(div) - }, - } -} - -let notification: { - addNotice(toastNotice: NotifFnData): void - destroy(): void -} | null = null -const notice: NotifFn = (type, content, duration = 2000, onClose) => { - if (!notification) notification = createNotification() - notification.addNotice({ type, content, duration, onClose }) -} - -export default { - info({ content, duration, onClose }: ToastData) { - return notice('info', content, duration, onClose) - }, - success({ content = '操作成功', duration, onClose }: ToastData) { - return notice('success', content, duration, onClose) - }, - error({ content, duration, onClose }: ToastData) { - return notice('error', content, duration, onClose) - }, - loading({ content = '加载中', duration, onClose }: ToastData) { - return notice('loading', content, duration, onClose) - }, -} diff --git a/src/pages/Compose/Toast/toast.tsx b/src/pages/Compose/Toast/toast.tsx deleted file mode 100644 index a5e5a56..0000000 --- a/src/pages/Compose/Toast/toast.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { IconButton, Snackbar, SnackbarContent } from '@material-ui/core' -import Autorenew from '@material-ui/icons/Autorenew' -import CheckCircleIcon from '@material-ui/icons/CheckCircle' -import CloseIcon from '@material-ui/icons/Close' -import ErrorIcon from '@material-ui/icons/Error' -import InfoIcon from '@material-ui/icons/Info' -import { css } from 'emotion' -import React from 'react' -type CloseEvent = ( - // tslint:disable-next-line:no-any - event: React.SyntheticEvent, - reason: string -) => void - -interface Props { - open: boolean - content: string - duration: number - type?: 'success' | 'error' | 'loading' | 'info' - handleClose: CloseEvent - onExited: () => void -} -const variantIcon = { - success: CheckCircleIcon, - loading: Autorenew, - error: ErrorIcon, - info: InfoIcon, -} -const variantStyle = { - golbal: css` - margin-right: 10px; - font-size: 10px; - `, - success: css` - background-color: #43a047 !important; - `, - loading: css` - background-color: #ffa000 !important; - `, - error: css` - background-color: #d32f2f !important; - `, - info: css` - background-color: #1976d2 !important; - `, -} -const message = css` - display: flex; - align-items: center; - font-size: 15px; -` -const ToastBox = ({ open, content, duration, type, handleClose, onExited }: Props) => { - const variant = type ? type : 'success' - const Icon = variantIcon[variant] - - return ( - { - onExited() - }} - > - - - {content} - } - // TODO: 撤销选项 - // action={[ - // , - action={[ - { - handleClose() - }} - > - - , - ]} - /> - - ) -} - -export default ToastBox diff --git a/src/pages/Compose/Toast/type.ts b/src/pages/Compose/Toast/type.ts deleted file mode 100644 index b3cc97d..0000000 --- a/src/pages/Compose/Toast/type.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface ToastData { - content: string - duration?: number - onClose?: () => void -} -export interface NotifFnData { - type: 'success' | 'error' | 'loading' | 'info' - content: string - duration?: number - onClose?: () => void -} diff --git a/src/pages/Compose/TypeSelect.tsx b/src/pages/Compose/TypeSelect.tsx deleted file mode 100644 index 26e0a7d..0000000 --- a/src/pages/Compose/TypeSelect.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react' - -import { - FormControl, - InputLabel, - MenuItem, - OutlinedInput, - Select, -} from '@material-ui/core' - -interface Props { - onChange: (event: React.ChangeEvent) => void, - topicType: string -} -const typeNum = [ - { id: '0', name:'普通' }, - { id: '1', name:'校园活动' }, - { id: '2', name:'学术信息' }, -] -export default ({ onChange, topicType }: Props) => ( - - 帖子类型 - - -) diff --git a/src/pages/Compose/index.tsx b/src/pages/Compose/index.tsx deleted file mode 100644 index b4a39f4..0000000 --- a/src/pages/Compose/index.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import React from 'react' - -import { navigate } from '@reach/router' - -import { TextField } from '@material-ui/core' - -import Editor from '@/components/Editor' - -import ScrollTag from './ScrollTag' -import TypeSelect from './TypeSelect' - -import toast from './Toast' -import { GET, POST } from '@/utils/fetch' - -interface TagType { - id: number - name: string -} -const labelStyle = { - paddingLeft: '15px', -} -const baseInputStyle = { - padding: '0px 15px 0px 15px', -} -interface State { - picList: FileList | null - title: string - topicType: string - tag: TagType[] - chosenTag: TagType[] -} -interface Props { - boardId: string -} - -interface BoradTag { - layer: number - tags: TagType[] -} - -class Compose extends React.Component { - state: State = { - picList: null, - title: '', - topicType: '0', - tag: [], - chosenTag: [], - } - - async componentDidMount() { - const res = await this.getTag(this.props.boardId) - this.setState({ tag: res }) - } - - async post(boardId: string, title: string, content: string, tags?: TagType[]) { - let postTag = {} - let i = 0 - if (tags) { - for (const iterator of tags) { - i = i + 1 - postTag = { - [`tag${i}`]: iterator.id, - ...postTag, - } - } - } - const data = { - content, - title, - contentType: 0, - type: 0, - topicType: this.state.topicType, - ...postTag, - } - const url = `/board/${boardId}/topic` - const response = await POST(url, { params: data }) - response.fail().succeed(topicId => { - navigate(`/topic/${topicId}`) - - return - }) - } - - async getTag(boardId: string) { - const url = `board/${boardId}/tag` - const res = await GET(url) - let ret: TagType[] = [] - res.fail().succeed(data => { - ret = data[0].tags - }) - - return ret - } - - bindText = ( - event: React.ChangeEvent - ) => { - this.setState({ - title: event.target.value, - }) - } - - bindType = (event: React.ChangeEvent) => { - this.setState({ topicType: event.target.value }) - } - - sendCallBack = (content: string, files?: string[]) => { - // console.log(files) - if (this.state.title === '') { - toast.error({ content: '请填写标题~( ̄▽ ̄~)(~ ̄▽ ̄)~ ' }) - - return - } - - let realContent: string - if (files) { - const imgString = files.map(e => ` \n [img]${e}[/img]`).join(' ') - realContent = content + imgString - } else { - realContent = content - } - this.post(this.props.boardId, this.state.title, realContent, this.state.chosenTag) - } - - render() { - const { tag } = this.state - const tagDom = - tag.length === 0 ? ( - <> - ) : ( - { - this.setState({ chosenTag: tags }) - }} - /> - ) - - return ( - <> - { - this.bindText(e) - }} - margin="normal" - /> - - {tagDom} - - - ) - } -} -export default Compose diff --git a/src/pages/Editor/Editor/Attachments.tsx b/src/pages/Editor/Editor/Attachments.tsx new file mode 100644 index 0000000..caf1cbe --- /dev/null +++ b/src/pages/Editor/Editor/Attachments.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import styled from 'styled-components' + +import { EditorContainer } from './EditorContainer' + +import { Badge } from '@material-ui/core' +import ClearIcon from '@material-ui/icons/Clear' + +import UBB from '@/UBB' + +const WrapperDiv = styled.div` + display: flex; + flex-wrap: wrap; + margin-top: 16px; +` + +const AttachDiv = styled.div` + margin: 10px; + max-width: 80px; + max-height: 80px; +` + +interface Props { + editor: EditorContainer +} + +export default ({ editor }: Props) => ( + + {editor.state.attachments.map((attach, index) => ( + + } + onClick={() => editor.detachAttachment(index)} + > + {UBB(attach)} + + + ))} + +) diff --git a/src/pages/Editor/Editor/EditorContainer.ts b/src/pages/Editor/Editor/EditorContainer.ts new file mode 100644 index 0000000..17f72ce --- /dev/null +++ b/src/pages/Editor/Editor/EditorContainer.ts @@ -0,0 +1,91 @@ +import { Container } from '@/hooks/useContainer' + +interface State { + /** + * 主文本区 + */ + mainContent: string + /** + * 追加区 + */ + attachments: string[] + /** + * 正在发布 + */ + isSending: boolean +} + +/** + * 编辑器句柄 + */ +export class EditorContainer extends Container { + constructor(initContent?: string) { + super() + + this.state = { + mainContent: initContent || '', + attachments: [], + isSending: false, + } + } + + /** + * 获取完整内容(包括 mainContent & attachments) + */ + get fullContent() { + let attachments = this.state.attachments.join('') + if (attachments) { + attachments = `\n${attachments}` + } + + return this.state.mainContent + attachments + } + + /** + * 替换主文本内容 + */ + replaceMainContent(str: string) { + this.setState({ + mainContent: str, + }) + } + + /** + * 追加主文本内容 + */ + appendMainContent(str: string) { + this.setState(prev => ({ + mainContent: prev.mainContent + str, + })) + } + + /** + * 追加内容到追加区 + */ + attachAttachment(content: string) { + this.setState(prev => ({ + attachments: prev.attachments.concat(content), + })) + } + + /** + * 删除追加区内容 + */ + detachAttachment(index: number) { + this.setState(prev => { + prev.attachments.splice(index, 1) + + return { attachments: prev.attachments } + }) + } + + /** + * 清空输入 + */ + clearAll() { + this.setState({ + mainContent: '', + attachments: [], + }) + } +} diff --git a/src/pages/Editor/Editor/MainContent.tsx b/src/pages/Editor/Editor/MainContent.tsx new file mode 100644 index 0000000..8b00b7b --- /dev/null +++ b/src/pages/Editor/Editor/MainContent.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import styled from 'styled-components' + +import { EditorContainer } from './EditorContainer' + +import { InputBase } from '@material-ui/core' + +const InputArea = styled(InputBase).attrs({ + fullWidth: true, + multiline: true, + autoFocus: true, + rows: 6, + rowsMax: 12, +})` + && { + margin-top: 8px; + padding: 12px 8px; + border: 1.5px solid #ccc; + } +` + +interface Props { + editor: EditorContainer +} + +export default ({ editor }: Props) => { + const handlerChange = (event: React.ChangeEvent) => { + editor.replaceMainContent(event.target.value) + } + + return ( + + ) +} diff --git a/src/pages/Editor/Editor/ToolBox/ClearBtn.tsx b/src/pages/Editor/Editor/ToolBox/ClearBtn.tsx new file mode 100644 index 0000000..9973cc1 --- /dev/null +++ b/src/pages/Editor/Editor/ToolBox/ClearBtn.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react' + +import { EditorContainer } from '../EditorContainer' + +import { + IconButton, + Button, + Dialog, + DialogContent, + DialogContentText, + DialogActions, +} from '@material-ui/core' +import DeleteIcon from '@material-ui/icons/Delete' + +interface Props { + editor: EditorContainer +} + +export default ({ editor }: Props) => { + const [open, setOpen] = useState(false) + const handleClose = () => { + setOpen(false) + } + + const handlerComfirm = () => { + editor.clearAll() + handleClose() + } + + function clickHandler() { + setOpen(true) + } + + return ( + + + + + 确认要清空已输入的内容吗? + + + + + + + + ) +} diff --git a/src/pages/Editor/Editor/ToolBox/PictureBtn.tsx b/src/pages/Editor/Editor/ToolBox/PictureBtn.tsx new file mode 100644 index 0000000..9fbc678 --- /dev/null +++ b/src/pages/Editor/Editor/ToolBox/PictureBtn.tsx @@ -0,0 +1,49 @@ +import React, { useRef } from 'react' + +import { EditorContainer } from '../EditorContainer' + +import { IconButton } from '@material-ui/core' +import AddPhotoAlternateIcon from '@material-ui/icons/AddPhotoAlternate' + +import { uploadPicture } from '@/services/editor' + +interface Props { + editor: EditorContainer +} + +export default ({ editor }: Props) => { + const fileInputRef = useRef(null) + + function clickHandler() { + if (!fileInputRef.current) { + return + } + + fileInputRef.current.click() + } + + async function choosePicFinish(files: FileList | null) { + if (!files || files.length === 0) return + for (const file of files) { + const res = await uploadPicture(file) + res.fail().succeed(data => { + editor.attachAttachment(`[img]${data[0]}[/img]`) + }) + } + } + + return ( + + choosePicFinish(e.target.files)} + ref={fileInputRef} + multiple + accept="image/*" + /> + + + ) +} diff --git a/src/pages/Editor/Editor/ToolBox/PreviewBtn.tsx b/src/pages/Editor/Editor/ToolBox/PreviewBtn.tsx new file mode 100644 index 0000000..1915597 --- /dev/null +++ b/src/pages/Editor/Editor/ToolBox/PreviewBtn.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react' +import styled from 'styled-components' + +import { EditorContainer } from '../EditorContainer' + +import { + IconButton, + Dialog, + DialogContent, + DialogContentText, + DialogActions, + Button, +} from '@material-ui/core' + +import TransformIcon from '@material-ui/icons/Transform' + +import UBB from '@/UBB' + +const DialogContentTextS = styled(DialogContentText)` + && { + min-height: 160px; + } +` + +interface PreviewProps { + content: string + handleClose: () => void +} + +const Preview = ({ content, handleClose }: PreviewProps) => ( + <> + + {content ? UBB(content) : '【没有内容】'} + + + + + +) + +interface Props { + editor: EditorContainer +} + +export default ({ editor }: Props) => { + const [open, setOpen] = useState(false) + const handleClose = () => setOpen(false) + + function clickHandler() { + setOpen(true) + } + + return ( + + + + + + + ) +} diff --git a/src/pages/Editor/Editor/ToolBox/SendBtn.tsx b/src/pages/Editor/Editor/ToolBox/SendBtn.tsx new file mode 100644 index 0000000..83a65a2 --- /dev/null +++ b/src/pages/Editor/Editor/ToolBox/SendBtn.tsx @@ -0,0 +1,25 @@ +import React from 'react' + +import { IconButton, CircularProgress } from '@material-ui/core' +import SendIcon from '@material-ui/icons/Send' + +import { EditorContainer } from '../EditorContainer' + +interface Props { + editor: EditorContainer + onSendCallback: () => void +} + +export default ({ editor, onSendCallback }: Props) => { + function clickHandler() { + editor.setState({ isSending: true }) + onSendCallback() + } + + return ( + + {!editor.state.isSending && } + {editor.state.isSending && } + + ) +} diff --git a/src/pages/Editor/Editor/ToolBox/StickerBox.tsx b/src/pages/Editor/Editor/ToolBox/StickerBox.tsx new file mode 100644 index 0000000..65bff3f --- /dev/null +++ b/src/pages/Editor/Editor/ToolBox/StickerBox.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react' +import styled from 'styled-components' + +import { DialogTitle, DialogContent, Tabs, Tab } from '@material-ui/core' + +import { EditorContainer } from '../EditorContainer' + +const DialogTitleS = styled(DialogTitle)` + && { + padding: 12px; + padding-top: 0; + } +` + +const Img = styled.img` + max-width: 25%; + padding: 5px; +` + +const FlexDiv = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; +` + +interface Props { + editor: EditorContainer + handleClose: () => void +} + +type StickerType = 'ac' | 'tb' | 'ms' | 'em' + +const BaseUrl = 'https://www.cc98.org/static/images' + +// TODO: refactor with UBB +// tslint:disable-next-line +function getStickerReactNode(type: StickerType, handleFunc: Function) { + const stickerArr = [] + + const suffix = type === 'em' ? 'gif' : 'png' + let start = 1 + let end = 54 + + if (type === 'tb') { + end = 33 + } else if (type === 'em') { + start = 0 + end = 91 + } + + for (let i = start; i <= end; i++) { + const number = i < 10 ? `0${i}` : `${i}` + const url = type === 'ac' ? `${type}-mini/${number}` : `${type}-mini/${type}${number}` + + stickerArr.push( + + ) + } + + return stickerArr +} + +export default ({ editor, handleClose }: Props) => { + const [type, setType] = useState('ac') + + const handleChange = (_: React.ChangeEvent, value: StickerType) => { + setType(value) + } + + const handleClick = (stickerCode: string) => (_: React.MouseEvent) => { + editor.appendMainContent(`[${stickerCode}]`) + handleClose() + } + + const StickerArr = getStickerReactNode(type, handleClick) + + return ( + <> + + + + + + + + + + + {StickerArr} + + + ) +} diff --git a/src/pages/Editor/Editor/ToolBox/StickerBtn.tsx b/src/pages/Editor/Editor/ToolBox/StickerBtn.tsx new file mode 100644 index 0000000..9ebe6c8 --- /dev/null +++ b/src/pages/Editor/Editor/ToolBox/StickerBtn.tsx @@ -0,0 +1,32 @@ +import React, { useState } from 'react' + +import { EditorContainer } from '../EditorContainer' + +import { IconButton, Dialog } from '@material-ui/core' +import TagFacesIcon from '@material-ui/icons/TagFaces' + +import StickerBox from './StickerBox' + +interface Props { + editor: EditorContainer +} + +export default ({ editor }: Props) => { + const [open, setOpen] = useState(false) + const handleClose = () => { + setOpen(false) + } + + function clickHandler() { + setOpen(!open) + } + + return ( + + + + + + + ) +} diff --git a/src/pages/Editor/Editor/ToolBox/index.tsx b/src/pages/Editor/Editor/ToolBox/index.tsx new file mode 100644 index 0000000..e8d3be3 --- /dev/null +++ b/src/pages/Editor/Editor/ToolBox/index.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import styled from 'styled-components' + +import { EditorContainer } from '../EditorContainer' + +import PictureBtn from './PictureBtn' +import StickerBtn from './StickerBtn' +import ClearBtn from './ClearBtn' +import PreviewBtn from './PreviewBtn' +import SendBtn from './SendBtn' + +const WrapperDiv = styled.div` + display: flex; + justify-content: space-between; +` +const WrapperToolBox = styled.div` + display: flex; + flex-direction: column; +` +interface Props { + editor: EditorContainer + onSendCallback: () => void +} + +export default ({ editor, onSendCallback }: Props) => ( + + +
+ + +
+
+ + + +
+
+
+) diff --git a/src/pages/Editor/Editor/index.tsx b/src/pages/Editor/Editor/index.tsx new file mode 100644 index 0000000..9158599 --- /dev/null +++ b/src/pages/Editor/Editor/index.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import styled from 'styled-components' + +import useContainer from '@/hooks/useContainer' +import { EditorContainer } from './EditorContainer' + +import MainContent from './MainContent' +import Attachments from './Attachments' +import ToolBox from './ToolBox' + +const FixBottomDiv = styled.div` + position: fixed; + left: 8px; + right: 8px; + bottom: 12px; +` + +export { EditorContainer } + +interface Props { + editor: EditorContainer + onSendCallback: () => void +} + +const Editor: React.FunctionComponent = ({ editor, onSendCallback }) => { + useContainer(editor) + + return ( +
+ + + + + +
+ ) +} + +export default React.memo(Editor) diff --git a/src/pages/Editor/MetaInfo/MetaInfoContainer.ts b/src/pages/Editor/MetaInfo/MetaInfoContainer.ts new file mode 100644 index 0000000..be16a16 --- /dev/null +++ b/src/pages/Editor/MetaInfo/MetaInfoContainer.ts @@ -0,0 +1,50 @@ +import { Container } from '@/hooks/useContainer' + +interface State { + /** + * 标题 + */ + title: string + /** + * 帖子类型 + */ + type: number + /** + * tag 1 + */ + tag1?: number + /** + * tag 2 + */ + tag2?: number +} + +/** + * 帖子元信息 (title + type + tags) + */ +export class MetaInfoContainer extends Container { + constructor(init: State) { + super() + + this.state = init + } + + /** + * 设置标题 + */ + setTitle(title: string) { + this.setState({ title }) + } + + setType(type: number) { + this.setState({ type }) + } + + setTag1(tag: number) { + this.setState({ tag1: tag }) + } + + setTag2(tag: number) { + this.setState({ tag2: tag }) + } +} diff --git a/src/pages/Editor/MetaInfo/ScrollTag.tsx b/src/pages/Editor/MetaInfo/ScrollTag.tsx new file mode 100644 index 0000000..703b679 --- /dev/null +++ b/src/pages/Editor/MetaInfo/ScrollTag.tsx @@ -0,0 +1,32 @@ +import React, { useEffect } from 'react' + +import { Select, MenuItem } from '@material-ui/core' +import { ITag } from '@cc98/api' + +interface Props { + tags: ITag[] + value?: number + onChange: (tag: number) => void +} + +export default ({ tags, value, onChange }: Props) => { + useEffect(() => { + if (value === undefined) { + onChange(tags[0].id) + } + }, []) + + const handleSelect = (e: React.ChangeEvent) => { + onChange(parseInt(e.target.value, 10)) + } + + return ( + + ) +} diff --git a/src/pages/Editor/MetaInfo/SelectType.tsx b/src/pages/Editor/MetaInfo/SelectType.tsx new file mode 100644 index 0000000..bf4d77a --- /dev/null +++ b/src/pages/Editor/MetaInfo/SelectType.tsx @@ -0,0 +1,26 @@ +import React from 'react' + +import { MenuItem, Select } from '@material-ui/core' + +const PostType = [{ id: 0, name: '普通' }, { id: 1, name: '校园活动' }, { id: 2, name: '学术信息' }] + +interface Props { + value: number + onChange: (id: number) => void +} + +export default ({ value, onChange }: Props) => { + const handleSelect = (e: React.ChangeEvent) => { + onChange(parseInt(e.target.value, 10)) + } + + return ( + + ) +} diff --git a/src/pages/Editor/MetaInfo/index.tsx b/src/pages/Editor/MetaInfo/index.tsx new file mode 100644 index 0000000..11dd3c9 --- /dev/null +++ b/src/pages/Editor/MetaInfo/index.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import styled from 'styled-components' + +import useFetcher from '@/hooks/useFetcher' +import useContainer from '@/hooks/useContainer' +import { MetaInfoContainer } from './MetaInfoContainer' + +import { InputBase, FormLabel } from '@material-ui/core' + +import ScrollTag from './ScrollTag' +import SelectType from './SelectType' + +import { getBoardTags } from '@/services/board' + +const InputArea = styled(InputBase).attrs({ + fullWidth: true, +})` + && { + padding: 4px 8px; + border: 1.5px solid #ccc; + } +` + +export { MetaInfoContainer } + +const SelectDiv = styled.div` + display: flex; + justify-content: space-between; + margin-top: 12px; + margin-bottom: 8px; + padding-left: 4px; +` + +const TagSelectDiv = styled.div`` + +interface Props { + container: MetaInfoContainer + /** + * 版面 ID + */ + boardId: number +} + +export default ({ container, boardId }: Props) => { + useContainer(container) + + const [boardTags] = useFetcher(() => getBoardTags(boardId)) + + const onTitleChange = (event: React.ChangeEvent) => { + container.setTitle(event.target.value) + } + + if (boardTags === null) { + return null + } + + return ( + <> + +
+ 类型: + container.setType(type)} /> +
+ + + {boardTags.length !== 0 && 标签:} + {boardTags[0] && ( + container.setTag1(tag)} + /> + )} + {boardTags[1] && ( + container.setTag2(tag)} + /> + )} + +
+ + + + ) +} diff --git a/src/pages/Editor/index.tsx b/src/pages/Editor/index.tsx new file mode 100644 index 0000000..8d3373c --- /dev/null +++ b/src/pages/Editor/index.tsx @@ -0,0 +1,180 @@ +import React, { useRef, useMemo } from 'react' +import styled from 'styled-components' + +import MetaInfo, { MetaInfoContainer } from './MetaInfo' +import Editor, { EditorContainer } from './Editor' + +import useInit from './useInit' + +import { ITopicParams, IPostParams, postTopic, replyTopic, editorPost } from '@/services/editor' + +import { goback } from '@/utils/history' +import snackbar from '@/utils/snackbar' + +const WrapperDiv = styled.div` + margin: 8px 12px; +` + +/******************************** + * boardId - 发布帖子 + * topicId - 回复帖子 + * topicId & postId - 引用帖子 + * postId - 修改帖子 + ********************************/ + +export interface Props { + /** + * 版面 ID + */ + boardId?: string + /** + * 帖子 ID + */ + topicId?: string + /** + * 楼层 ID + */ + postId?: string +} + +export default (props: Props) => { + const init = useInit(props) + + const isContainerInit = useRef(false) + + interface MutableRefObject { + current: T + } + // 此处 @types/react 类型有误 + const editor = useRef(null) as MutableRefObject + const metaContainer = useRef(null) as MutableRefObject + + if (init === null) { + // init 还在获取中 + return null + } + + if (!isContainerInit.current) { + editor.current = new EditorContainer(init.editor.initContent) + metaContainer.current = new MetaInfoContainer(init.metaInfo) + + isContainerInit.current = true + } + + const onSendCallback = useMemo( + () => + chooseSendCallback(editor.current, metaContainer.current, props, init.boardId !== undefined), + [] + ) + + return ( + + {init.boardId && } + + + ) +} + +/** + * 选择合适的回调 + */ +function chooseSendCallback( + editor: EditorContainer, + metaInfo: MetaInfoContainer, + props: Props, + isEditorTopic: boolean +): () => void { + const { boardId, topicId, postId } = props + + const stopLoading = () => { + editor.setState({ isSending: false }) + } + + /** + * for test + */ + // return () => { + // setTimeout(() => { + // stopLoading() + // }, 2000) + // } + + // 发布帖子 + if (boardId) { + return () => { + const topicParams: ITopicParams = { + ...metaInfo.state, + content: editor.fullContent, + contentType: 0, + } + + postTopic(boardId, topicParams).then(res => + res + .fail(() => { + snackbar.error('发布失败') + stopLoading() + }) + .succeed(() => { + // TODO: 刷新帖子,下同 + snackbar.success('发布成功') + goback() + }) + ) + } + } + + // 回复帖子 + if (topicId) { + return () => { + const postParams: IPostParams = { + title: '', + content: editor.fullContent, + contentType: 0, + } + + replyTopic(topicId, postParams).then(res => + res + .fail(() => { + snackbar.error('回复失败') + stopLoading() + }) + .succeed(() => { + snackbar.success('回复成功') + goback() + }) + ) + } + } + + // 编辑帖子 + if (postId) { + return () => { + const params: ITopicParams | IPostParams = isEditorTopic + ? { + ...metaInfo.state, + content: editor.fullContent, + contentType: 0, + } + : { + title: '', + content: editor.fullContent, + contentType: 0, + } + + editorPost(postId, params).then(res => + res + .fail(() => { + snackbar.error('编辑失败') + stopLoading() + }) + .succeed(() => { + snackbar.success('编辑成功') + goback() + }) + ) + } + } + + // default callback + return () => undefined +} diff --git a/src/pages/Editor/useInit.ts b/src/pages/Editor/useInit.ts new file mode 100644 index 0000000..546e50c --- /dev/null +++ b/src/pages/Editor/useInit.ts @@ -0,0 +1,121 @@ +import { useState } from 'react' + +import { getOriginalPost } from '@/services/editor' +import { getTopicInfo } from '@/services/topic' + +import dayjs from 'dayjs' + +interface Init { + /** + * MetaInfo Container 初始值 + */ + metaInfo: { + title: string + type: number + tag1?: number + tag2?: number + } + /** + * Editor Container 初始值 + */ + editor: { + initContent: string + } + /** + * MetaInfo 的 props 之一 + * 同时 boardId 有值意味着是 发布/修改主题 + */ + boardId: number | undefined +} + +import { Props } from './index' + +/** + * 获取 editor 和 metaInfo 的初始值,返回 null 意味着 loading 中 + */ +export default function useInit(props: Props): Init | null { + const { boardId, topicId, postId } = props + const [ok, setOk] = useState(false) + + const [initContent, setInitContent] = useState('') + const [metaInfo, setMetaInfo] = useState({ + title: '', + type: 0, + }) + const [retBoardId, setRetBoardId] = useState(undefined) + + if (ok) { + return { + metaInfo, + editor: { + initContent, + }, + boardId: retBoardId, + } + } + + // 发帖 + if (boardId) { + setRetBoardId(parseInt(boardId, 10)) + setOk(true) + + return null + } + + // 回复帖子 + if (topicId && !postId) { + setOk(true) + + return null + } + + // 引用某楼层 + if (topicId && postId) { + getOriginalPost(postId).then(res => + res.fail().succeed(postInfo => { + const { floor, userName, time, topicId, content } = postInfo + const formatTime = dayjs(time).format('YYYY-MM-DD HH:mm') + setInitContent( + // tslint:disable-next-line + `[quote][b]以下是引用${floor}楼:用户${userName}在${formatTime}的发言:[color=blue][url=/topic/${topicId}#${floor}]>>查看原帖<<[/url][/color][/b]\n${content}[/quote]\n` + ) + setOk(true) + }) + ) + + return null + } + + // 编辑自己的帖子 + if (postId) { + getOriginalPost(postId).then(res => + res.fail().succeed(postInfo => { + setInitContent(postInfo.content) + if (postInfo.floor !== 1) { + setOk(true) + + return + } + + // 编辑主题 + setRetBoardId(postInfo.boardId) + getTopicInfo(postInfo.topicId).then(res => + res.fail().succeed(topicInfo => { + setMetaInfo({ + title: topicInfo.title, + type: topicInfo.type, + tag1: topicInfo.tag1, + tag2: topicInfo.tag2, + }) + + setOk(true) + }) + ) + }) + ) + + return null + } + + return null +} diff --git a/src/pages/Error/400.tsx b/src/pages/Error/400.tsx new file mode 100644 index 0000000..414e17a --- /dev/null +++ b/src/pages/Error/400.tsx @@ -0,0 +1,5 @@ +import React from 'react' + +import ErrorPage from './ErrorPage' + +export default () => diff --git a/src/pages/Error/401.tsx b/src/pages/Error/401.tsx index 0bd7a37..9bb247d 100644 --- a/src/pages/Error/401.tsx +++ b/src/pages/Error/401.tsx @@ -1,5 +1,5 @@ import React from 'react' -const Page401: React.SFC = () => <>401 +import ErrorPage from './ErrorPage' -export default Page401 +export default () => diff --git a/src/pages/Error/403.tsx b/src/pages/Error/403.tsx new file mode 100644 index 0000000..fc2010a --- /dev/null +++ b/src/pages/Error/403.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +import ErrorPage from './ErrorPage' + +export default () => ( + +) diff --git a/src/pages/Error/404.tsx b/src/pages/Error/404.tsx index d53a3f2..ace98aa 100644 --- a/src/pages/Error/404.tsx +++ b/src/pages/Error/404.tsx @@ -1,24 +1,5 @@ -import { css } from 'emotion' import React from 'react' -import img404 from '@/assets/404.png' +import ErrorPage from './ErrorPage' -import LayoutCenter from '@/components/LayoutCenter' - -const img = css` - width: 60%; - max-width: 600px; -` - -// https://github.com/reach/router/issues/44 -const goback = () => window.history.back() - -const Page404: React.SFC = () => ( - - 404 - -) - -export default Page404 - -// TODO: 彩蛋:戳樱桃 - “哥哥不要这样啦~” +export default () => diff --git a/src/pages/Error/500.tsx b/src/pages/Error/500.tsx new file mode 100644 index 0000000..bfe6e5d --- /dev/null +++ b/src/pages/Error/500.tsx @@ -0,0 +1,5 @@ +import React from 'react' + +import ErrorPage from './ErrorPage' + +export default () => diff --git a/src/pages/Error/ErrorImage.tsx b/src/pages/Error/ErrorImage.tsx new file mode 100644 index 0000000..7d5f2e9 --- /dev/null +++ b/src/pages/Error/ErrorImage.tsx @@ -0,0 +1,34 @@ +import React, { useState } from 'react' +import styled from 'styled-components' + +import img from '@/assets/error.png' +import imgOnTouch from '@/assets/error-on-touch.png' + +const Img = styled.img` + width: 60%; + max-width: 600px; +` +/** + * props for ErrorImage component + */ +interface Props { + /** + * status code + */ + status: string +} + +/** + * image for error page + */ +const ErrorImage: React.FunctionComponent = props => { + const [imgSrc, setImgSrc] = useState(img) + + const changeImage = () => { + imgSrc === img ? setImgSrc(imgOnTouch) : setImgSrc(img) + } + + return {props.status} +} + +export default ErrorImage diff --git a/src/pages/Error/ErrorPage.tsx b/src/pages/Error/ErrorPage.tsx new file mode 100644 index 0000000..81a1f0f --- /dev/null +++ b/src/pages/Error/ErrorPage.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import styled from 'styled-components' + +import ErrorImage from './ErrorImage' + +import { Typography } from '@material-ui/core' +import LayoutCenter from '@/components/LayoutCenter' + +const FlexDiv = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; +` + +interface Props { + /** + * 错误提示 + */ + errMessage: string + /** + * 补充提示 + */ + secondMessage?: string +} + +export default ({ errMessage, secondMessage }: Props) => ( + + + + {errMessage} + {secondMessage && {secondMessage}} + + +) diff --git a/src/pages/Error/index.tsx b/src/pages/Error/index.tsx new file mode 100644 index 0000000..e3e255b --- /dev/null +++ b/src/pages/Error/index.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { Router } from '@reach/router' +import { Route } from '@/router/Router' + +import Page400 from './400' +import Page401 from './401' +import Page403 from './403' +import Page404 from './404' +import Page500 from './500' + +export default () => ( + + + + + + + + +) diff --git a/src/pages/Help/About/index.tsx b/src/pages/Help/About/index.tsx new file mode 100644 index 0000000..d84b3d1 --- /dev/null +++ b/src/pages/Help/About/index.tsx @@ -0,0 +1,112 @@ +import React from 'react' +import styled from 'styled-components' + +import useFetcher from '@/hooks/useFetcher' + +import { navigate } from '@/utils/history' + +import { + Table, + TableRow, + TableBody, + TableCell, + Avatar, + CardHeader, + Divider, + Typography, +} from '@material-ui/core' + +import { getSiteInfo } from '@/services/global' + +const Title = styled(Typography).attrs({ + align: 'center', + variant: 'h6', +})` + && { + margin-top: 16px; + margin-bottom: 16px; + } +` + +const SiteInfo = () => { + const [info] = useFetcher(getSiteInfo) + + if (info === null) { + return null + } + + const rows = [ + { name: '今日帖数', data: info.todayCount }, + { name: '论坛总主题数', data: info.maxPostCount }, + { name: '论坛总回复数', data: info.postCount }, + { name: '总用户数', data: info.userCount }, + { name: '最新加入用户', data: info.lastUserName }, + ] + + return ( + <> + 论坛统计 + + + + + {rows.map(row => ( + + {row.name} + {row.data} + + ))} + +
+ + ) +} + +interface DevCardProps { + name: string + description: string + userId: number +} + +const CardHeaderS = styled(CardHeader)` + && { + width: 48%; + } +` + +const DevCard = ({ name, description, userId }: DevCardProps) => ( + } + title={name} + subheader={description} + onClick={() => navigate(`/user/${userId}`)} + /> +) + +const CardFlexDiv = styled.div` + display: flex; + flex-wrap: wrap; +` + +const DevTeam = () => ( + <> + 开发组 + + + + + + + + + + + +) + +export default () => ( + <> + + + +) diff --git a/src/pages/Help/DevTeam.tsx b/src/pages/Help/DevTeam.tsx new file mode 100644 index 0000000..72b2ef2 --- /dev/null +++ b/src/pages/Help/DevTeam.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import styled from 'styled-components' + +import { navigate } from '@/utils/history' + +import { Avatar, CardHeader, Divider, Typography } from '@material-ui/core' + +const Title = styled(Typography).attrs({ + align: 'center', + variant: 'h6', +})` + && { + margin-top: 16px; + margin-bottom: 16px; + } +` + +interface DevCardProps { + name: string + description: string + userId: number +} + +const CardHeaderS = styled(CardHeader)` + && { + width: 48%; + } +` + +const DevCard = ({ name, description, userId }: DevCardProps) => ( + } + title={name} + subheader={description} + onClick={() => navigate(`/user/${userId}`)} + /> +) + +const CardFlexDiv = styled.div` + display: flex; + flex-wrap: wrap; +` + +export default () => ( + <> + 开发组 + + + + + + + + + + + +) diff --git a/src/pages/Help/HowPWA.tsx b/src/pages/Help/HowPWA.tsx new file mode 100644 index 0000000..2264905 --- /dev/null +++ b/src/pages/Help/HowPWA.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import styled from 'styled-components' + +import pwa from '@/assets/pwa.jpg' + +const Img = styled.img` + width: 100%; +` +export default () => ( +
+ +
+) diff --git a/src/pages/Help/HowVPN.tsx b/src/pages/Help/HowVPN.tsx new file mode 100644 index 0000000..18779f7 --- /dev/null +++ b/src/pages/Help/HowVPN.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import styled from 'styled-components' + +import vpn from '@/assets/vpn.jpg' + +const Img = styled.img` + width: 100%; +` +export default () => ( +
+ +
+) diff --git a/src/pages/Help/SiteInfo.tsx b/src/pages/Help/SiteInfo.tsx new file mode 100644 index 0000000..806a203 --- /dev/null +++ b/src/pages/Help/SiteInfo.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import styled from 'styled-components' + +import useFetcher from '@/hooks/useFetcher' + +import { Table, TableRow, TableBody, TableCell, Divider, Typography } from '@material-ui/core' + +import { getSiteInfo } from '@/services/global' + +const Title = styled(Typography).attrs({ + align: 'center', + variant: 'h6', +})` + && { + margin-top: 16px; + margin-bottom: 16px; + } +` + +export default () => { + const [info] = useFetcher(getSiteInfo) + + if (info === null) { + return null + } + + const rows = [ + { name: '今日帖数', data: info.todayCount }, + { name: '论坛总主题数', data: info.maxPostCount }, + { name: '论坛总回复数', data: info.postCount }, + { name: '总用户数', data: info.userCount }, + { name: '最新加入用户', data: info.lastUserName }, + ] + + return ( + <> + 论坛统计 + + + + + {rows.map(row => ( + + {row.name} + {row.data} + + ))} + +
+ + ) +} diff --git a/src/pages/Help/index.tsx b/src/pages/Help/index.tsx new file mode 100644 index 0000000..8db3f2a --- /dev/null +++ b/src/pages/Help/index.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { Router } from '@reach/router' +import { Route } from '@/router/Router' + +import { MenuList, MenuItem, ListItemIcon, Typography } from '@material-ui/core' + +import NetworkCellIcon from '@material-ui/icons/NetworkCell' +import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward' +import BarChartIcon from '@material-ui/icons/BarChart' +import CopyrightIcon from '@material-ui/icons/Copyright' + +import { navigate } from '@/utils/history' + +import HowVPN from './HowVPN' +import HowPWA from './HowPWA' +import SiteInfo from './SiteInfo' +import DevTeam from './DevTeam' + +const Index = () => ( + + navigate('/help/VPN')}> + + + + 如何使用 RVPN 连接内网 + + navigate('/help/PWA')}> + + + + 如何将 PWA 安装到桌面 + + navigate('/help/siteInfo')}> + + + + 论坛统计 + + navigate('/help/devTeam')}> + + + + CC98 PWA 开发组 + + +) + +export default () => ( + + + + + + + +) diff --git a/src/pages/Home/Recommend.tsx b/src/pages/Home/Recommend.tsx new file mode 100644 index 0000000..b7abea1 --- /dev/null +++ b/src/pages/Home/Recommend.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { navigate } from '@/utils/history' +import styled from 'styled-components' + +import useFetcher from '@/hooks/useFetcher' + +import { List, ListItem, ListItemText, ListItemIcon, Divider, Avatar } from '@material-ui/core' + +import Event from '@material-ui/icons/Event' + +import { getHomeInfo } from '@/services/global' +import { notificationHandler } from '@/services/utils/errorHandler' +import { IRecommendationReading } from '@cc98/api' + +const AvatarS = styled(Avatar)` + && { + background-color: #999; + } +` + +const ListItemTextS = styled(ListItemText)` + && { + padding: 0; + } +` + +export default () => { + const [data] = useFetcher(getHomeInfo, { + fail: notificationHandler, + }) + + if (data === null) { + return null + } + + return ( + + + + + + + + + + {data.recommendationReading.map((info: IRecommendationReading) => ( + navigate(info.url)}> + + + + + + ))} + + ) +} diff --git a/src/pages/Home/config.ts b/src/pages/Home/config.ts deleted file mode 100644 index db26aab..0000000 --- a/src/pages/Home/config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import asuka from '@/assets/asuka.jpg' -import bg9 from '@/assets/bg9.jpg' - -const Time = parseInt(Date.now().toString(), 10) -export let Background = '' -export let HomeText = '' -switch (Time % 2) { - case 0: - Background = asuka - HomeText = 'Asuka の CC98' - break - case 1: - Background = bg9 - HomeText = '琪露诺 の CC98' - break -} diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index b7c1009..6f7c4fe 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -1,52 +1,10 @@ import React from 'react' -import { css } from 'emotion' -import global from '@/model/global' +import RecommendReadings from './Recommend' -import { Button, Typography } from '@material-ui/core' - -import LayoutCenter from '@/components/LayoutCenter' - -import { Background, HomeText } from './config' - -const img = css` - position: fixed; - width: 100%; - height: 100%; - background-image: url(${Background}); - background-size: cover; - opacity: 0.85; -` - -const button = css` - && { - border-color: rgba(255, 255, 255, 0.4); - transform: translateY(30px); - } -` - -const text = css` - && { - /* variant h6 */ - font-size: 1.25rem; - font-weight: normal; - color: #ddd; - } -` - -const Home: React.SFC = () => ( +const Home: React.FunctionComponent = () => ( <> -
- - - + ) diff --git a/src/pages/HotTopic/HotTopicItem.tsx b/src/pages/HotTopic/HotTopicItem.tsx index 54668aa..9bdb285 100644 --- a/src/pages/HotTopic/HotTopicItem.tsx +++ b/src/pages/HotTopic/HotTopicItem.tsx @@ -1,52 +1,32 @@ -import React from 'react' -import styled from 'react-emotion' +import React, { useState, useEffect } from 'react' +import { navigate } from '@/utils/history' + +import { TopicItem } from '@/components/TopicList/TopicListItem' import { IHotTopic } from '@cc98/api' -import { ListItem, ListItemText } from '@material-ui/core' -import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction' -import { StyleRules, withStyles } from '@material-ui/core/styles' -import { ClassNameMap } from '@material-ui/core/styles/withStyles' +import { getBoardNameById } from '@/services/board' interface Props { - info: IHotTopic - click?: (topicID: number) => void + /** + * 帖子信息 + */ + data: IHotTopic } -const styles: StyleRules = { - root: { - width: '100%', - }, - primary: { - fontSize: '0.875rem', - opacity: 0.54, - textAlign: 'right', - }, - secondary: { - textAlign: 'right', - }, -} +export default ({ data }: Props) => { + const [boardName, setBoardName] = useState('') -const Text = styled.span` - display: block; - max-width: 80%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -` + useEffect(() => { + getBoardNameById(data.boardId).then(boardName => setBoardName(boardName)) + }, []) -export default withStyles(styles)(({ info, click, classes }: Props & { classes: ClassNameMap }) => ( - click && click(info.id)}> - {info.title}} - secondary={info.authorName ? info.authorName : '匿名'} + return ( + navigate(`/topic/${data.id}`)} + title={data.title} + subtitle={data.authorName ? data.authorName : '[匿名]'} + info1={boardName} + info2={`回贴:${data.replyCount}`} /> - - - - -)) + ) +} diff --git a/src/pages/HotTopic/HotTopicList.tsx b/src/pages/HotTopic/HotTopicList.tsx deleted file mode 100644 index afe93b4..0000000 --- a/src/pages/HotTopic/HotTopicList.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react' -import { navigate } from '@reach/router' - -import { List } from '@material-ui/core' - -import LoadingCircle from '@/components/LoadingCircle' -import HotTopicItem from './HotTopicItem' - -import { GET } from '@/utils/fetch' -import { IHotTopic } from '@cc98/api' - -interface State { - hotTopics: IHotTopic[] - isLoading: boolean -} - -class TopicList extends React.Component<{}, State> { - state: State = { - hotTopics: [], - isLoading: false, - } - - async componentDidMount() { - this.setState({ - isLoading: true, - }) - - const res = await GET('topic/hot') - - res.fail().succeed(hotTopics => { - this.setState({ - hotTopics, - isLoading: false, - }) - }) - } - - jump2Post(topicID: number) { - navigate(`/topic/${topicID}`) - } - - render() { - const { hotTopics, isLoading } = this.state - if (isLoading) { - return - } - - return ( - - {hotTopics.map(info => ( - - ))} - - ) - } -} - -export default TopicList diff --git a/src/pages/HotTopic/index.tsx b/src/pages/HotTopic/index.tsx index f12a3ea..7f39d1c 100644 --- a/src/pages/HotTopic/index.tsx +++ b/src/pages/HotTopic/index.tsx @@ -1,7 +1,70 @@ -import React from 'react' +import React, { useState } from 'react' -import HotTopicList from './HotTopicList' +import useFetcher from '@/hooks/useFetcher' +import { FinTopicList } from '@/components/TopicList' -const HotTopic: React.SFC = () => +import { List, Tab, Tabs } from '@material-ui/core' -export default HotTopic +import LoadingCircle from '@/components/LoadingCircle' +import HotTopicItem from './HotTopicItem' + +import { + getHotTopics, + getWeeklyHotTopics, + getMonthlyHotTopics, + getHistoryHotTopics, +} from '@/services/topic' +import { notificationHandler } from '@/services/utils/errorHandler' + +interface Props { + service: typeof getHotTopics +} + +export const HotTopicList: React.FunctionComponent = ({ service }) => { + const [topics] = useFetcher(service, { + fail: notificationHandler, + }) + + if (topics === null) { + return + } + + return ( + + {topics.map(data => ( + + ))} + + ) +} + +export default () => { + const [current, setCurrent] = useState('day') + + const handleChange = (_: React.ChangeEvent, value: string) => { + setCurrent(value) + } + + return ( + <> + + + + + + + + {current === 'day' && } + + {current === 'week' && } + {current === 'month' && } + {current === 'history' && } + + ) +} diff --git a/src/pages/LogIn/LogInForm.tsx b/src/pages/LogIn/LogInForm.tsx index db605c1..0c56bfc 100644 --- a/src/pages/LogIn/LogInForm.tsx +++ b/src/pages/LogIn/LogInForm.tsx @@ -1,6 +1,6 @@ -import React from 'react' -import { navigate } from '@reach/router' -import { css } from 'emotion' +import React, { useState } from 'react' +import { navigate } from '@/utils/history' +import styled from 'styled-components' import { Button, @@ -12,22 +12,24 @@ import { Typography, } from '@material-ui/core' -import global from '@/model/global' +import userInstance from '@/containers/user' + +import { loginHandler } from '@/services/utils/errorHandler' import snowball from '@/assets/snowball.png' -const root = css` +const WrapperDiv = styled.div` display: flex; flex-direction: column; align-items: center; ` -const snowBallImg = css` +const SnowballImg = styled.img` width: 100px; margin-bottom: 30px; ` -const form = css` +const FormDiv = styled.div` display: flex; flex-direction: column; align-items: center; @@ -36,12 +38,22 @@ const form = css` height: 105px; ` -const button = css` - margin-top: 35px; +const LogInButton = styled(Button).attrs({ + variant: 'contained', + color: 'primary', +})` + && { + margin-top: 35px; + } ` -const buttonProgress = css` - margin-left: 15px; +const ButtonProgress = styled(CircularProgress).attrs({ + size: 20, + color: 'secondary', +})` + && { + margin-left: 15px; + } ` interface FormField { @@ -49,99 +61,86 @@ interface FormField { password: string } -interface State { - formField: FormField +interface LogInState { loading: boolean logInFail: boolean } -class LogIn extends React.Component<{}, State> { - state: State = { - formField: { - username: '', - password: '', - }, +const LogIn: React.FunctionComponent = () => { + const [formField, setFormField] = useState({ + username: '', + password: '', + }) + const [logInState, setLogInState] = useState({ loading: false, logInFail: false, - } + }) - handleChange = (field: keyof FormField) => (event: React.ChangeEvent) => { - this.setState({ - formField: { - ...this.state.formField, - [field]: event.target.value, - }, + const handleChange = (field: keyof FormField) => (event: React.ChangeEvent) => { + setFormField({ + ...formField, + [field]: event.target.value, }) } - logIn = async () => { - const { - formField: { username, password }, - } = this.state + const logIn = async () => { + const { username, password } = formField - this.setState({ + setLogInState({ loading: true, logInFail: false, }) - const token = await global.LOG_IN(username, password) + const token = await userInstance.LOG_IN(username, password) token - .fail(() => { + .fail(err => { setTimeout(() => { - this.setState({ + setLogInState({ loading: false, logInFail: true, }) // tslint:disable-next-line:align }, 2000) - // TODO: 错误提示 + loginHandler(err) }) .succeed(_ => { setTimeout(() => navigate('/'), 1500) }) } - render() { - const { formField, loading, logInFail } = this.state - - return ( -
- - - 登录 - -
- - 用户名 - - - - 密码 - - -
- -
- -
-
- ) - } + const { logInFail, loading } = logInState + + return ( + + + + 登录 + + + + 用户名 + + + + 密码 + + + + + + {logInFail ? '重试' : '登录'} + {loading && } + + + ) } export default LogIn diff --git a/src/pages/LogIn/index.tsx b/src/pages/LogIn/index.tsx index 5ec95d3..73bafeb 100644 --- a/src/pages/LogIn/index.tsx +++ b/src/pages/LogIn/index.tsx @@ -4,7 +4,7 @@ import LayoutCenter from '@/components/LayoutCenter' import LogInForm from './LogInForm' -const LogIn: React.SFC = () => ( +const LogIn: React.FunctionComponent = () => ( diff --git a/src/pages/Message/Detail.tsx b/src/pages/Message/Detail.tsx index 24bc742..5ae2639 100644 --- a/src/pages/Message/Detail.tsx +++ b/src/pages/Message/Detail.tsx @@ -1,19 +1,25 @@ -/** - * @author dongyansong - * @date 2018-10-26 - */ import React from 'react' -import { Subscribe } from '@cc98/state' +import styled from 'styled-components' -import Editor from '@/components/Editor' +import useInfList from '@/hooks/useInfList' import InfiniteList from '@/components/InfiniteList' -import store, { Detail } from '@/pages/Message/model/detail' -import { List, RootRef } from '@material-ui/core' -import Paper from '@material-ui/core/Paper' +import { List } from '@material-ui/core' import DetailItem from './components/DetailItem' +import { getMessageContent } from '@/services/message' + +const ListS = styled(List)` + && { + width: 100%; + position: absolute; + top: 56px; + bottom: 80px; + padding: 8px 0; + } +` + interface Props { /** * 联系人id (from url) @@ -22,40 +28,30 @@ interface Props { } /** - * @description 私信 会话列表 - * @author dongyansong + * 私信-会话列表 */ export default ({ id }: Props) => { - const list = React.useRef() - - React.useEffect(() => { - store - .init(parseInt(id, 10)) - .then(() => list.current && window.scrollTo(0, list.current.scrollHeight)) - }, []) + const service = (from: number) => getMessageContent(id, from, 10) + const [list, state, callback] = useInfList(service, { + step: 10, + }) + const { isLoading, isEnd } = state return ( - - {({ state: { messages, isEnd, isLoading } }: Detail) => ( - <> - - - - - {messages[id] && - messages[id]!.map(item => )} - - - - - - - )} - + <> + + + {list.map(item => ( + + ))} + + + ) } diff --git a/src/pages/Message/List.tsx b/src/pages/Message/List.tsx index 5eb2989..1aa90f8 100644 --- a/src/pages/Message/List.tsx +++ b/src/pages/Message/List.tsx @@ -1,37 +1,28 @@ -/** - * @author dongyansong - * @date 2018-10-23 - */ import React from 'react' -import { Subscribe } from '@cc98/state' +import useInfList from '@/hooks/useInfList' import InfiniteList from '@/components/InfiniteList' -import MessageStore from '@/pages/Message/model/recent' -import { List, Paper } from '@material-ui/core' +import { List } from '@material-ui/core' import ListItem from './components/ListItem' +import { getRecentMessage } from '@/services/message' + /** - * @description 私信 联系人列表 - * @author dongyansong + * 私信-联系人列表 */ -export default () => ( - - {({ state: { recentList, recentListEnd, recentListLoading }, getRecentList }: MessageStore) => ( - - - - {recentList.map(item => ( - - ))} - - - - )} - -) +export default () => { + const [recentList, state, callback] = useInfList(getRecentMessage) + const { isEnd, isLoading } = state + + return ( + + + {recentList.map(item => ( + + ))} + + + ) +} diff --git a/src/pages/Message/components/DetailItem.tsx b/src/pages/Message/components/DetailItem.tsx index e7aa6b7..a1a229d 100644 --- a/src/pages/Message/components/DetailItem.tsx +++ b/src/pages/Message/components/DetailItem.tsx @@ -1,21 +1,24 @@ -/** - * @author dongyansong - * @date 2018-10-26 - */ import React from 'react' -import { Subscribe } from '@cc98/state' -import { IMessageContent } from '@cc98/api' -import dayjs from 'dayjs' -import styled, { css } from 'react-emotion' +import styled from 'styled-components' import { Avatar, ListItem, ListItemAvatar, ListItemText } from '@material-ui/core' -import global, { GlobalContainer } from '@/model/global' -import user, { UserInfoStore } from '@/model/user' +import useFetcher from '@/hooks/useFetcher' +import useContainer from '@/hooks/useContainer' +import userInstace from '@/containers/user' + +import { IMessageContent, IUser } from '@cc98/api' + +import { getUserInfoById } from '@/services/user' + +import dayjs from 'dayjs' +import { navigate } from '@/utils/history' -import avatar from '@/assets/9.png' +const ListItemS = styled(ListItem)` + flex-shrink: 0; +` -const AvatarClass = css` +const ListItemAvatarS = styled(ListItemAvatar)` align-self: flex-start; ` @@ -47,7 +50,7 @@ const MessageContentLeft = styled(MessageContent)` border-width: 0.5em 0.5em 0.5em 0; border-color: transparent; border-right-color: #eee; - left: -0.5em; + left: -0.4em; position: absolute; top: 1em; } @@ -60,14 +63,14 @@ const MessageContentRight = styled(MessageContent)` border-width: 0.5em 0 0.5em 0.5em; border-color: transparent; border-left-color: #eee; - right: -0.5em; + right: -0.4em; position: absolute; top: 1em; } ` const MessageDate = styled.span<{ right?: boolean }>` - color: #666; + color: #aaa; font-size: 0.7em; align-self: ${props => (props.right ? 'flex-end' : '')}; ` @@ -77,37 +80,39 @@ interface Props { } // TODO: 消息气泡 -const renderItem = (message: IMessageContent, userAvatar = avatar, isCurrSend: boolean) => +const renderItem = (message: IMessageContent, userInfo: IUser, isCurrSend: boolean) => !isCurrSend ? ( - - - - + + + navigate(`/user/${userInfo.id}`)} /> + {message.content} {dayjs(message.time).format('YYYY-MM-DD HH:mm:ss')} - + ) : ( - + {message.content} {dayjs(message.time).format('YYYY-MM-DD HH:mm:ss')} - - - - + + + + ) -export default ({ message }: Props) => ( - - {({ state }: UserInfoStore, { state: { myInfo } }: GlobalContainer) => - renderItem( - message, - state[message.senderId] && state[message.senderId].portraitUrl, - !!myInfo && myInfo.id === message.senderId - )} - -) +export default ({ message }: Props) => { + const { + state: { myInfo }, + } = useContainer(userInstace) + + const [userInfo] = useFetcher(() => getUserInfoById(message.senderId)) + if (userInfo === null || myInfo === null) { + return null + } + + return renderItem(message, userInfo, myInfo.id === message.senderId) +} diff --git a/src/pages/Message/components/ListItem.tsx b/src/pages/Message/components/ListItem.tsx index f84826a..f600da9 100644 --- a/src/pages/Message/components/ListItem.tsx +++ b/src/pages/Message/components/ListItem.tsx @@ -1,12 +1,8 @@ -/** - * @author dongyansong - * @date 2018-10-26 - */ import React from 'react' -import styled from 'react-emotion' -import { navigate } from '@reach/router' -import { Subscribe } from '@cc98/state' +import styled from 'styled-components' +import { navigate } from '@/utils/history' import { IRecentMessage } from '@cc98/api' + import dayjs from 'dayjs' import { @@ -17,9 +13,9 @@ import { ListItemText, } from '@material-ui/core' -import store, { UserInfoStore } from '@/model/user' +import useFetcher from '@/hooks/useFetcher' -import avatar from '@/assets/9.png' +import { getUserInfoById } from '@/services/user' const Text = styled.span` display: block; @@ -35,25 +31,22 @@ interface Props { const navigateToDetail = (userId: number) => navigate(`/messageDetail/${userId}`) -const renderItem = (message: IRecentMessage, username = '', userAvatar = avatar) => ( - navigateToDetail(message.userId)}> - - - - {message.lastContent}} /> - - - - -) - -export default ({ message }: Props) => ( - - {({ state }: UserInfoStore) => - renderItem( - message, - state[message.userId] && state[message.userId].name, - state[message.userId] && state[message.userId].portraitUrl - )} - -) +export default ({ message }: Props) => { + const [userInfo] = useFetcher(() => getUserInfoById(message.userId)) + if (userInfo === null) { + return null + } + const { name, portraitUrl } = userInfo + + return ( + navigateToDetail(message.userId)}> + + + + {message.lastContent}} /> + + + + + ) +} diff --git a/src/pages/Message/model/detail.ts b/src/pages/Message/model/detail.ts deleted file mode 100644 index 050248a..0000000 --- a/src/pages/Message/model/detail.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * @author dongyansong - * @date 2018-10-26 - */ -import { GET, POST } from '@/utils/fetch' -import { IMessageContent } from '@cc98/api' -import { Container } from '@cc98/state' - -import reverse from 'lodash-es/reverse' - -import global from '@/model/global' -import user from '@/model/user' - -interface IMap { - [key: string]: T -} - -interface State { - messages: IMap - isEnd: IMap - isLoading: boolean - id: number -} - -let messageId = -1 - -export class Detail extends Container { - state: State = { - messages: {}, - isEnd: {}, - isLoading: true, - id: -1, - } - - init = async (id: number) => { - if (this.state.messages[id]) { - this.put(state => { - state.id = id - }) - - return - } - this.put(state => (state.isLoading = true)) - - const res = await GET(`message/user/${id}`, { - params: { - from: '0', - size: '20', - }, - }) - - res.fail().succeed(data => { - this.put(state => { - state.messages[id] = reverse(data) - state.isLoading = false - state.id = id - state.isEnd[id] = data.length < 20 - }) - user.getInfo(id) - }) - } - - getList = async () => { - this.put(state => (state.isLoading = true)) - - const res = await GET(`message/user/${this.state.id}`, { - params: { - from: `${(this.state.messages[this.state.id] || []).length}`, - size: '20', - }, - }) - - res.fail().succeed(data => { - this.put(state => { - state.messages[this.state.id] = [...reverse(data), ...(state.messages[this.state.id] || [])] - state.isLoading = false - state.isEnd[this.state.id] = data.length < 20 - }) - }) - } - - getNewMessage = async () => { - this.put(state => (state.isLoading = true)) - - const res = await GET(`message/user/${this.state.id}`, { - params: { - from: '0', - size: '1', - }, - }) - - res.fail().succeed(messages => { - this.put(state => { - if (messages[0] && this.state.messages[this.state.id]) { - state.messages[this.state.id]!.push(messages[0]) - } else { - state.messages[this.state.id] = messages - } - }) - }) - } - - sendMessage = async (content: string) => { - const res = await POST('message', { - params: { - content, - receiverId: this.state.id, - }, - }) - - res.fail().succeed(() => { - this.put(state => { - const newMessage = { - content, - id: messageId, - senderId: global.state.myInfo!.id, - receiverId: this.state.id, - time: new Date(Date.now()).toUTCString(), - isRead: true, - } - if (this.state.messages[this.state.id]) { - state.messages[this.state.id]!.push(newMessage) - } else { - state.messages[this.state.id] = [newMessage] - } - messageId -= 1 - }) - }) - } -} - -export default new Detail() diff --git a/src/pages/Message/model/recent.ts b/src/pages/Message/model/recent.ts deleted file mode 100644 index 868d070..0000000 --- a/src/pages/Message/model/recent.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @author dongyansong - * @date 2018-10-26 - */ -import { GET } from '@/utils/fetch' -import { IRecentMessage } from '@cc98/api' -import { Container } from '@cc98/state' -import user from '@/model/user' - -interface State { - recentList: IRecentMessage[] - recentListEnd: boolean - recentListLoading: boolean -} - -export default class MessageStore extends Container { - state: State = { - recentList: [], - recentListEnd: false, - recentListLoading: true, - } - - constructor() { - super() - - this.initRecentList() - } - - initRecentList = async () => { - this.put(state => (state.recentListLoading = true)) - const res = await GET('message/recent-contact-users?from=0&size=20') - res.fail().succeed(data => { - this.put(state => { - state.recentList = data - state.recentListLoading = false - if (data.length < 20) state.recentListEnd = true - }) - user.getInfos(data.map(item => item.userId)) - }) - } - - getRecentList = async () => { - this.put(state => (state.recentListLoading = true)) - const res = await GET( - `message/recent-contact-users?from=${this.state.recentList.length}&size=20` - ) - res.fail().succeed(data => { - this.saveRecentList(data) - user.getInfos(data.map(item => item.userId)) - }) - } - - private saveRecentList(data: IRecentMessage[]): void { - this.put(state => { - state.recentList.concat(data) - state.recentListLoading = false - if (data.length < 20) state.recentListEnd = true - }) - } -} diff --git a/src/pages/MyFollow/Follow.tsx b/src/pages/MyFollow/Follow.tsx deleted file mode 100644 index 0d018a6..0000000 --- a/src/pages/MyFollow/Follow.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React from 'react' -import { css } from 'emotion' - -import { TopicInfoStore } from '@/model/topic' - -import TopicItem from '@/components/TopicItem' -import InfiniteList from '@/components/InfiniteList' - -import { List, Paper, Tab, Tabs } from '@material-ui/core' - -import { Theme, withStyles } from '@material-ui/core/styles' -import { ClassNameMap } from '@material-ui/core/styles/withStyles' -import { GET } from '@/utils/fetch' -import { IBaseBoard, ITopic } from '@cc98/api' -import getBoardName from '@/services/getBoardName' -import Topic from '../Topic/Topic'; - -interface Props { - boards: IBaseBoard[] - classes: ClassNameMap - topicInstance: TopicInfoStore -} -interface State { - isLoading: boolean - isEnd: boolean - b_topics: ITopic[] - b_from: number - u_topics: ITopic[] - u_from: number - current: string -} - -const indexStyle = css` - && { - min-height: 90vh; - } -` - -const styles = (theme: Theme) => ({ - root: { - color: theme.palette.primary.main, - }, -}) - -export default withStyles(styles)( - class extends React.Component { - state: State = { - isLoading: false, - isEnd: false, - b_topics: [], - b_from: 0, - u_topics: [], - u_from: 0, - current: 'board', - } - - componentDidMount() { - if (this.state.current === 'board') { - this.getFollowBoardTopics() - } else { - this.getFolloweeTopics() - } - } - - changeFocus = () => { - if (this.state.current === 'board') { - this.getFolloweeTopics() - } else { - this.getFollowBoardTopics() - } - this.setState({ - current: this.state.current === 'board' ? 'user' : 'board', - }) - } - - getFollowBoardTopics = async () => { - const { b_from } = this.state - const { boards } = this.props - this.setState({ - isLoading: true, - }) - - const topicsTry = await GET(`me/custom-board/topic?from=${b_from}&size=20`) - topicsTry.map(async topicList => { - // tslint:disable-next-line:prefer-array-literal - topicList.map(topic => (topic.boardName = getBoardName(boards, topic.boardId))) - this.setState({ - b_topics: this.state.b_topics.concat(topicList), - b_from: b_from + topicList.length, - isLoading: false, - isEnd: topicList.length !== 20, - }) - }) - } - - getFolloweeTopics = async () => { - const { u_from } = this.state - const { boards } = this.props - this.setState({ - isLoading: true, - }) - - const topicsTry = await GET(`me/followee/topic?from=${u_from}&size=20`) - topicsTry.map(async topicList => { - // tslint:disable-next-line:prefer-array-literal - topicList.map(topic => (topic.boardName = getBoardName(boards, topic.boardId))) - this.setState({ - u_topics: this.state.u_topics.concat(topicList), - u_from: u_from + topicList.length, - isLoading: false, - isEnd: topicList.length !== 20, - }) - }) - } - - render() { - const { isLoading, isEnd, b_topics, u_topics, current } = this.state - const { classes } = this.props - const topics = current === 'board' ? b_topics : u_topics - - return ( -
- - - - - - - - - {topics.map(topic => ( - - ))} - - - -
- ) - } - } -) diff --git a/src/pages/MyFollow/index.tsx b/src/pages/MyFollow/index.tsx index 47edb19..1404bfd 100644 --- a/src/pages/MyFollow/index.tsx +++ b/src/pages/MyFollow/index.tsx @@ -1,33 +1,33 @@ -import React from 'react' +import React, { useState } from 'react' -import { Subscribe } from '@cc98/state' +import { InfTopicList } from '@/components/TopicList' -import { BoardInfoStore } from '@/model/board' -import { TopicInfoStore } from '@/model/topic' +import { Tab, Tabs } from '@material-ui/core' -import Component from './Follow' +import { getFollowBoardsTopics, getFollowUsersTopics } from '@/services/topic' -interface State { - topicInstance: TopicInfoStore -} -export default class extends React.Component<{}, State> { - state: State = { - topicInstance: new TopicInfoStore(), - } +export default () => { + const [current, setCurrent] = useState('board') - render() { - const { topicInstance } = this.state + const handleChange = (_: React.ChangeEvent, value: string) => { + setCurrent(value) + } - return ( - + - { - (data: BoardInfoStore) => data.state.boardData.length !== 0 ? - : null - } - - ) + + + - } + {current === 'board' && } + {current === 'user' && } + + ) } diff --git a/src/pages/NewTopic/TopicList.tsx b/src/pages/NewTopic/TopicList.tsx deleted file mode 100644 index 884aef1..0000000 --- a/src/pages/NewTopic/TopicList.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react' -import { navigate } from '@reach/router' - -import { TopicInfoStore } from '@/model/topic'; - -import InfiniteList from '@/components/InfiniteList' - -import { List, Paper } from '@material-ui/core' - -import TopicItem from '@/components/TopicItem' - -import { GET } from '@/utils/fetch' -import { IBaseBoard, ITopic } from '@cc98/api' -import getBoardName from '@/services/getBoardName' - -interface Props { - boards: IBaseBoard[] - topicInstance: TopicInfoStore -} - -export default (props: Props) => { - - const { topicInstance, boards } = props - const { isLoading, isEnd, topics } = topicInstance.state - const { getTopics } = topicInstance - - return ( - - { getTopics(null, 'newtopic') }} - > - - {topics.map((info: ITopic) => { - const boardName = getBoardName(boards, info.boardId) - const topic = { boardName, ...info } - - return ( - - ) - })} - - - - ) -} diff --git a/src/pages/NewTopic/index.tsx b/src/pages/NewTopic/index.tsx index 1af5b77..24aaa27 100644 --- a/src/pages/NewTopic/index.tsx +++ b/src/pages/NewTopic/index.tsx @@ -1,32 +1,7 @@ import React from 'react' -import { Subscribe } from '@cc98/state' +import { InfTopicList } from '@/components/TopicList' -import { BoardInfoStore } from '@/model/board' -import { TopicInfoStore } from '@/model/topic' +import { getNewTopics } from '@/services/topic' -import TopicList from './TopicList' - -interface State { - topicInstance: TopicInfoStore, -} -export default class extends React.Component<{}, State> { - state: State = { - topicInstance: new TopicInfoStore(), - } - - render() { - const { topicInstance } = this.state - - return ( - - {(b: BoardInfoStore) => { - if (!topicInstance.state.searchMes) { - topicInstance.init(null, 'newtopic') - } - - return - }} - ) - } -} +export default () => diff --git a/src/pages/Search/SearchInput.tsx b/src/pages/Search/SearchInput.tsx new file mode 100644 index 0000000..6fd56a6 --- /dev/null +++ b/src/pages/Search/SearchInput.tsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react' +import styled from 'styled-components' + +import { TextField, IconButton } from '@material-ui/core' + +import SearchIcon from '@material-ui/icons/Search' + +const SearchDiv = styled.div` + display: flex; + align-items: center; + width: 100%; + padding: 10px 10px 10px 25px; +` + +interface Props { + onSearch: (value: string) => void +} + +const SearchInput: React.FunctionComponent = ({ onSearch }) => { + const [value, setValue] = useState('') + + const onChange = (e: React.ChangeEvent) => { + setValue(e.target.value) + } + + return ( + + + onSearch(value)}> + + + + ) +} + +export default SearchInput diff --git a/src/pages/Search/index.tsx b/src/pages/Search/index.tsx index 20eab9f..f454fb0 100644 --- a/src/pages/Search/index.tsx +++ b/src/pages/Search/index.tsx @@ -1,97 +1,29 @@ -import React from 'react' -import { css } from 'react-emotion' - -import { Subscribe } from '@cc98/state' -import { TopicInfoStore } from '@/model/topic' - -import { TextField, Button, List, Paper } from '@material-ui/core' - -import InfiniteList from '@/components/InfiniteList' -import TopicItem from '@/components/TopicItem' - -const root = css` - display: flex; - flex-direction: column; - width: 100%; -` - -const searchInput = css` - display: flex; - align-items: center; - height: 70px; -` -interface State { - searchTerm: string - view: boolean - topicInstance: TopicInfoStore -} - -export default class extends React.Component<{}, State> { - state: State = { - searchTerm: '', - view: false, - topicInstance: new TopicInfoStore(), - } - - searchUpdated = (event: React.ChangeEvent) => { - this.setState({ searchTerm: event.target.value }) - } - - render() { - const { searchTerm, topicInstance } = this.state - - return ( - - {() => { - const { getTopics, reset, init } = topicInstance - const { isLoading, isEnd, topics, searchMes } = topicInstance.state - if (!searchMes) { - init(null, 'search') - } - - return ( -
-
- -
- - {this.state.view && ( - - { - getTopics(null, 'search', this.state.searchTerm) - }} - > - - {topics.map(info => ( - - ))} - - - - )} -
- ) - }} -
- ) - } +import React, { useState } from 'react' + +import { InfTopicList } from '@/components/TopicList' +import SearchInput from './SearchInput' + +import { searchTopics } from '@/services/topic' + +import { throttle } from 'lodash-es' + +export default () => { + const [searchTerm, setSearchTerm] = useState('') + + const onSearch = throttle((value: string) => { + setSearchTerm(value) + }, 1000 * 10) + + return ( + <> + + {searchTerm && ( + searchTopics(searchTerm, from)} + place="search" + /> + )} + + ) } diff --git a/src/pages/Setting/Cache.tsx b/src/pages/Setting/Cache.tsx new file mode 100644 index 0000000..052ebaa --- /dev/null +++ b/src/pages/Setting/Cache.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { ListItem, TextField, MenuItem, ListItemText, InputAdornment } from '@material-ui/core' + +import useContainer from '@/hooks/useContainer' +import settingInstance from '@/containers/setting' + +const ranges = [ + { label: '1', value: 1 }, + { label: '2', value: 2 }, + { label: '3', value: 3 }, + { label: '4', value: 4 }, + { label: '5', value: 5 }, +] + +export default () => { + const { + state: { routerCacheSize }, + CHANGE_CACHE, + } = useContainer(settingInstance) + + const handleChange = (event: React.ChangeEvent) => { + CHANGE_CACHE(parseInt(event.target.value, 10)) + } + + return ( + + + 页, + }} + value={routerCacheSize} + onChange={handleChange} + > + {ranges.map(option => ( + + {option.label} + + ))} + + + ) +} diff --git a/src/pages/Setting/Proxy.tsx b/src/pages/Setting/Proxy.tsx index 7f67915..aacf2c0 100644 --- a/src/pages/Setting/Proxy.tsx +++ b/src/pages/Setting/Proxy.tsx @@ -1,42 +1,25 @@ -/** - * @author Dearkano - * @date 2018-11-10 - */ -import React, { useState } from 'react' +import React from 'react' -import { Subscribe } from '@cc98/state' -import global from '@/model/global' +import useContainer from '@/hooks/useContainer' +import settingInstance from '@/containers/setting' +import userInstance from '@/containers/user' import { ListItem, ListItemText, Switch } from '@material-ui/core' -import WhiteList from '../../config/proxy' + +import ProxyList from '@/config/proxy' export default () => { - const [state, setState] = useState(Boolean(localStorage.getItem('proxy'))) - function changeProxy() { - if (localStorage.getItem('proxy')) { - localStorage.removeItem('proxy') - setState(false) - } else { - localStorage.setItem('proxy', '233333') - setState(true) - } - } + const { state, TOGGLE_PROXY } = useContainer(settingInstance) + const { + state: { myInfo }, + } = useContainer(userInstance) - return ( - - {() => { - const name = global.state.myInfo ? global.state.myInfo.name : '' - if (WhiteList.indexOf(name) !== -1) { - return ( - changeProxy()}> - - - - ) - } + const isDev = myInfo !== null && ProxyList.indexOf(myInfo.name) !== -1 - return null - }} - + return ( + + + + ) } diff --git a/src/pages/Setting/Signalr.tsx b/src/pages/Setting/Signalr.tsx index ca8cf62..6bc6bff 100644 --- a/src/pages/Setting/Signalr.tsx +++ b/src/pages/Setting/Signalr.tsx @@ -1,20 +1,17 @@ -/** - * @author dongyansong - * @date 2018-10-29 - */ -import { Subscribe } from '@cc98/state' -import { ListItem, ListItemText, Switch } from '@material-ui/core' import React from 'react' -import store, { Store } from '@/model/signalr' +import useContainer from '@/hooks/useContainer' +import settingInstance from '@/containers/setting' + +import { ListItem, ListItemText, Switch } from '@material-ui/core' + +export default () => { + const { state, TOGGLE_SIGNALR } = useContainer(settingInstance) -export default () => ( - - {({ state: { shouldUseSignalr }, setShouldUseSignalr }: Store) => ( - setShouldUseSignalr(!shouldUseSignalr)}> - - - - )} - -) + return ( + + + + + ) +} diff --git a/src/pages/Setting/Theme.tsx b/src/pages/Setting/Theme.tsx index f96ab9f..5aaf8ba 100644 --- a/src/pages/Setting/Theme.tsx +++ b/src/pages/Setting/Theme.tsx @@ -1,17 +1,17 @@ import React from 'react' -import { Subscribe } from '@cc98/state' +import useContainer from '@/hooks/useContainer' +import settingInstance from '@/containers/setting' + import { ListItem, ListItemText, Switch } from '@material-ui/core' -import global, { GlobalContainer } from '@/model/global' +export default () => { + const { state, TOGGLE_THEME } = useContainer(settingInstance) -export default () => ( - - {({ state: { theme }, CHANGE_THEME }: GlobalContainer) => ( - - - - - )} - -) + return ( + + + + + ) +} diff --git a/src/pages/Setting/index.tsx b/src/pages/Setting/index.tsx index 40493fc..8e21d2d 100644 --- a/src/pages/Setting/index.tsx +++ b/src/pages/Setting/index.tsx @@ -5,10 +5,26 @@ import { List } from '@material-ui/core' import Signalr from './Signalr' import Theme from './Theme' import Proxy from './Proxy' -export default () => ( - - - - - -) +import Cache from './Cache' + +import useContainer from '@/hooks/useContainer' +import userInstance from '@/containers/user' + +import proxtList from '@/config/proxy' + +const Setting: React.FunctionComponent = () => { + const { myInfo, isLogIn } = useContainer(userInstance).state + + const isDev = isLogIn && myInfo && proxtList.indexOf(myInfo.name) !== -1 + + return ( + + + + + {isDev && } + + ) +} + +export default Setting diff --git a/src/pages/SignIn/index.tsx b/src/pages/SignIn/index.tsx deleted file mode 100644 index 9a76bfd..0000000 --- a/src/pages/SignIn/index.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react' - -import { Button, Typography } from '@material-ui/core' - -import LayoutCenter from '@/components/LayoutCenter' - -import { GET, POST } from '@/utils/fetch' -import { ISignIn } from '@cc98/api' - -interface State { - hasSignedInToday: boolean -} - -class SignIn extends React.Component<{}, State> { - state: State = { - hasSignedInToday: false, - } - - async componentDidMount() { - const res = await GET('me/signin') - - res.fail().succeed(signInRes => { - this.setState({ - hasSignedInToday: signInRes.hasSignedInToday, - }) - }) - } - - signIn = async () => { - if (this.state.hasSignedInToday) { - return - } - - const response = await POST('me/signin') - response.fail().succeed(() => { - this.setState({ - hasSignedInToday: true, - }) - }) - } - - render() { - const { hasSignedInToday } = this.state - - return ( - <> - - - - - ) - } -} - -export default SignIn diff --git a/src/pages/Topic/Dialog.tsx b/src/pages/Topic/Dialog.tsx deleted file mode 100644 index a2bdd46..0000000 --- a/src/pages/Topic/Dialog.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React from 'react'; - -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - Tab, - Tabs, - TextField -} from '@material-ui/core'; -import blue from '@material-ui/core/colors/blue'; -import { StyleRules, withStyles } from '@material-ui/core/styles' -import { ClassNameMap } from '@material-ui/core/styles/withStyles' - -import { PUT } from '@/utils/fetch' -import { IPost } from '@cc98/api' - -const styles: StyleRules = { - avatar: { - backgroundColor: blue[100], - color: blue[600], - }, - tabRoot: { - flexGrow: 1, - }, -}; -interface Props { - onClose: () => void - open: boolean - currentPost: IPost - refreshItem: (data: { id: number, content: string, reason: string }) => void -} -interface State { - value: number - text: string -} - -export default withStyles(styles)( - class extends React.Component { - state: State = { - value: 1, - text: '', - } - handleClose = () => { - this.props.onClose(); - }; - // tslint:disable-next-line:no-any - handleChange = (event: any, value: number) => { - this.setState({ value }); - }; - - // tslint:disable-next-line:no-any - handleTextChange = (event: any) => { - this.setState({ text: event.target.value }) - } - submit = async () => { - const { currentPost } = this.props - const url = `/post/${currentPost.id}/rating`; - const request = await PUT(url, { - params: { - value: this.state.value, - reason: this.state.text, - }, - }) - request.fail() - .succeed(() => { - this.props.onClose() - this.props.refreshItem({ - id: this.props.currentPost.id, - reason: this.state.text, - content: this.state.value === 1 ? '风评值+1' : '风评值-1', - }) - }) - } - - render() { - const { onClose, open, classes, ...other } = this.props; - const { value } = this.state - - return ( - - 评分 - - - 评分需要发帖数达到500以上,您每天有一次评分机会。 - - - - - - - - - - - - - - ); - } - } -) diff --git a/src/pages/Topic/Editor.tsx b/src/pages/Topic/Editor.tsx deleted file mode 100644 index edbcfeb..0000000 --- a/src/pages/Topic/Editor.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import React from 'react' - -import { css } from 'emotion' - -import { - Button, - Input -} from '@material-ui/core' - -import Editor from '@/components/Editor' - -import { POST } from '@/utils/fetch' -import { ITopic } from '@cc98/api' - -interface Props { - topic: ITopic - callback: () => void - initContent?: string - resetInitContent: () => void - theme: string -} -interface State { - editing: boolean -} -const blackWrap = css` - height: 100%; - width: 100%; - position: fixed; - top: 0px; - background-color: #00000063; -` -const editing = css` - && { - position: fixed; - bottom: 0px; - display: flex; - width: 100%; - flex-direction: column; - } -` -const decorEditor = css` - position: fixed; - bottom: 0px; - display: flex; - width: 100%; - justify-content: space-between; - background-color: white; - height: 40px; -` -const darkStyle = css` - && { - background-color: #424242; - } -` -const lightStyle = css` - && { - background-color: white; - } -` -const blankBlock = css` - height: 45px; - /* background-color:white; */ -` -const inputBox = css` - && { - margin-left: 15px; - width: 100%; - opacity: 0.54; - } -` -const post = async (value: string, topic: ITopic, self: ReplyEditor) => { - // const url = `/post/topic/${topic.id}` - const content = { - content: value, - contentType: 0, - title: '', - } - const postData = await POST(`/topic/${topic.id}/post`, { params: content }) - postData.fail().succeed(e => { - // location.reload() - self.props.callback() - self.setState({ editing: false }) - self.props.resetInitContent() - }) -} - -class ReplyEditor extends React.Component { - state: State = { - editing: false, - } - - static getDerivedStateFromProps(props: Props, state: State) { - if (props.initContent) { - return { - editing: true, - } - } - - return null - } - - render() { - const { theme } = this.props - const editMode = this.state.editing - const resetInitContent = this.props.resetInitContent - - return ( - <> -
-
{ - this.setState({ editing: false }) - this.props.resetInitContent() - }} - /> -
- { - let realContent: string - if (files) { - const imgString = files.map(e => ` \n [img]${e}[/img]`).join(' ') - realContent = content + imgString - } else { - realContent = content - } - post(realContent, this.props.topic, this) - this.setState({ editing: false }) - resetInitContent() - }} - /> -
-
-
-
{ - this.setState({ editing: true }) - }} - style={!editMode ? {} : { display: 'none' }} - > - - -
- - ) - } -} - -export default ReplyEditor diff --git a/src/pages/Topic/FixButtons.tsx b/src/pages/Topic/FixButtons.tsx new file mode 100644 index 0000000..1e41cb8 --- /dev/null +++ b/src/pages/Topic/FixButtons.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react' + +import FixFab from '@/components/FixFab' + +import RotateRightIcon from '@material-ui/icons/RotateRight' +import SwapVertIcon from '@material-ui/icons/SwapVert' +import EditIcon from '@material-ui/icons/Edit' +import AddIcon from '@material-ui/icons/Add' +import RemoveIcon from '@material-ui/icons/Remove' + +import { navigate } from '@/utils/history' +import { ITopic } from '@cc98/api' + +interface Props { + /** + * 帖子信息 + */ + topicInfo: ITopic + /** + * 是否逆序 + */ + isReverse?: boolean + /** + * 刷新帖子的回调 + */ + refreshFunc: () => void +} + +export default ({ topicInfo, isReverse, refreshFunc }: Props) => { + // 控制按钮是否展开 + const [expand, setExpand] = useState(false) + + return ( + <> + {expand && ( + <> + + + + + + isReverse + ? navigate(`/topic/${topicInfo.id}`) + : navigate(`/topic/${topicInfo.id}/reverse`) + } + /> + + + navigate(`/editor/replyTopic/${topicInfo.id}`)} /> + + + )} + + {expand ? ( + setExpand(false)} /> + ) : ( + setExpand(true)} /> + )} + + + ) +} diff --git a/src/pages/Topic/PostHead.tsx b/src/pages/Topic/PostHead.tsx index 96743ab..11842b3 100644 --- a/src/pages/Topic/PostHead.tsx +++ b/src/pages/Topic/PostHead.tsx @@ -1,45 +1,52 @@ -import React from 'react' -import { css } from 'emotion' +import React, { useState, useEffect } from 'react' +import styled from 'styled-components' import { IconButton, Typography, Paper } from '@material-ui/core' import KeyboardBackspaceIcon from '@material-ui/icons/KeyboardBackspace' import { ITopic } from '@cc98/api' +import { getBoardNameById } from '@/services/board' -// FIXME: if history stack is empty ? -const goback = () => window.history.back() +import { navigate, goback } from '@/utils/history' -const root = css` - display: flex; - align-items: center; - position: sticky; - top: 0; - height: 56px; - padding: 0 16px; - background-color: #fff; - /* z-index of TopBar is 1100 and DrawerMenu is 1200 */ - z-index: 1105; +const Wrapper = styled(Paper).attrs({ + square: true, + elevation: 1, +})` + && { + display: flex; + align-items: center; + position: sticky; + top: 0; + min-height: 56px; + padding: 0 16px; + /* z-index of TopBar is 1100 and DrawerMenu is 1200 */ + z-index: 1105; - @media (min-width: 600px) { - height: 64px; + @media (min-width: 600px) { + height: 64px; + } } ` -const gobackIcon = css` +const GobackIcon = styled(IconButton)` && { margin-left: -12px; margin-right: 5px; } ` -const title = css` +const Title = styled(Typography).attrs({ + variant: 'subtitle2', +})` && { + margin: 4px 0; flex-grow: 2; } ` -const subTitle = css` +const SubTitle = styled(Typography)` && { margin-left: 8px; margin-right: -5px; @@ -52,16 +59,22 @@ interface Props { topicInfo: ITopic } -const PostHead: React.SFC = ({ topicInfo }) => ( - - - - - - {topicInfo.title} - - {topicInfo.boardName} - -) +const PostHead: React.FunctionComponent = ({ topicInfo }) => { + const [boardName, setBoardName] = useState('') + + useEffect(() => { + getBoardNameById(topicInfo.boardId).then(boardName => setBoardName(boardName)) + }, []) + + return ( + + + + + {topicInfo.title} + navigate(`/board/${topicInfo.boardId}`)}>{boardName} + + ) +} export default PostHead diff --git a/src/pages/Topic/PostItem/Actions.tsx b/src/pages/Topic/PostItem/Actions.tsx new file mode 100644 index 0000000..b8d0827 --- /dev/null +++ b/src/pages/Topic/PostItem/Actions.tsx @@ -0,0 +1,256 @@ +import React, { useState } from 'react' +import styled from 'styled-components' + +import useFetcher from '@/hooks/useFetcher' + +import { IconButton, Typography, Menu, MenuItem } from '@material-ui/core' + +import ThumbUpIcon from '@material-ui/icons/ThumbUp' +import ThumbDownIcon from '@material-ui/icons/ThumbDown' +import FormatQuoteIcon from '@material-ui/icons/FormatQuote' +import ExpandMoreIcon from '@material-ui/icons/ExpandMore' + +import { IPost, ILikeState, IUser, IBoard } from '@cc98/api' +import { putLike, putDislike } from '@/services/post' + +import userInstance from '@/containers/user' + +import { navigate } from '@/utils/history' +import snackbar from '@/utils/snackbar' +import { getBoardsInfo } from '@/services/board' +import copy2Clipboard from 'copy-to-clipboard' + +import Judge from './Judge' +import Manage from './Manage' + +// @babel/plugin-transform-typescript does not support const enums +enum LikeState { + NONE = 0, + LIKE = 1, + DISLIKE = 2, +} + +interface Props { + /** + * 帖子信息 + */ + postInfo: IPost + /** + * 用户信息 + */ + userInfo?: IUser + /** + * 是否追踪 + */ + isTrace?: boolean + /** + * 更新 Post 信息 + */ + refreshPost: () => void +} + +const ActionDiv = styled.div` + display: flex; + align-items: center; + margin-left: 8px; +` + +const Count = styled(Typography).attrs({ + color: 'textSecondary', +})` + && { + margin-left: -2px; + margin-right: 12px; + } +` + +const DividerCol = styled.span` + margin: 0 4px; + /* FIXME: remove hardcode color */ + border: solid thin rgba(0, 0, 0, 0.54); + height: 1rem; +` + +/** + * 检查是否登录 + */ +function checkLogIn() { + if (!userInstance.state.isLogIn) { + snackbar.error('请先登录') + + return false + } + + return true +} + +const IconActions: React.FunctionComponent = ({ postInfo, refreshPost }) => { + const { likeState } = postInfo + + const handleLike = (newLikeState: ILikeState) => () => { + if (!checkLogIn()) return + + const putService = newLikeState === 1 ? putLike : putDislike + + putService(postInfo.id).then(res => + res.fail().succeed(_ => { + refreshPost() + }) + ) + } + + const handleQuote = () => { + if (!checkLogIn()) return + + if (postInfo.isDeleted) { + snackbar.error('不能引用已删除的帖子') + + return + } + navigate(`/editor/replyTopic/${postInfo.topicId}/quote/${postInfo.id}`) + } + + return ( + + + + + {postInfo.dislikeCount} + + + + + {postInfo.likeCount} + + + + + + ) +} + +const MoreActions = ({ postInfo, isTrace, refreshPost, userInfo }: Props) => { + // 控制 Menu 的显示 + const [anchorEl, setAnchorEl] = useState(null) + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + const handleClose = () => { + setAnchorEl(null) + } + + const handleTrace = () => { + if (isTrace) { + navigate(`/topic/${postInfo.topicId}`) + } else { + if (postInfo.isAnonymous) { + navigate(`/topic/${postInfo.topicId}/anonymous/trace/${postInfo.id}`) + } else { + navigate(`/topic/${postInfo.topicId}/trace/${postInfo.userId}`) + } + } + handleClose() + } + + const handleShare = () => { + if (document.location) { + copy2Clipboard( + `https://${document.location.host}/topic/${postInfo.topicId}#${postInfo.floor}` + ) + } + snackbar.success('分享链接已经成功复制到剪切板') + handleClose() + } + + // 控制 评分 的显示 + const [showJudge, setShowJudge] = useState(false) + const [showManage, setShowManage] = useState(false) + + const judgeOpen = () => setShowJudge(true) + const judgeClose = () => setShowJudge(false) + + const manageOpen = () => setShowManage(true) + const manageClose = () => setShowManage(false) + + const handleJudge = () => { + judgeOpen() + handleClose() + } + + const handleManage = () => { + manageOpen() + handleClose() + } + + // FIXME: 不接受每次都这样请求,写一个新的 service + const [childBoards, setChildBoards] = useState([]) + useFetcher(getBoardsInfo, { + success: boards => { + setChildBoards( + boards.map(baseBoard => baseBoard.boards).reduce((prev, cur) => cur.concat(prev)) + ) + }, + }) + const board = childBoards.filter(b => b.id === postInfo.boardId)[0] + + function isManager() { + // 本人是管理员允许修改任何帖子 + if (myInfo && myInfo.privilege === '管理员') return true + // 不是管理员包括版主不允许修改管理员的帖子 + if (userInfo && userInfo.privilege === '管理员') return false + // 本人是版主可以修改其他帖子 + if (myInfo && board && board.boardMasters.indexOf(myInfo.name) !== -1) return true + } + + const myInfo = userInstance.state.myInfo + const isMaster = isManager() + const canEdit = postInfo.userId === (myInfo && myInfo.id) || postInfo.isAnonymous || isMaster + + return ( + <> + {showJudge && ( + + )} + {showManage && ( + + )} + + + + + {isTrace ? '返回' : '追踪'} + {canEdit && ( + { + navigate(`/editor/edit/${postInfo.id}`) + handleClose() + }} + > + 编辑 + + )} + 评分 + 分享 + {isMaster && 管理} + + + ) +} + +const FlexDiv = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +` + +export default ({ postInfo, isTrace, refreshPost, userInfo }: Props) => ( + + + + +) diff --git a/src/pages/Topic/PostItem/Award.tsx b/src/pages/Topic/PostItem/Award.tsx deleted file mode 100644 index 975465e..0000000 --- a/src/pages/Topic/PostItem/Award.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react' -import { css } from 'emotion' -import { IPost } from '@cc98/api' -import { CardContent, Table, TableHead, TableBody, TableCell, TableRow } from '@material-ui/core' - -interface Props { - postInfo: IPost -} - -const left = css` - && { - min-width: 4rem; - white-space: nowrap; - padding: 0 0 0 0; - } -` -const middle = css` - && { - min-width: 5rem; - white-space: nowrap; - padding: 0 0 0 0; - } -` -const right = css` - && { - flex-grow: 2; - padding: 0 0 0 0; - } -` -const row = css` - && { - padding: 0 0 0 0; - height: 30px; - } -` - -export default (props: Props) => { - const { postInfo } = props - - return ( - - - - - 评分人 - 操作内容 - 理由 - - - - - {postInfo.awards.length !== 0 && - postInfo.awards.map(award => ( - - {award.operatorName} - {award.content} - {award.reason} - - ))} - -
-
- ) -} diff --git a/src/pages/Topic/PostItem/Awards.tsx b/src/pages/Topic/PostItem/Awards.tsx new file mode 100644 index 0000000..10c0f7e --- /dev/null +++ b/src/pages/Topic/PostItem/Awards.tsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react' +import styled from 'styled-components' + +import { Table, TableHead, TableBody, TableRow, TableCell } from '@material-ui/core' + +import { IAward } from '@cc98/api' + +const TableRowS = styled(TableRow)` + && { + height: 2rem; + padding: 0; + } +` + +const CellLeft = styled(TableCell)` + && { + min-width: 5rem; + max-width: 7rem; + word-break: break-all; + padding: 4px; + padding-left: 0; + } +` + +const CellMiddle = CellLeft + +const CellRight = styled(TableCell)` + /* 保证优先级高于 .MuiTableCell-root-163:last-child */ + &&& { + padding: 4px 0; + } +` + +const CellShowMore = styled(TableCell)` + &&& { + padding: 8px 0; + text-align: center; + cursor: pointer; + } +` + +interface Props { + awards: IAward[] +} + +// 显示的评分数,超出将默认折叠 +const SHOW_AWARDS_NUM = 3 + +const Awards = ({ awards }: Props) => { + const showExpanded = awards && awards.length > SHOW_AWARDS_NUM + const [expanded, setExpanded] = useState(false) + + const showAwards = expanded ? awards : awards ? awards.slice(0, SHOW_AWARDS_NUM) : [] + + return ( + <> + {showAwards.map(award => ( + + {award.operatorName} + {award.content} + {award.reason} + + ))} + {showExpanded && !expanded && ( + + setExpanded(true)}> + 展开剩余{awards ? awards.length : 0 - SHOW_AWARDS_NUM}个评分 + + + )} + + ) +} + +const AwardsTable: React.FunctionComponent = ({ children }) => ( + + + + 评分人 + 操作 + 理由 + + + {children} +
+) + +const WrapperDiv = styled.div` + margin: 16px; + margin-top: 0; +` + +export default ({ awards }: Props) => { + if (!awards || awards.length === 0) { + return null + } + + return ( + + + + + + ) +} diff --git a/src/pages/Topic/PostItem/Content.tsx b/src/pages/Topic/PostItem/Content.tsx new file mode 100644 index 0000000..d34327a --- /dev/null +++ b/src/pages/Topic/PostItem/Content.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import styled from 'styled-components' + +import { Typography } from '@material-ui/core' + +import { IPost } from '@cc98/api' + +import UBB from '@/UBB' +import remark from 'remark' +import remark2react from 'remark-react' + +function Markdown(content: string) { + return remark() + .use(remark2react) + .processSync(content).contents +} + +const TypographyS = styled(Typography).attrs({ + component: 'div', +})` + && { + margin: 12px 16px; + margin-bottom: 4px; + + /* for in markdown */ + img { + max-width: 100%; + } + } +` + +interface Props { + /** + * 帖子信息 + */ + postInfo: IPost +} + +export default ({ postInfo }: Props) => { + const content = postInfo.contentType === 0 ? UBB(postInfo.content) : Markdown(postInfo.content) + + return {content} +} diff --git a/src/pages/Topic/PostItem/Header.tsx b/src/pages/Topic/PostItem/Header.tsx index 912f73f..fa94280 100644 --- a/src/pages/Topic/PostItem/Header.tsx +++ b/src/pages/Topic/PostItem/Header.tsx @@ -1,115 +1,91 @@ -import React, { useState } from 'react' +import React from 'react' +import styled from 'styled-components' -import { css } from 'emotion' -import { navigate } from '@reach/router' +import { Avatar, Typography } from '@material-ui/core' +import Whatshot from '@material-ui/icons/Whatshot' +import red from '@material-ui/core/colors/red' -import { CardHeader, Avatar, IconButton, Menu, MenuItem } from '@material-ui/core' -import ExpandMoreIcon from '@material-ui/icons/ExpandMore' -import { ClassNameMap } from '@material-ui/core/styles/withStyles' import { IPost, IUser } from '@cc98/api' -interface Props { - classes: ClassNameMap - postInfo: IPost - userInfo: IUser | null - isTrace: boolean - trace: (topicId: number, userId: number, isTrace: boolean, isAnonymous?: boolean) => void - openDialog: (info: IPost) => void -} +import { navigate } from '@/utils/history' +import dayjs from 'dayjs' -const cursorStyle = css` - cursor: pointer; +const FlexDiv = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin: 8px 16px; ` -const postOptionStyle = css` + +const AvatarArea = styled.div` display: flex; - justify-content: center; + align-items: center; +` + +const AvatarS = styled(Avatar)` + && { + margin-right: 12px; + } ` -export default (props: Props) => { - const { classes, postInfo, userInfo, isTrace, trace, openDialog } = props - const [anchorEl, setAnchorEl] = useState(null) - const open = Boolean(anchorEl) +const Title = Typography + +const SubTitle = styled(Typography).attrs({ + color: 'textSecondary', +})`` - return ( - { - navigate(`/user/${postInfo.userId}`) - }} - src={userInfo ? userInfo.portraitUrl : undefined} - > - 匿 - - } - title={ -
- {postInfo.isAnonymous ? `匿名${postInfo.userName.toUpperCase()}` : postInfo.userName} -
} - subheader={new Date(postInfo.time).toLocaleString()} - action={[ - - - {postInfo.isHot ? '热' : `${postInfo.floor}`} - - , - // tslint:disable-next-line:ter-indent -
- setAnchorEl(e.currentTarget)} - > - - - setAnchorEl(null)} - PaperProps={{ - style: { - maxHeight: 48 * 4.5, - width: '5rem', - }, - }} - > - {['评分', isTrace ? '返回' : '追踪', '编辑'].map(option => ( - { - if (option === '追踪') { - if (!postInfo.isAnonymous) { - trace(postInfo.topicId, postInfo.userId, true) - navigate(`/topic/${postInfo.topicId}/trace/${postInfo.userId}`) - } else { - trace(postInfo.topicId, postInfo.id, true, true) - navigate(`/topic/${postInfo.topicId}/anonymous/trace/${postInfo.id}`) - } - } else if (option === '返回') { - trace(postInfo.topicId, postInfo.userId, false) - navigate(`/topic/${postInfo.topicId}`) - } else if (option === '编辑') { - // TODO: - } else if (option === '评分') { - openDialog(postInfo) - } - setAnchorEl(null) - }} - classes={{ root: classes.menuItemRoot }} - > - {option} - - ))} - -
, - ]} - /> - ) +const Floor = styled(Typography).attrs({ + variant: 'button', + color: 'textSecondary', +})`` + +const HotIcon = styled(Whatshot)` + color: ${red[400]}; +` + +interface Props { + /** + * 帖子信息 + */ + postInfo: IPost + /** + * 用户信息 + */ + userInfo: IUser | undefined + /** + * 是否热帖 + */ + isHot?: boolean } + +export default ({ postInfo, userInfo, isHot }: Props) => ( + + + !postInfo.isAnonymous && navigate(`/user/${postInfo.userId}`)} + src={userInfo && userInfo.portraitUrl} + > + {(postInfo.isAnonymous || postInfo.isDeleted) && '匿'} + + + + + {isHot ? : `${postInfo.floor}L`} + +) diff --git a/src/pages/Topic/PostItem/Judge.tsx b/src/pages/Topic/PostItem/Judge.tsx new file mode 100644 index 0000000..76048e7 --- /dev/null +++ b/src/pages/Topic/PostItem/Judge.tsx @@ -0,0 +1,93 @@ +import React, { useState } from 'react' +import styled from 'styled-components' + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Tab, + Tabs, + TextField, +} from '@material-ui/core' + +import { rateHandler } from '@/services/utils/errorHandler' + +import { rate } from '@/services/post' +import { IPost } from '@cc98/api' + +const TabS = styled(Tab)` + && { + flex-grow: 1; + } +` + +const TextFieldS = styled(TextField).attrs({ + fullWidth: true, +})` + && { + margin-top: 16px; + } +` as typeof TextField + +interface Props { + /** + * 帖子信息 + */ + postInfo: IPost + /** + * 关闭 Dialog + */ + handleClose: () => void + /** + * 更新 postInfo + */ + refreshPost: () => void +} + +const Judge: React.FunctionComponent = ({ postInfo, handleClose, refreshPost }) => { + const [point, setPoint] = useState<1 | -1>(1) + + const handlePointChange = (_: React.ChangeEvent, value: 1 | -1) => { + setPoint(value) + } + + const [reason, setReason] = useState('') + const handleTextChange = (event: React.ChangeEvent) => { + setReason(event.target.value) + } + + const submitJudge = async () => { + const res = await rate(postInfo.id, point, reason) + res.fail(rateHandler).succeed(() => { + handleClose() + refreshPost() + }) + } + + return ( + + 评分 + + 评分需要发帖数达到500以上,您每天有一次评分机会 + + + + + + + + + + + + ) +} + +export default Judge diff --git a/src/pages/Topic/PostItem/Manage.tsx b/src/pages/Topic/PostItem/Manage.tsx new file mode 100644 index 0000000..a6e1f3f --- /dev/null +++ b/src/pages/Topic/PostItem/Manage.tsx @@ -0,0 +1,124 @@ +import React, { useState } from 'react' +import styled from 'styled-components' + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Tab, + Tabs, + TextField, +} from '@material-ui/core' + +import { manageHandler } from '@/services/utils/errorHandler' +import { operateWealth, deletePost, stopPost, cancelStopPost } from '@/services/manage' +import { IPost } from '@cc98/api' +import snackbar from '@/utils/snackbar' + +const TabS = styled(Tab)` + && { + flex-grow: 1; + } +` + +const TextFieldS = styled(TextField).attrs({ + fullWidth: true, +})` + && { + margin-top: 16px; + } +` as typeof TextField + +interface Props { + /** + * 帖子信息 + */ + postInfo: IPost + /** + * 关闭 Dialog + */ + handleClose: () => void + /** + * 更新 postInfo + */ + refreshPost: () => void +} + +const Manage: React.FunctionComponent = ({ postInfo, handleClose, refreshPost }) => { + const [point, setPoint] = useState(0) + const handlePointChange = (_: React.ChangeEvent, value: number) => { + setPoint(value) + } + + const [value, setValue] = useState(0) + const handleValueChange = (event: React.ChangeEvent) => { + setValue(parseInt(event.target.value, 10)) + } + + const [reason, setReason] = useState('') + const handleTextChange = (event: React.ChangeEvent) => { + setReason(event.target.value) + } + + const submit = async () => { + let res = null + switch (point) { + case 0: + res = await operateWealth(postInfo.id, value, reason, 0) + break + case 1: + res = await operateWealth(postInfo.id, value, reason, 1) + break + case 2: + res = await deletePost(postInfo.id, reason) + break + case 3: + res = await stopPost(postInfo.id, value, reason) + break + case 4: + res = await cancelStopPost(postInfo.boardId, postInfo.userId) + break + } + + if (!res) { + return + } + + res.fail(manageHandler).succeed(() => { + snackbar.success('操作成功') + handleClose() + refreshPost() + }) + } + + return ( + + 管理 + + + + + + + + + {(point === 0 || point === 1 || point === 3) && ( + + )} + + + + + + + + ) +} + +export default Manage diff --git a/src/pages/Topic/PostItem/PostUtils.ts b/src/pages/Topic/PostItem/PostUtils.ts deleted file mode 100644 index 181753b..0000000 --- a/src/pages/Topic/PostItem/PostUtils.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { GET, PUT } from '@/utils/fetch' -import { ILike, IPost } from '@cc98/api' - -export default { - like: async (id: number) => { - const resPut = await PUT(`/post/${id}/like`, { params: '1' }) - - const resPost = await GET(`/post/${id}/like`) - - // FIXME: error handle - return resPost._value._value as ILike - }, - - dislike: async (id: number) => { - const resPut = await PUT(`/post/${id}/like`, { params: '2' }) - - const resPost = await GET(`/post/${id}/like`) - - // FIXME: error handle - return resPost._value._value as ILike - }, - - quote: async (post: IPost) => { - const time = new Date(post.time).toLocaleString() - - const content = `[quote][b]以下是引用${post.floor}楼:${ - post.userName - }在${time}的发言:[color=blue]\ - [url=/topic/${post.topicId}/#${post.floor}]>>查看原帖<<[/url][/color][/b]\ - ${[post.content]}[/quote]\ - ` - - return content - }, -} diff --git a/src/pages/Topic/PostItem/index.tsx b/src/pages/Topic/PostItem/index.tsx index 8129dad..b1c6c17 100644 --- a/src/pages/Topic/PostItem/index.tsx +++ b/src/pages/Topic/PostItem/index.tsx @@ -1,49 +1,23 @@ -import React from 'react' +import React, { useState } from 'react' +import styled from 'styled-components' -import { css, cx } from 'emotion' - -import { PostInfoStore } from '@/model/post' -import UBB from '@cc98/ubb-react' - -import Utils from './PostUtils' - -import { - Button, - Card, - CardActions, - CardContent, - Collapse, - Divider, - IconButton, - Typography, -} from '@material-ui/core' - -import { StyleRules, withStyles } from '@material-ui/core/styles' -import { ClassNameMap } from '@material-ui/core/styles/withStyles' -import ExpandMoreIcon from '@material-ui/icons/ExpandMore' -import Quote from '@material-ui/icons/RotateLeft' -import DislikeIcon from '@material-ui/icons/ThumbDown' -import LikeIcon from '@material-ui/icons/ThumbUp' +import { Paper, Divider } from '@material-ui/core' import Header from './Header' -import Award from './Award' +import Content from './Content' +import Actions from './Actions' +import Awards from './Awards' -import resolveMarkdown from './resolveMarkdown' +import { getSinglePost } from '@/services/post' import { IPost, IUser } from '@cc98/api' -const root = css` - margin-top: 6px; - background-color: #ccc; -` - -const expand = css` - transform: rotate(0deg); - transition-property: transform; - /* transition-duration: */ -` - -const expandOpen = css` - transform: rotate(180deg); +const Wrapper = styled(Paper).attrs({ + square: true, + elevation: 0, +})` + && { + margin-top: 6px; + } ` interface Props { @@ -54,246 +28,53 @@ interface Props { /** * 用户信息 */ - userInfo: IUser | null + userInfo: IUser | undefined /** - * 是否是追踪模式 + * 是否热帖 */ - isTrace: boolean - classes: ClassNameMap - postInstance: PostInfoStore + isHot?: boolean /** - * 方法 + * 是否追踪 */ - openDialog: (info: IPost) => void - closeDialog: () => void + isTrace?: boolean } -interface State { - /** - * 签名档是否展开 - */ - expanded: boolean - // tslint:disable-next-line:no-any - anchorEl: any -} - -const likeStateMap = ['none', 'like', 'dislike'] -const likeButton = { - clicked: css` - color: #dd5e5c; - `, - unclicked: css` - color: inherit; - `, -} -const dislikeButton = { - clicked: css` - color: #6464ff; - `, - unclicked: css` - color: inherit; - `, -} -const styles: StyleRules = { - actionsRoot: { - display: 'flex', - width: '100%', - height: '2rem', - }, - action: { - flexGrow: 1, - '&:hover': { - backgroundColor: '#fff', - }, - }, - hr: { - border: '#555 solid thin', - height: '1rem', - }, - headerAction: { - display: 'flex', - flexDirection: 'column', - }, - iconRoot: { - padding: '5px', - }, - menuItemRoot: { - width: '3rem', - }, - typographyRoot: { - wordBreak: 'break-all', - }, - floor: { - width: '30px', - height: '30px', - fontSize: '0.8rem', - backgroundColor: '#79b8ca', - }, - hotFloor: { - width: '30px', - height: '30px', - fontSize: '0.8rem', - backgroundColor: 'red', - }, - awardAvatar: { - width: '25px', - height: '25px', - }, - awardAction: { - height: '30px', - fontSize: '0.8rem', - opacity: 0.54, - borderTop: '#aaaaaa solid thin', - marginLeft: '16px', - marginRight: '16px', - }, - expandButton: { - width: '80%', - height: '30px', - }, - tableRoot: { - width: '100%', - }, -} - -const contentRoot = css`&&{ - img { - max-width: 100%; - }, -}` - -export default withStyles(styles)( - class extends React.Component { - state: State = { - expanded: false, - anchorEl: null, - } - - onExpandClick = () => { - this.setState({ - expanded: !this.state.expanded, - }) - } - - render() { - const { postInfo, classes, postInstance, isTrace, userInfo, openDialog } = this.props - const { updateSinglePosts, wakeUpEditor } = postInstance - const { anchorEl } = this.state - const open = Boolean(anchorEl) - if (postInfo.isDeleted) { - return null +export default ({ postInfo, userInfo, isHot, isTrace = false }: Props) => { + const [currentPost, setCurrentPost] = useState(postInfo) + if (postInfo.isDeleted) { + postInfo.content = '该贴已被 my CC98, my home' + } + const refreshPost = async () => { + const res = await getSinglePost(postInfo.topicId, postInfo.floor - 1) + res.fail().succeed(data => { + if (data.length && data.length === 1) { + if (data[0].isDeleted) { + data[0].content = '该贴已被 my CC98, my home' + if (userInfo) { + userInfo.portraitUrl = '' + } + } + setCurrentPost(data[0]) } - - const text = - postInfo.contentType === 0 ? UBB(postInfo.content) : resolveMarkdown(postInfo.content) - - return ( - -
- - - {text} - - - - - { - const res = await Utils.dislike(postInfo.id) - updateSinglePosts(postInfo.id, res) - }} - > - - - {postInfo.dislikeCount} - - - - { - const content = await Utils.quote(this.props.postInfo) - wakeUpEditor(content) - }} - > - - - - - { - const res = await Utils.like(postInfo.id) - updateSinglePosts(postInfo.id, res) - }} - > - - - {postInfo.likeCount} - - - - {postInfo.awards.length > 5 && ( - - - - - - - )} - {postInfo.awards.length > 0 && - postInfo.awards.length <= 5 && } - {postInfo.awards.length > 5 && ( - - - - )} - - ) - } + }) } -) + + return ( + +
+ + + + + + + ) +} diff --git a/src/pages/Topic/PostItem/resolveMarkdown.ts b/src/pages/Topic/PostItem/resolveMarkdown.ts deleted file mode 100644 index d8b5a9f..0000000 --- a/src/pages/Topic/PostItem/resolveMarkdown.ts +++ /dev/null @@ -1,32 +0,0 @@ -import remark from 'remark' -import reactRenderer from 'remark-react' - -// FIXME: move -export default function(content: string) { - let parseContent = content.replace( - /\n>[\s\S]*?\n\n/g, - // tslint:disable-next-line:align - v => v.replace(/\n[^\n](?!>)/g, v1 => v1.replace(/\n(?!>)/, '\n>')) - ) - - if (parseContent[0] === '>') { - const index = parseContent.indexOf('\n\n') - if (index === -1) { - parseContent = parseContent.replace( - /\n[^\n](?!>)/g, - // tslint:disable-next-line:align - v1 => v1.replace(/\n(?!>)/, '\n>') - ) - } else { - const substr = parseContent.substr(0, index) - parseContent = - substr.replace(/\n[^\n](?!>)/g, v1 => v1.replace(/\n(?!>)/, '\n>')) + - parseContent.substr(index + 1, parseContent.length) - } - } - parseContent = parseContent.replace(/发言:\*\*\n/g, '发言:**\n\n') - - return remark() - .use(reactRenderer) - .processSync(parseContent).contents -} diff --git a/src/pages/Topic/PostList.tsx b/src/pages/Topic/PostList.tsx new file mode 100644 index 0000000..a4c0480 --- /dev/null +++ b/src/pages/Topic/PostList.tsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react' + +import useInfList, { Service } from '@/hooks/useInfList' + +import InfiniteList from '@/components/InfiniteList' +import PostItem from './PostItem' + +import { IPost, IUser } from '@cc98/api' +import { getUsersInfoByIds } from '@/services/user' + +interface IUserMap { + [key: number]: IUser +} + +interface Props { + service: Service + isTrace: boolean +} + +export function useUserMap() { + const [userMap, setUserMap] = useState({}) + + const updateUserMap = async (list: IPost[]) => { + const res = await getUsersInfoByIds(list.map(p => p.userId).filter(id => id)) + res.fail().succeed(users => { + users.forEach(user => { + userMap[user.id] = user + }) + + setUserMap(userMap) + }) + } + + return [userMap, updateUserMap] as [typeof userMap, typeof updateUserMap] +} + +const PostList: React.FunctionComponent = ({ service, isTrace, children }) => { + const [userMap, updateUserMap] = useUserMap() + + const [posts, state, callback] = useInfList(service, { + step: 10, + success: updateUserMap, + }) + const { isLoading, isEnd } = state + + return ( + + {posts.map((info: IPost, index: number) => + info.floor === 1 ? ( + <> + + {children/** */} + + ) : ( + + ) + )} + + ) +} + +export default PostList diff --git a/src/pages/Topic/PostListHot.tsx b/src/pages/Topic/PostListHot.tsx new file mode 100644 index 0000000..449c94c --- /dev/null +++ b/src/pages/Topic/PostListHot.tsx @@ -0,0 +1,31 @@ +import React from 'react' + +import useFetcher, { Service } from '@/hooks/useFetcher' +import PostItem from './PostItem' +import { useUserMap } from './PostList' + +import { IPost } from '@cc98/api' + +interface Props { + service: Service +} + +export default ({ service }: Props) => { + const [userMap, updateUserMap] = useUserMap() + + const [posts] = useFetcher(service, { + success: updateUserMap, + }) + + if (posts === null) { + return null + } + + return ( + <> + {posts.map((info: IPost) => ( + + ))} + + ) +} diff --git a/src/pages/Topic/Topic.tsx b/src/pages/Topic/Topic.tsx deleted file mode 100644 index c822b12..0000000 --- a/src/pages/Topic/Topic.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React from 'react' -import { navigate } from '@reach/router' - -import LoadingCircle from '@/components/LoadingCircle' -import Editor from './Editor' -import PostHead from './PostHead' - -import InfiniteList from '@/components/InfiniteList' -import { BoardInfoStore } from '@/model/board' -import { GlobalContainer } from '@/model/global' -import { PostInfoStore } from '@/model/post' -import getBoardName from '@/services/getBoardName' -import { GET } from '@/utils/fetch' -import { IPost, ITopic, IUser } from '@cc98/api' -import MyDialog from './Dialog' -import PostItem from './PostItem' - -interface Props { - topicId: string - postId: string - userId: string - postInstance: PostInfoStore - global: GlobalContainer - boardInstance: BoardInfoStore -} - -interface State { - topicInfo: ITopic | null - open: boolean - currentPost: IPost | null - editing: boolean -} - -export default class extends React.Component { - constructor(props: Props) { - super(props) - props.postInstance.init(parseInt(props.topicId, 10)) - } - - state: State = { - topicInfo: null, - open: false, - currentPost: null, - editing: false, - } - - async componentDidMount() { - const topicId = parseInt(this.props.topicId, 10) - if (isNaN(topicId)) { - navigate('/404') - - return null - } - - const { postInstance, boardInstance } = this.props - const topic = await GET(`/topic/${topicId}`) - topic.fail().succeed(topicInfo => { - const boardName = getBoardName(boardInstance.state.boardData, topicInfo.boardId) - topicInfo.boardName = boardName - if (this.props.userId) { - const userId = parseInt(this.props.userId, 10) - postInstance.trace(topicId, userId, true) - } else if (this.props.postId) { - const postId = parseInt(this.props.postId, 10) - postInstance.trace(topicId, postId, true, true) - } else { - postInstance.fetchPosts() - } - this.setState({ - topicInfo, - }) - }) - } - - componentWillUnmount() { - this.props.postInstance.reset() - } - handleClickOpen = (info: IPost) => { - this.setState({ - open: true, - currentPost: info, - }) - } - - handleDialogClose = () => { - this.setState({ open: false }) - } - - render() { - const { topicInfo, currentPost } = this.state - const { postId, userId, global, postInstance } = this.props - const { isLoading, isEnd, posts, userMap } = postInstance.state - const isTrace = Boolean(postId) || Boolean(userId) - if (topicInfo === null) { - return - } - const myInfo = global.state.myInfo as IUser - - return ( - <> - - {currentPost && ( - { - postInstance.updatePostAward(data, myInfo) - }} - /> - )} - - - {posts.map((info: IPost) => ( - - ))} - - - - - ) - } -} diff --git a/src/pages/Topic/index.tsx b/src/pages/Topic/index.tsx index 38ac881..c70edca 100644 --- a/src/pages/Topic/index.tsx +++ b/src/pages/Topic/index.tsx @@ -1,49 +1,87 @@ -import React from 'react' +import React, { useState, useMemo, useCallback } from 'react' +import styled from 'styled-components' -import { Subscribe } from '@cc98/state' +import useFetcher from '@/hooks/useFetcher' -import { BoardInfoStore } from '@/model/board' -import global, { GlobalContainer } from '@/model/global' -import { PostInfoStore } from '@/model/post' +import LoadingCircle from '@/components/LoadingCircle' -import Topic from './Topic' +import PostHead from './PostHead' +import PostListHot from './PostListHot' +import PostList from './PostList' +import FixButtons from './FixButtons' + +import { getTopicInfo } from '@/services/topic' +import { + getPost, + getReversePost, + getTracePost, + getAnonymousTracePost, + getHotPost, +} from '@/services/post' +import { navigateHandler } from '@/services/utils/errorHandler' + +const EndPlaceholder = styled.div` + height: 64px; +` interface Props { + // 帖子 ID topicId: string - postId: string - userId: string + // 追踪非匿名帖子 + userId?: string + // 追踪匿名帖子 + postId?: string + // 是否逆向 + isReverse?: boolean } -interface State { - postInstance: PostInfoStore -} +const Topic = ({ topicId, userId, postId, isReverse }: Props) => { + const [topicInfo] = useFetcher(() => getTopicInfo(topicId), { + fail: navigateHandler, + }) -export default class extends React.Component { - state = { - postInstance: new PostInfoStore(), - } + // 用于刷新 + const [postListKey, setPostListKey] = useState(0) - render() { - const { postId, userId, topicId } = this.props - const { postInstance } = this.state - - return ( - - {(g: GlobalContainer, p: PostInfoStore, boardInstance: BoardInfoStore) => { - const isRender = g.state.myInfo - - return isRender ? ( - - ) : null - }} - - ) + if (!topicInfo) { + return } + + // 根据 URL 参数选择获取 post 的 service + const postService = useMemo( + () => + isReverse + ? (from: number) => getReversePost(topicInfo.id, from, topicInfo.replyCount) + : userId + ? (from: number) => getTracePost(topicInfo.id, userId, from) + : postId + ? (from: number) => getAnonymousTracePost(topicInfo.id, postId, from) + : (from: number) => getPost(topicInfo.id, from), + [] + ) + + const hotPostService = () => getHotPost(topicInfo.id) + + // 是否处于追踪状态 + const isTrace = !!userId || !!postId + + const refreshFunc = useCallback(() => setPostListKey(postListKey + 1), [postListKey]) + + return ( + <> + + + {!isTrace && } + + + + + ) } + +/** + * 逆序 Topic + */ +const TopicReverse = (props: Props) => + +export { Topic as default, TopicReverse } diff --git a/src/pages/UserCenter/Edit/Edit.tsx b/src/pages/UserCenter/Edit/Edit.tsx deleted file mode 100644 index 9c89f6a..0000000 --- a/src/pages/UserCenter/Edit/Edit.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import React from 'react' - -import { navigate } from '@reach/router' -import { css } from 'emotion' - -import basicInstance from '@/model/global' - -import { - IconButton, - Typography, - Button, - FormControl, - InputLabel, - OutlinedInput, - Select, - TextField -} from '@material-ui/core' -import { Theme, withStyles } from '@material-ui/core/styles' -import { ClassNameMap, StyleRulesCallback } from '@material-ui/core/styles/withStyles' -import KeyboardBackspaceIcon from '@material-ui/icons/KeyboardBackspace' - -import { PUT } from '@/utils/fetch' -import { IUser } from '@cc98/api' - -const goback = () => window.history.back() -interface Props { - info: IUser -} -interface State { - newInfo: IUser - disabled: boolean - buttonInfo: string -} - -const root = css` - display: flex; - align-items: center; - position: sticky; - top: 0; - height: 56px; - padding: 0 18px; - background-color: #fff; - /* z-index of TopBar is 1100 and DrawerMenu is 1200 */ - z-index: 1105; - width: 100%; - @media (min-width: 600px) { - height: 64px; - } -` -const gobackIcon = css` - && { - margin-left: -12px; - margin-right: 5px; - } -` - -const styles: StyleRulesCallback = (theme: Theme) => ({ - container: { - display: 'flex', - flexWrap: 'wrap', - }, - textField: { - marginLeft: theme.spacing.unit, - marginRight: theme.spacing.unit, - width: '100%', - }, - dense: { - marginTop: 16, - }, - menu: { - width: 200, - }, - genderRoot: { - width: '100%', - marginLeft: '8px', - marginRight: '8px', - }, -}) -const ButtonStyle = css` - && { - width: 100%; - margin-left: 10px; - margin-right: 10px; - margin-top: 10px; - } -` - -export default withStyles(styles)( - class extends React.Component { - constructor(props: Props & { classes: ClassNameMap }) { - super(props) - this.state = { - newInfo: props.info, - buttonInfo: '修改', - disabled: false, - } - } - - handleChange = (name: keyof IUser) => ( - e: React.ChangeEvent | React.ChangeEvent - ) => { - const info: IUser = { ...this.state.newInfo } - info[name] = e.target.value - this.setState({ - newInfo: info, - }) - } - - submit = async () => { - const { newInfo } = this.state - this.setState({ - disabled: true, - buttonInfo: '...', - }) - - const submitTry = await PUT('me', { params: newInfo }) - submitTry - .fail(() => { - this.setState({ disabled: false }) - }) - .succeed(() => { - basicInstance.FRESH_INFO() - this.setState({ disabled: false, buttonInfo: '修改' }) - navigate('/userCenter') - }) - } - - render() { - const { classes, info } = this.props - const { newInfo, disabled, buttonInfo } = this.state - - return ( -
-
- - - - 编辑个人信息 -
- - Age - - - - - - - - - ) - } - } -) diff --git a/src/pages/UserCenter/Edit/index.tsx b/src/pages/UserCenter/Edit/index.tsx index bca660c..7f47a2c 100644 --- a/src/pages/UserCenter/Edit/index.tsx +++ b/src/pages/UserCenter/Edit/index.tsx @@ -1,19 +1,122 @@ -import React from 'react'; +import React, { useState } from 'react' -import { Subscribe } from '@cc98/state'; +import { goback } from '@/utils/history' +import styled from 'styled-components' -import global, { GlobalContainer } from '@/model/global'; +import useContainer from '@/hooks/useContainer' +import userInstace from '@/containers/user' -import EditContainer from './Edit'; +import { IconButton, Typography, Button, TextField } from '@material-ui/core' -export default class extends React.Component<{}, {}> { - render() { +import KeyboardBackspaceIcon from '@material-ui/icons/KeyboardBackspace' - return ( - - {(basic: GlobalContainer) => - basic.state.myInfo ? : null - } - ); +import { modifyMyInfo } from '@/services/user' +import snackbar from '@/utils/snackbar' + +const HeaderDiv = styled.div` + display: flex; + align-items: center; + margin: 8px 0; +` + +const GobackIcon = styled(IconButton)` + && { + margin-left: 4px; + margin-right: 5px; + } +` + +const FormHeader = () => ( + + + + + 编辑个人信息 + +) + +const FormWrapper = styled.form` + display: flex; + flex-direction: column; + align-items: center; + margin: 0 16px; +` + +const FormItem = styled(TextField).attrs({ + fullWidth: true, + variant: 'outlined', +})` + && { + margin-bottom: 20px; + } +` as typeof TextField // FIXME: @types/styled-components + +const SubmitButton = styled(Button).attrs({ + variant: 'contained', + color: 'primary', +})` + && { + margin: 8px; } +` + +const FormBody = () => { + const { + state: { myInfo }, + } = useContainer(userInstace) + + const [info, setInfo] = useState(myInfo) + + if (info === null) { + return null + } + + const handleChange = (name: keyof typeof info) => ( + evt: React.ChangeEvent + ) => { + info[name] = evt.target.value + setInfo(info) + } + + const handleSubmit = () => { + modifyMyInfo(info).then(res => + res.fail().succeed(_ => { + snackbar.success('修改成功') + userInstace.FRESH_INFO() + }) + ) + } + + return ( + + + + + + + + + + 提交修改 + + ) } + +export default () => ( + <> + + + +) diff --git a/src/pages/UserCenter/ExpandPanel.tsx b/src/pages/UserCenter/ExpandPanel.tsx new file mode 100644 index 0000000..7f64f18 --- /dev/null +++ b/src/pages/UserCenter/ExpandPanel.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import styled from 'styled-components' + +import { + ExpansionPanel, + ExpansionPanelDetails, + ExpansionPanelSummary, + Typography, +} from '@material-ui/core' + +import ExpandMoreIcon from '@material-ui/icons/ExpandMore' + +const ExpansionPanelDetailsS = styled(ExpansionPanelDetails)` + && { + width: 100%; + padding: 0 4px 24px 4px; + } +` + +interface Props { + /** + * ExpansionPanel props + */ + expanded?: boolean + defaultExpanded?: boolean + /** + * 标题 + */ + title?: string +} + +const ExpandPanel: React.FunctionComponent = props => ( + + {props.title && ( + }> + {props.title} + + )} + {props.children} + +) + +export default ExpandPanel diff --git a/src/pages/UserCenter/Topics.tsx b/src/pages/UserCenter/Topics.tsx deleted file mode 100644 index 93ca188..0000000 --- a/src/pages/UserCenter/Topics.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import React from 'react' -import { css } from 'emotion' - -import { - ExpansionPanel, - ExpansionPanelDetails, - ExpansionPanelSummary, - List, - Typography, -} from '@material-ui/core' - -import ExpandMoreIcon from '@material-ui/icons/ExpandMore' - -import { StyleRules, withStyles } from '@material-ui/core/styles' -import { ClassNameMap } from '@material-ui/core/styles/withStyles' - -import InfiniteList from '@/components/InfiniteList' -import TopicItem from '@/components/TopicItem' - -import { GET } from '@/utils/fetch' -import { IBaseBoard, ITopic, IUser } from '@cc98/api' -import getBoardNameById from '@/services/getBoardName' - -const styles: StyleRules = { - root: { - display: 'flex', - alignItems: 'center', - width: '100%', - }, - row: { - display: 'flex', - alignItems: 'center', - width: '100%', - }, - itemRoot: { - paddingTop: 3, - paddingBottom: 3, - borderTop: '#eaeaea solid thin', - }, - bigAvatar: { - width: '5rem', - height: '5rem', - }, - action: { - alignSelf: 'center', - marginTop: 0, - marginBottom: 0, - }, - primary: { - width: '5rem', - marginRight: '2rem', - color: 'rgba(0, 0, 0, 0.54)', - fontSize: '0.8rem', - }, - secondary: { - color: 'rgba(0, 0, 0, 0.87)', - }, - expanded: { - marginTop: '0.5rem', - marginBottom: '0.5rem', - }, - expandRoot: { - width: '100%', - margin: '0 0 0 0', - }, - expandDetailRoot: { - display: 'flex', - flexDirection: 'column', - width: '100%', - padding: '0 0 0 0 ', - }, -} - -const ExpandPanelSummaryStyle = css` - && { - display: flex; - justify-content: space-between; - align-items: center; - padding-left: 1rem; - margin: 0 0 0 0; - } -` - -interface Props { - info: IUser - boards: IBaseBoard[] - classes: ClassNameMap -} - -interface State { - recentTopics: ITopic[] - isLoading: boolean - isEnd: boolean - from: number - size: number -} -export default withStyles(styles)( - class extends React.Component { - state: State = { - recentTopics: [], - isLoading: false, - isEnd: false, - from: 0, - size: 10, - } - - componentDidMount() { - this.getRecentTopics() - } - - getRecentTopics = async () => { - const { info, boards } = this.props - this.setState({ isLoading: true }) - const { from, recentTopics, size } = this.state - if (info) { - const recentTopicsData = await GET( - `user/${info.id}/recent-topic?from=${from}&size=10` - ) - recentTopicsData.fail().succeed(async (newRecentTopics: ITopic[]) => { - newRecentTopics.forEach(async topic => { - topic.boardName = getBoardNameById(boards, topic.boardId) - }) - this.setState({ - recentTopics: recentTopics.concat(newRecentTopics), - from: from + newRecentTopics.length, - isLoading: false, - isEnd: size !== newRecentTopics.length, - }) - }) - } - } - - render() { - const { classes } = this.props - const { isLoading, isEnd, recentTopics } = this.state - - return ( - - } - > - 发表主题 - - - - - {recentTopics.map(topic => ( - - ))} - - - - - ) - } - } -) diff --git a/src/pages/UserCenter/User.tsx b/src/pages/UserCenter/User.tsx deleted file mode 100644 index 35a8c86..0000000 --- a/src/pages/UserCenter/User.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import React from 'react' - -import { navigate } from '@reach/router' -import { css } from 'emotion' -import { Subscribe } from '@cc98/state' - -import boardInstance, { BoardInfoStore } from '@/model/board' - -import { - Avatar, - Button, - CardHeader, - Divider, - List, - ListItem, - ListItemText, - Paper -} from '@material-ui/core' -import { StyleRules, withStyles } from '@material-ui/core/styles' -import { ClassNameMap } from '@material-ui/core/styles/withStyles' - -import Topics from './Topics' - -import { DELETE, PUT } from '@/utils/fetch' -import { IUser } from '@cc98/api' - -interface Props { - info: IUser - isUserCenter: boolean -} - -const styles: StyleRules = { - root: { - display: 'flex', - alignItems: 'center', - width: '100%', - }, - row: { - display: 'flex', - alignItems: 'center', - width: '100%', - }, - itemRoot: { - paddingTop: 3, - paddingBottom: 3, - borderTop: '#eaeaea solid thin', - }, - bigAvatar: { - width: '5rem', - height: '5rem', - }, - action: { - alignSelf: 'center', - marginTop: 0, - marginBottom: 0, - }, - primary: { - width: '5rem', - marginRight: '2rem', - opacity: 0.54, - fontSize: '0.8rem', - }, - secondary: { - opacity: 0.54, - }, -} -const userNameStyle = css` - && { - font-size: 1.5rem; - } -` -const userStyle = css` - display: flex; - flex-direction: column; - width: 100%; - align-items: center; - justify-content: center; -` -const optionStyle = css` - && { - width: 100%; - } -` -const actionStyle = css` - display: flex; - justify-content: flex-end; -` -const btnStyle = css` - && { - margin-right: 10px; - margin-left: 10px; - } -` -interface State { - buttonInfo: string - disabled: boolean - isFollowing: boolean -} -interface ClassNameProps { - classes: ClassNameMap -} -export default withStyles(styles)( - class extends React.Component { - constructor(props: Props & ClassNameProps) { - super(props) - this.state = { - buttonInfo: props.info.isFollowing ? '取关' : '关注', - disabled: false, - isFollowing: props.info.isFollowing, - } - } - changeFollowStatus = async () => { - const { info } = this.props - const { isFollowing } = this.state - this.setState({ - buttonInfo: '...', - disabled: true, - }) - const url = `/me/followee/${info.id}` - if (isFollowing) { - const unfollowTry = await DELETE(url) - unfollowTry - .fail(() => - this.setState({ - buttonInfo: '取关失败', - disabled: false, - }) - ) - .succeed(() => - this.setState({ - buttonInfo: '关注', - disabled: false, - isFollowing: false, - }) - ) - } else { - const followTry = await PUT(url) - followTry - .fail(() => - this.setState({ - buttonInfo: '关注失败', - disabled: false, - }) - ) - .succeed(() => - this.setState({ - buttonInfo: '取关', - disabled: false, - isFollowing: true, - }) - ) - } - } - render() { - const { classes, info, isUserCenter } = this.props - const { buttonInfo, disabled } = this.state - const followBtn = ( - - ) - const editBtn = ( - - ) - if (info) { - return ( - - } - title={
{info.name}
} - action={ -
- {isUserCenter ? editBtn : followBtn} - {!isUserCenter ? ( - - ) : null} -
} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {(store: BoardInfoStore) => - store.state.boardData.length !== 0 ? ( - - ) : null - } - -
- ) - } - - return null - } - } -) diff --git a/src/pages/UserCenter/UserAvatar.tsx b/src/pages/UserCenter/UserAvatar.tsx new file mode 100644 index 0000000..a9d8ba7 --- /dev/null +++ b/src/pages/UserCenter/UserAvatar.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react' +import { navigate } from '@/utils/history' +import styled from 'styled-components' + +import { Avatar, IconButton, Typography, CircularProgress } from '@material-ui/core' + +import ExpandPanel from './ExpandPanel' + +import FavoriteIcon from '@material-ui/icons/Favorite' +import EditIcon from '@material-ui/icons/Edit' +import ChatIcon from '@material-ui/icons/Chat' + +import { followUser, unFollowUser } from '@/services/user' +import { IUser } from '@cc98/api' + +const WrapperDiv = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin: 24px; + margin-bottom: 0; +` + +const AvatarDiv = styled.div` + display: flex; + align-items: center; +` + +const ButtonDiv = styled.div` + margin-right: -10px; +` + +const AvatarS = styled(Avatar)` + && { + width: 70px; + height: 70px; + margin-right: 20px; + } +` + +interface Props { + info: IUser + isUserCenter: boolean +} + +const UserAvatar: React.FunctionComponent = ({ info, isUserCenter }) => { + const [isFollowing, setIsFollowing] = useState(info.isFollowing) + const [isLoading, setIsLoading] = useState(false) + const toggleFunc = async () => { + if (isLoading) { + return + } + setIsLoading(true) + if (isFollowing) { + const res = await unFollowUser(info.id) + res + .fail(() => setIsLoading(false)) + .succeed(() => { + setIsFollowing(false) + setIsLoading(false) + }) + } else { + const res = await followUser(info.id) + res + .fail(() => setIsLoading(false)) + .succeed(() => { + setIsFollowing(true) + setIsLoading(false) + }) + } + } + + const buttonsJSX = isUserCenter ? ( + navigate('/userCenter/edit')}> + + + ) : ( + <> + + {isLoading ? ( + + ) : ( + + )} + + + navigate(`/messageDetail/${info.id}`)} /> + + + ) + + return ( + + + + + {info.name} + + {buttonsJSX} + + + ) +} + +export default UserAvatar diff --git a/src/pages/UserCenter/UserDetail.tsx b/src/pages/UserCenter/UserDetail.tsx new file mode 100644 index 0000000..bdc9186 --- /dev/null +++ b/src/pages/UserCenter/UserDetail.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import styled from 'styled-components' + +import { Typography } from '@material-ui/core' +import ExpandPanel from './ExpandPanel' + +import { IUser } from '@cc98/api' + +import dayjs from 'dayjs' + +interface ListItemProps { + name: string + value: string | number +} + +const ItemDiv = styled.div` + padding: 10px 20px; + width: 50%; +` + +const ListItem: React.FunctionComponent = ({ name, value }) => ( + + {name} + {value} + +) + +const ListDiv = styled.div` + display: flex; + flex-wrap: wrap; +` + +interface Props { + info: IUser +} + +const RecentTopics: React.FunctionComponent = ({ info }) => ( + + + + + + + + + + + + + + + +) + +export default RecentTopics diff --git a/src/pages/UserCenter/UserRecentTopics.tsx b/src/pages/UserCenter/UserRecentTopics.tsx new file mode 100644 index 0000000..65e7c68 --- /dev/null +++ b/src/pages/UserCenter/UserRecentTopics.tsx @@ -0,0 +1,26 @@ +import React from 'react' + +import ExpandPanel from './ExpandPanel' + +import { InfTopicList } from '@/components/TopicList' + +import { IUser } from '@cc98/api' +import { getUsersRecentTopics, getMyRecentTopics } from '@/services/topic' + +interface Props { + info: IUser + isUserCenter: boolean +} + +const RecentTopics: React.FunctionComponent = ({ info, isUserCenter }) => ( + + getUsersRecentTopics(info.id, from) + } + place="usercenter" + /> + +) + +export default RecentTopics diff --git a/src/pages/UserCenter/UserSignature.tsx b/src/pages/UserCenter/UserSignature.tsx new file mode 100644 index 0000000..043a892 --- /dev/null +++ b/src/pages/UserCenter/UserSignature.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import styled from 'styled-components' + +import { Typography } from '@material-ui/core' +import ExpandPanel from './ExpandPanel' + +import { IUser } from '@cc98/api' + +import UBB from '@/UBB' + +const TypographyS = styled(Typography)` + && { + width: 100%; + margin: 0 12px; + } +` + +interface Props { + info: IUser +} + +const UserSignature: React.FunctionComponent = ({ info }) => ( + + {UBB(info.signatureCode)} + +) + +export default UserSignature diff --git a/src/pages/UserCenter/index.tsx b/src/pages/UserCenter/index.tsx index 58b25f0..d30a8a2 100644 --- a/src/pages/UserCenter/index.tsx +++ b/src/pages/UserCenter/index.tsx @@ -1,40 +1,56 @@ import React from 'react' -import { Subscribe } from '@cc98/state' +import useContainer from '@/hooks/useContainer' +import userInstance from '@/containers/user' -import global from '@/model/global' -import user, { UserInfoStore } from '@/model/user' +import useFetcher from '@/hooks/useFetcher' + +import { IUser } from '@cc98/api' +import { getUserInfoById } from '@/services/user' + +import UserAvatar from './UserAvatar' +import UserSignature from './UserSignature' +import UserDetail from './UserDetail' +import UserRecentTopics from './UserRecentTopics' +import { navigate } from '@/utils/history' -import User from './User' interface Props { - id: string | undefined + info: IUser + isUserCenter: boolean +} + +const UserCenter: React.FunctionComponent = ({ info, isUserCenter }) => ( + <> + + + + + +) + +interface WrapperProps { + /** + * 来自路由 + */ + id?: string } -export default class extends React.Component { - componentDidMount() { - const { id } = this.props - if (id) user.getInfo(parseInt(id, 10)) +const Wrapper: React.FunctionComponent = props => { + const { + state: { myInfo }, + } = useContainer(userInstance) + + const [userInfo] = useFetcher(props.id ? () => getUserInfoById(props.id as string) : null) + + if (!myInfo) { + navigate('/error/401') } - render() { - if (this.props.id) { - const id = parseInt(this.props.id, 10) - - return ( - - {({ state: userMap }: UserInfoStore) => ( - userMap[id] ? : null - )} - - ) - } - - return ( - - {() => ( - global.state.myInfo ? : null - )} - - ) + if (!props.id && myInfo) { + return } + + return userInfo && } + +export default Wrapper diff --git a/src/router.tsx b/src/router.tsx deleted file mode 100644 index 934df9a..0000000 --- a/src/router.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React from 'react' -// https://reach.tech/router/api/Router -import { RouteComponentProps, Router } from '@reach/router' - -import BoardList from './pages/Board' -import Board from './pages/Board/Board' -import Compose from './pages/Compose' -import Page401 from './pages/Error/401' -import Page404 from './pages/Error/404' -import Home from './pages/Home' -import HotTopic from './pages/HotTopic' -import LogIn from './pages/LogIn' -import MessageDetail from './pages/Message/Detail' -import MessageList from './pages/Message/List' -import MyFollow from './pages/MyFollow' -import NewTopic from './pages/NewTopic' -import Search from './pages/Search' -import Setting from './pages/Setting' -import SignIn from './pages/SignIn' -import Topic from './pages/Topic' -import UserCenter from './pages/UserCenter' -import UserCenterEdit from './pages/UserCenter/Edit' - -// TODO: cache -const Route: React.SFC< - RouteComponentProps & { - // @types/react 里 createElement 签名很混乱 - // tslint:disable-next-line:no-any - component: any - } -> = props => { - const { path, component, ...otherProps } = props - - return React.createElement(component, otherProps) -} - -const Routes: React.SFC = () => ( - - - - - - - - - - - - - - - - - - - - - - - - - -) - -export default Routes - -// 全局左滑返回 -const globalBack = { - clientX: 0, - // clientY: 0, -} - -document.addEventListener( - 'touchstart', - (event: TouchEvent) => { - globalBack.clientX = event.changedTouches[0].clientX - }, - false -) - -document.addEventListener( - 'touchend', - (event: TouchEvent) => { - const moveLen = event.changedTouches[0].clientX - globalBack.clientX - - if (globalBack.clientX < 120 && moveLen > 120) { - window.history.back() - } - }, - false -) diff --git a/src/router/Router.tsx b/src/router/Router.tsx new file mode 100644 index 0000000..c00ea55 --- /dev/null +++ b/src/router/Router.tsx @@ -0,0 +1,80 @@ +import React from 'react' +// https://reach.tech/router/api/Router +import { Router, RouteComponentProps, WindowLocation } from '@reach/router' + +import BoardList from '@/pages/BoardList' +import Board from '@/pages/Board' +import Editor from '@/pages/Editor' +import Home from '@/pages/Home' +import HotTopic from '@/pages/HotTopic' +import MessageDetail from '@/pages/Message/Detail' +import MessageList from '@/pages/Message/List' +import MyFollow from '@/pages/MyFollow' +import NewTopic from '@/pages/NewTopic' +import Search from '@/pages/Search' +import Setting from '@/pages/Setting' +import Topic, { TopicReverse } from '@/pages/Topic' +import UserCenter from '@/pages/UserCenter' +import UserCenterEdit from '@/pages/UserCenter/Edit' +import Help from '@/pages/Help' + +import LogIn from '@/pages/LogIn' +import Error from '@/pages/Error' + +export const Route: React.FunctionComponent< + RouteComponentProps & { + // @types/react 里 createElement 签名很混乱 + // tslint:disable-next-line:no-any + component: any + // component: React.FunctionComponent + } +> = props => { + const { path, component, ...otherProps } = props + + return React.createElement(component, otherProps) +} + +export interface ILocation { + location: WindowLocation +} + +const MyRouter: React.FunctionComponent = ({ location }) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) + +export default React.memo(({ location }: ILocation) => ) diff --git a/src/router/gesture.tsx b/src/router/gesture.tsx new file mode 100644 index 0000000..1dc3949 --- /dev/null +++ b/src/router/gesture.tsx @@ -0,0 +1,41 @@ +import history from '@/utils/history' + +/** + * 全局手势滑动 + * + * TODO: 动画效果 + */ + +const globalBack = { + clientX: 0, + clientY: 0, +} + +document.addEventListener( + 'touchstart', + (event: TouchEvent) => { + globalBack.clientX = event.changedTouches[0].clientX + globalBack.clientY = event.changedTouches[0].clientY + }, + false +) + +document.addEventListener( + 'touchend', + (event: TouchEvent) => { + const moveX = event.changedTouches[0].clientX - globalBack.clientX + const moveY = event.changedTouches[0].clientY - globalBack.clientY + + if (Math.abs(moveY) > 40) { + return + } + + if (moveX > 150) { + history.go(-1) + } + if (moveX < -150) { + history.go(1) + } + }, + false +) diff --git a/src/router/index.tsx b/src/router/index.tsx new file mode 100644 index 0000000..5dc91b9 --- /dev/null +++ b/src/router/index.tsx @@ -0,0 +1,94 @@ +import React, { useEffect } from 'react' +// https://reach.tech/router/api/Router +import { Location, WindowLocation } from '@reach/router' +import Router, { ILocation } from './Router' + +import useContainer, { Container } from '@/hooks/useContainer' +import settingInstance from '@/containers/setting' + +import './gesture' + +interface State { + locations: WindowLocation[] + MAX_CACHE_SIZE: number +} + +/** + * 路由级页面缓存 + */ +class RouterCacheContainer extends Container { + state: State = { + locations: [], + MAX_CACHE_SIZE: settingInstance.state.routerCacheSize, + } + + /** + * 新增路由缓存 (LRU) + * @param location + */ + push(location: WindowLocation) { + const { locations, MAX_CACHE_SIZE } = this.state + const index = locations.findIndex(backLoc => backLoc.href === location.href) + + if (index !== -1) { + const loc = locations[index] + locations.splice(index, 1) + locations.push(loc) + } else { + locations.push({ ...location }) + // 超过最大缓存数 + if (locations.length > MAX_CACHE_SIZE) { + locations.shift() + } + } + + this.setState({ locations }) + } +} + +/** + * 路由缓存实例 + */ +export const ROUTER_CACHE = new RouterCacheContainer() + +/** + * 将函数触发限定在某一路由下(配合事件绑定用) + * @param func 待绑定函数 + * @param href 路由 + */ +// tslint:disable-next-line +export function bindURL(func: Function, href: string) { + return () => { + if (window.location.href === href) { + func() + } + } +} + +const CacheRouter: React.FunctionComponent = ({ location }) => { + const { locations } = useContainer(ROUTER_CACHE).state + + useEffect( + () => { + ROUTER_CACHE.push(location) + }, + [location] + ) + + return ( + <> + {locations.map(backLoc => ( +
+ +
+ ))} + + ) +} + +export default React.memo(() => ( + {({ location }) => } +)) diff --git a/src/services/board.ts b/src/services/board.ts index 8ec4912..870d8b2 100644 --- a/src/services/board.ts +++ b/src/services/board.ts @@ -1,21 +1,67 @@ -import { Try, Success } from '@/utils/fp/Try' -import { IBaseBoard } from '@cc98/api' -import { FetchError, GET } from '@/utils/fetch' -import { getLocalStorage, setLocalStorage } from '@/utils/storage' +import { GET, PUT, DELETE } from '@/utils/fetch' + +import { IBoardGroup, IBoard, ITagGroup } from '@cc98/api' + +import { cacheService } from './utils' + +/** + * 获取所有版面信息 + */ +export const getBoardsInfo = cacheService( + () => GET('board/all'), + 'boardsInfo', + 3600 * 24 * 7 +) /** - * @description 获取版面信息 - * @returns {Promise>} + * 通过版面Id获取版面名称 */ -export async function getBoardsInfo(): Promise> { - const cache = getLocalStorage('boardsInfo') as IBaseBoard[] | undefined +export const getBoardNameById = (() => { + // cache + let _hasMap = false + const _BoardNameCacheMap: { + [key: number]: string + } = {} - if (cache) { - return Promise.resolve(Try.of(Success.of(cache))) + return async (id: number) => { + if (!_hasMap) { + const res = await getBoardsInfo() + res.fail().succeed(boards => { + for (const baseBoard of boards) { + for (const childBoard of baseBoard.boards) { + _BoardNameCacheMap[childBoard.id] = childBoard.name + } + } + _hasMap = true + }) + } + + return _BoardNameCacheMap[id] || '版面不存在' } +})() - const res = await GET('/board/all') - res.succeed(data => setLocalStorage('boardsInfo', data)) +/** + * 获取单个版面信息 + */ +export function getBoardInfo(id: string) { + return GET(`board/${id}`) +} + +/** + * 获取某个版面的标签组 + */ +export async function getBoardTags(id: string | number) { + return GET(`board/${id}/tag`) +} + +/** + * 关注/取关版面 + */ +export function customBoard(id: number, opt: 0 | 1) { + const url = `me/custom-board/${id}` + if (opt === 1) { + return PUT(url) + } - return res + return DELETE(url) } diff --git a/src/services/editor.ts b/src/services/editor.ts new file mode 100644 index 0000000..a41c967 --- /dev/null +++ b/src/services/editor.ts @@ -0,0 +1,81 @@ +import { GET, POST, PUT } from '@/utils/fetch' + +import { IPost } from '@cc98/api' + +/** + * 获取帖子内容 + */ +export async function getOriginalPost(postId: number | string) { + return GET(`post/${postId}/original`) +} + +/** + * 上传图片 + */ +export async function uploadPicture(file: File) { + const formData = new FormData() + formData.append('files', file, file.name) + + return POST('file', { + headers: { + // Content-Type 置空 + }, + requestInit: { + body: formData, + }, + }) +} + +export interface IPostParams { + /** + * 标题 + */ + title: string + /** + * 回帖内容 + */ + content: string + /** + * 回帖格式 + */ + contentType: 0 | 1 +} + +export interface ITopicParams extends IPostParams { + /** + * 帖子类型 + */ + type: number + /** + * tags + */ + tag1?: number + tag2?: number +} + +/** + * 发帖 + */ +export async function postTopic(boardId: number | string, topicParams: ITopicParams) { + return POST(`board/${boardId}/topic`, { + params: topicParams, + }) +} + +/** + * 回帖 + */ +export async function replyTopic(topicId: number | string, postParams: IPostParams) { + return POST(`topic/${topicId}/post`, { + params: postParams, + }) +} + +/** + * 编辑帖子 + */ +export async function editorPost(topicId: number | string, params: ITopicParams | IPostParams) { + return PUT(`post/${topicId}`, { + params, + }) +} diff --git a/src/services/getBoardName.ts b/src/services/getBoardName.ts deleted file mode 100644 index 0381677..0000000 --- a/src/services/getBoardName.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { IBaseBoard } from '@cc98/api' - - -// export default async function getBoardNameById(id: number) { -// // 先找本地缓存 -// const boardsInfoStorageOrNull = getLocalStorage('boardsInfo') -// if (boardsInfoStorageOrNull) { -// for (const baseBoard of boardsInfoStorageOrNull as IBaseBoard[]) { -// for (const childBoard of baseBoard.boards) { -// if (id === childBoard.id) return childBoard.name -// } -// } - -// return '版面不存在' -// } - -// const boardsInfoData = await GET('/board/all') -// boardsInfoData.fail().succeed(async boardsInfo => { -// setLocalStorage('boardsInfo', boardsInfo, 3600) -// await getBoardNameById(id) -// }) -// } - -// FIXME: move -export default function(boards: IBaseBoard[], id: number) { - for (const baseBoard of boards) { - for (const childBoard of baseBoard.boards) { - if (id === childBoard.id) return childBoard.name - } - } - - return '版面不存在' -} diff --git a/src/services/getTagName.ts b/src/services/getTagName.ts deleted file mode 100644 index 270c0d8..0000000 --- a/src/services/getTagName.ts +++ /dev/null @@ -1,13 +0,0 @@ -interface T { - name: string - id: number -} - -// FIXME: move -export default function(tags: T[], id: number) { - for (const t of tags) { - if (t.id === id) return t.name - } - - return '未找到标签' -} diff --git a/src/services/global.ts b/src/services/global.ts new file mode 100644 index 0000000..b3f5100 --- /dev/null +++ b/src/services/global.ts @@ -0,0 +1,30 @@ +import { GET, POST } from '@/utils/fetch' +import { ISignIn, ISite, IConfig } from '@cc98/api' + +/** + * 获取全站基本信息 + */ +export function getSiteInfo() { + return GET('config/global') +} + +/** + * 获取全站主页信息 + */ +export function getHomeInfo() { + return GET('config/index') +} + +/** + * 获取签到信息 + */ +export function getSignState() { + return GET('me/signin') +} + +/** + * 签到 + */ +export function signIn() { + return POST('me/signin') +} diff --git a/src/services/manage.ts b/src/services/manage.ts new file mode 100644 index 0000000..2bcaedf --- /dev/null +++ b/src/services/manage.ts @@ -0,0 +1,34 @@ +import { POST, DELETE } from '../utils/fetch' + +export const operateWealth = ( + postId: number, + value: number, + reason: string, + operationType: number +) => + POST(`post/${postId}/operation`, { + params: { + reason, + operationType, + wealth: value, + }, + }) + +export const deletePost = (postId: number, reason: string) => + DELETE(`post/${postId}`, { + params: { + reason, + }, + }) + +export const stopPost = (postId: number, value: number, reason: string) => + POST(`post/${postId}/operation`, { + params: { + reason, + stopPostDays: value, + operationType: 1, + }, + }) + +export const cancelStopPost = (boardId: number, userId: number) => + DELETE(`/board/${boardId}/stop-post-user/${userId}`) diff --git a/src/services/message.ts b/src/services/message.ts new file mode 100644 index 0000000..8e5480a --- /dev/null +++ b/src/services/message.ts @@ -0,0 +1,26 @@ +import { GET } from '@/utils/fetch' +import { IRecentMessage, IMessageContent } from '@cc98/api' + +/** + * 获取近期私信列表 + */ +export function getRecentMessage(from: number) { + return GET('message/recent-contact-users', { + params: { + from, + size: 20, + }, + }) +} + +/** + * 获取私信内容 + */ +export function getMessageContent(userId: number | string, from: number, size: number) { + return GET(`message/user/${userId}`, { + params: { + from, + size, + }, + }) +} diff --git a/src/services/post.ts b/src/services/post.ts new file mode 100644 index 0000000..492cd8f --- /dev/null +++ b/src/services/post.ts @@ -0,0 +1,128 @@ +import { GET, PUT } from '@/utils/fetch' +import { IPost, ILike } from '@cc98/api' + +/** + * 获取一个帖子的10层楼 + */ +export function getPost(id: number, from: number) { + return GET(`topic/${id}/post`, { + params: { + from, + size: 10, + }, + }) +} + +/** + * 逆向获取帖子 + */ +export function getReversePost(id: number, from: number, total: number) { + const floor = total + 1 + /** + * case ex 34L floor = 34 from = 0 + * 请求 realFrom = 25 size = 10 -> floor = 34 from = 10 + * realFrom = 15 size = 10 -> floor = 34 from = 20 + * realFrom = 5 size = 10 -> floor = 34 from = 30 + * 1 < floor - from < 9 realFrom = 0 size = floor - from + 1 + * floor = from + 1 + * case ex 10L + * realFrom = 0 realSize = 10 + * from = 9 total = 9 + */ + const realFrom = floor - from - 10 > 0 ? floor - from - 10 : 0 + let realSize = from !== 0 && from === total ? 0 : 10 + if (floor - from < 9) { + realSize = floor - from + } + + return GET(`topic/${id}/post`, { + params: { + from: realFrom, + size: realSize, + }, + }).then(res => Promise.resolve(res.map(data => data.reverse()))) +} + +/** + * 获取一个帖子的单独一层 + */ +export function getSinglePost(id: number, from: number) { + return GET(`topic/${id}/post`, { + params: { + from, + size: 1, + }, + }) +} + +/** + * 追踪非匿名板块的用户 + */ +export function getTracePost(topicId: number, userId: number | string, from: number) { + return GET('post/topic/user', { + params: { + topicId, + userId, + from, + size: 10, + }, + }) +} + +/** + * 追踪匿名板块用户 + */ +export function getAnonymousTracePost(topicId: number, postId: number | string, from: number) { + return GET('post/topic/anonymous/user', { + params: { + topicId, + postId, + from, + size: 10, + }, + }) +} + +/** + * 获取热评 + */ +export function getHotPost(topicId: number) { + return GET(`topic/${topicId}/hot-post`) +} + +/** + * 获取赞/踩状态 + */ +export function getLikeState(topicId: number) { + return GET(`post/${topicId}/like`) +} + +/** + * 赞 + */ +export function putLike(topicId: number) { + return PUT(`post/${topicId}/like`, { + params: 1, + }) +} + +/** + * 踩 + */ +export function putDislike(topicId: number) { + return PUT(`post/${topicId}/like`, { + params: 2, + }) +} + +/** + * 用户评分 +1或-1 + */ +export function rate(id: number, value: 1 | -1, reason: string) { + return PUT(`post/${id}/rating`, { + params: { + value, + reason, + }, + }) +} diff --git a/src/services/topic.ts b/src/services/topic.ts new file mode 100644 index 0000000..e6b36de --- /dev/null +++ b/src/services/topic.ts @@ -0,0 +1,160 @@ +import { GET } from '@/utils/fetch' +import { ITopic, IHotTopic } from '@cc98/api' + +/** + * 根据id获取某个版面的置顶帖子 + */ +export function getTopTopics(id: string) { + return GET('topic/toptopics', { + params: { + boardid: id, + }, + }) +} + +/** + * 获取版面内帖子 + * @param id 版面id + * @param from 起始位置 + * @param size 请求数量 + * @param tag1 默认 -1 + * @param tag2 默认 -1 + */ +export function getTopicsInBoard(id: string, from: number, size: number, tag1 = -1, tag2 = -1) { + if (tag1 === -1 && tag2 === -1) { + return GET(`board/${id}/topic`, { + params: { + from, + size, + }, + }) + } + + const params: { [key: string]: string | number } = {} + + if (tag1 !== -1) { + params.tag1 = tag1 + } + if (tag2 !== -1) { + params.tag2 = tag2 + } + params.from = from + params.size = size + + interface Topics { + count: number + topics: ITopic[] + } + + return GET(`topic/search/board/${id}/tag`, { + params, + }).then(res => Promise.resolve(res.map(topics => topics.topics))) +} + +/** + * 获取帖子基本信息 + */ +export function getTopicInfo(id: number | string) { + return GET(`topic/${id}`) +} + +/** + * 获取新帖 + */ +export function getNewTopics(from: number) { + return GET('topic/new', { + params: { + from, + size: 20, + }, + }) +} + +/** + * 获取关注版面的帖子 + */ +export function getFollowBoardsTopics(from: number) { + return GET('me/custom-board/topic', { + params: { + from, + size: 20, + }, + }) +} + +/** + * 获取关注用户的帖子 + */ +export function getFollowUsersTopics(from: number) { + return GET('me/followee/topic', { + params: { + from, + size: 20, + }, + }) +} + +/** + * 搜索 + */ +export function searchTopics(keyword: string, from: number) { + return GET('topic/search', { + params: { + keyword: `${keyword}`, + from, + size: 20, + }, + }) +} + +/** + * 获取热门 + */ +export function getHotTopics() { + return GET('topic/hot') +} + +/** + * 获取本周热门 + */ +export function getWeeklyHotTopics() { + return GET('topic/hot-weekly') +} + +/** + * 获取本月热门 + */ +export function getMonthlyHotTopics() { + return GET('topic/hot-monthly') +} + +/** + * 获取历史热门 + */ +export function getHistoryHotTopics() { + return GET('topic/hot-history') +} + +/** + * 获取一个用户近期发的帖子 + */ +export function getUsersRecentTopics(id: number, from: number) { + return GET(`user/${id}/recent-topic`, { + params: { + from, + size: 20, + }, + }) +} + +/** + * 获取用户近期发的帖子 + */ +export function getMyRecentTopics(from: number) { + return GET('me/recent-topic', { + params: { + from, + size: 20, + }, + }) +} diff --git a/src/services/user.ts b/src/services/user.ts index 9456203..98d5c7d 100644 --- a/src/services/user.ts +++ b/src/services/user.ts @@ -1,21 +1,52 @@ -import { Try } from '@/utils/fp/Try' import { IUser } from '@cc98/api' -import { FetchError, GET } from '@/utils/fetch' +import { GET, PUT, DELETE } from '@/utils/fetch' + +import { memoize } from 'lodash-es' /** * @description 通过用户id获取用户信息 * @param {number} id 用户id - * @return {Promise>} */ -export function getUserInfoById(id: number): Promise> { - return GET(`/user/${id}`) -} +export const getUserInfoById = memoize((id: string | number) => GET(`user/${id}`)) /** * @description 通过用户名获取用户信息 * @param {string} name 用户名 - * @return {Promise>} */ -export function getUserInfoByName(name: string): Promise> { - return GET(`/user/name/${name}`) +export function getUserInfoByName(name: string) { + return GET(`user/name/${name}`) +} + +/** + * @description 通过 用户ID 批量获取用户信息 + */ +export function getUsersInfoByIds(ids: number[]) { + const query = ids.map(id => `id=${id}`).join('&') + + return GET(`user?${query}`) +} + +/** + * 关注一个用户 + * @param id 用户 ID + */ +export function followUser(id: number) { + return PUT(`me/followee/${id}`) +} + +/** + * 取关一个用户 + * @param id 用户 ID + */ +export function unFollowUser(id: number) { + return DELETE(`me/followee/${id}`) +} + +/** + * 修改个人资料 + */ +export function modifyMyInfo(newInfo: IUser) { + return PUT('me', { + params: newInfo, + }) } diff --git a/src/services/utils/errorHandler.ts b/src/services/utils/errorHandler.ts new file mode 100644 index 0000000..4f62f71 --- /dev/null +++ b/src/services/utils/errorHandler.ts @@ -0,0 +1,61 @@ +import { FetchError } from '../../utils/fetch' +import { navigate } from '@/utils/history' +import snackbar from '@/utils/snackbar' + +export function notificationHandler(err: FetchError) { + if (err.status === 400) { + snackbar.error('请求无效') + } else if (err.status === 401) { + snackbar.error('您未登陆或没有权限进行此操作') + } else if (err.status === 403) { + snackbar.error('您的操作是被禁止的') + } else if (err.status === 404) { + snackbar.error('找不到此页面') + } else if (err.status === 500 || err.status === 502 || err.status === 503) { + snackbar.error('服务器内部错误') + } +} + +export function navigateHandler(err: FetchError) { + if (err.status === 400) { + navigate('/error/400') + } else if (err.status === 401) { + navigate('/error/401') + } else if (err.status === 403) { + navigate('/error/403') + } else if (err.status === 404) { + navigate('/error/404') + } else if (err.status === 500 || err.status === 502 || err.status === 503) { + navigate('/error/500') + } +} + +export function rateHandler(err: FetchError) { + if (err.msg === 'cannot_rate_yourself') { + snackbar.error('您不能给自己评分') + } else if (err.msg === 'has_rated_tody') { + snackbar.error('您今天已经评过分了,请明天再来') + } else if (err.msg === 'you_cannot_rate') { + snackbar.error('您发帖还不足500,不能评分') + } else if (err.msg === 'has_rated_this_post') { + snackbar.error('您已经给这个贴评过分了') + } else if (err.msg === 'post_user_not_exists') { + snackbar.error('这个回复的账号已经不存在了') + } +} + +export function loginHandler(err: FetchError) { + // TODO: +} + +export function manageHandler(err: FetchError) { + if (err.msg === 'reward_wealth_limited') { + snackbar.error('您不能给自己评分') + } else if (err.status === 400) { + snackbar.error('输入有误') + } else if (err.status === 401) { + snackbar.error('您的权限不足') + } else if (err.status === 500) { + snackbar.error('服务器内部错误') + } +} diff --git a/src/services/utils/index.ts b/src/services/utils/index.ts new file mode 100644 index 0000000..6106a00 --- /dev/null +++ b/src/services/utils/index.ts @@ -0,0 +1,38 @@ +import { Try, Success } from '@/utils/fp/Try' +import { FetchError } from '@/utils/fetch' + +import { getLocalStorage, setLocalStorage } from '@/utils/storage' + +type Service = () => Promise> + +/** + * 缓存一个无参数服务 + * @param service 服务 + * @param name 缓存使用的 key + * @param expireIn 本地缓存有效时间 + */ +export function cacheService( + service: Service, + name: string, + expireIn = 0 +) { + const key = `cache-${name}` + let cache = getLocalStorage(key) as T | null + + return () => { + if (cache) { + return Promise.resolve(Try.of(Success.of(cache))) + } + + const response = service() + + response.then(res => { + res.fail().succeed(value => { + cache = value + setLocalStorage(key, value, expireIn) + }) + }) + + return response + } +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..4656a66 --- /dev/null +++ b/src/style.css @@ -0,0 +1,40 @@ +* { + box-sizing: border-box; +} + +html { + height: 100%; +} + +body { + margin: 0; + height: 100%; + /* 禁止 Safari 的双击放大 */ + touch-action: manipulation; + /* 平滑滚动 */ + scroll-behavior: smooth; +} + +#root { + height: 100%; +} + +/* https://stackoverflow.com/questions/2781549/removing-input-background-colour-for-chrome-autocomplete */ +@keyframes autofill { + to { + color: #666; + background: transparent; + } +} + +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus { + animation-name: autofill; + animation-fill-mode: both; +} + +/* https://stackoverflow.com/questions/5106934/prevent-grey-overlay-on-touchstart-in-mobile-safari-webview */ +div { + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} diff --git a/src/utils/fetch.ts b/src/utils/fetch.ts index 530cd51..5f69c55 100644 --- a/src/utils/fetch.ts +++ b/src/utils/fetch.ts @@ -1,6 +1,6 @@ /* tslint:disable */ -import { getLocalStorage, setLocalStorage } from './storage' -import { Failure, Success, Try } from './fp/Try' +import { Try, Success, Failure } from './fp/Try' +import { getAccessToken } from './logIn' import host from '@/config/host' @@ -21,12 +21,9 @@ export interface FetchError { } async function cc98Fetch(url: string, init: RequestInit): Promise> { - // const baseUrl = "https://apitest.niconi.cc" - // const baseUrl = "https://api-v2.cc98.org" const baseUrl = host.api const requestURL = `${baseUrl}/${url}` - // console.log("Fetch: " + requestURL) const response = await fetch(requestURL, init) if (!(response.ok && response.status === 200)) { @@ -45,6 +42,7 @@ async function cc98Fetch(url: string, init: RequestInit): Promise(url: string, options: GETOptions = {}) { +export async function GET(url: string, options: GETOptions = {}) { const headers: Record = {} if (!options.noAuthorization) { @@ -113,7 +111,7 @@ interface POSTOptions { params?: any } -export async function POST(url: string, options: POSTOptions = {}) { +export async function POST(url: string, options: POSTOptions = {}) { const headers: Record = {} if (!options.noAuthorization) { @@ -138,7 +136,7 @@ export async function POST(url: string, options: POSTOptions = {}) { type PUTOptions = POSTOptions -export async function PUT(url: string, options: PUTOptions = {}) { +export async function PUT(url: string, options: PUTOptions = {}) { const headers: Record = {} if (!options.noAuthorization) { @@ -163,7 +161,7 @@ export async function PUT(url: string, options: PUTOptions = {}) { type DELETEOptions = GETOptions -export async function DELETE(url: string, options: DELETEOptions = {}) { +export async function DELETE(url: string, options: DELETEOptions = {}) { const headers: Record = {} if (!options.noAuthorization) { @@ -175,8 +173,11 @@ export async function DELETE(url: string, options: DELETEOptions = {}) method: 'DELETE', headers: new Headers({ ...headers, - ...options.headers, + ...(options.headers || { + 'Content-Type': 'application/json', + }), }), + body: options.params && JSON.stringify(options.params), ...options.requestInit, } @@ -186,126 +187,8 @@ export async function DELETE(url: string, options: DELETEOptions = {}) /** * just like $.param */ -function encodeParams(params: { [key: string]: string }) { +export function encodeParams(params: { [key: string]: string | number }) { return Object.keys(params) - .map(key => encodeURIComponent(key) + '=' + encodeURIComponent(params[key])) + .map(key => encodeURIComponent(key) + '=' + encodeURIComponent(`${params[key]}`)) .join('&') } - -/** - * 从本地取得 access_token,如果过期尝试刷新 - */ -export async function getAccessToken(): Promise { - let accessToken = getLocalStorage('access_token') - - if (!accessToken) { - const refreshToken = getLocalStorage('refresh_token') - - if (!refreshToken) { - return '' - } - - const token = await getTokenByRefreshToken(refreshToken) - token - .fail - // TODO: 添加 refresh token 过期的处理 - () - .succeed(token => { - const access_token = `${token.token_type} ${token.access_token}` - setLocalStorage('access_token', access_token, token.expires_in) - // refresh_token 有效期一个月 - setLocalStorage('refresh_token', token.refresh_token, 2592000) - - accessToken = access_token - }) - } - - return accessToken -} - -interface Token { - access_token: string - expires_in: number - refresh_token: string - token_type: string -} - -/** - * 使用refresh_token获取token - * @param {string} refreshToken - * @return {Promise>} - */ -async function getTokenByRefreshToken(refreshToken: string): Promise> { - const requestBody = { - client_id: '9a1fd200-8687-44b1-4c20-08d50a96e5cd', - client_secret: '8b53f727-08e2-4509-8857-e34bf92b27f2', - grant_type: 'refresh_token', - refresh_token: refreshToken, - } - - const response = await fetch(host.oauth, { - method: 'POST', - headers: new Headers({ - 'Content-Type': 'application/x-www-form-urlencoded', - }), - body: encodeParams(requestBody), - }) - - if (!(response.ok && response.status === 200)) { - return Try.of( - Failure.of({ - status: response.status, - msg: await response.text(), - response, - }) - ) - } - - return Try.of(Success.of(await response.json())) -} - -/** - * 登录 - */ -export async function logIn(username: string, password: string): Promise> { - /** - * 请求的正文部分 - * 密码模式需要 5个参数 - * 其中 client_id 和 client_secret 来自申请的应用,grant_type 值为 "password" - */ - const requestBody = { - client_id: '9a1fd200-8687-44b1-4c20-08d50a96e5cd', - client_secret: '8b53f727-08e2-4509-8857-e34bf92b27f2', - grant_type: 'password', - username, - password, - scope: 'cc98-api openid offline_access', - } - - const response = await fetch(host.oauth, { - method: 'POST', - headers: new Headers({ - 'Content-Type': 'application/x-www-form-urlencoded', - }), - body: encodeParams(requestBody), - }) - - if (!(response.ok && response.status === 200)) { - return Try.of( - Failure.of({ - status: response.status, - msg: await response.text(), - response, - }) - ) - } - - const token = await response.json() - - const access_token = `${token.token_type} ${token.access_token}` - setLocalStorage('access_token', access_token, token.expires_in) - // refresh_token 有效期一个月 - setLocalStorage('refresh_token', token.refresh_token, 2592000) - - return Try.of(Success.of(token)) -} diff --git a/src/utils/history.ts b/src/utils/history.ts new file mode 100644 index 0000000..2570559 --- /dev/null +++ b/src/utils/history.ts @@ -0,0 +1,33 @@ +import { navigate as reachNavigate, NavigateOptions } from '@reach/router' + +// TODO: 忽略不需要 cache 的页面 + +/** + * 路由跳转到对应 URL + * @param url + * @param options + */ +export function navigate(url: string, options?: NavigateOptions<{}>) { + reachNavigate(url, options) +} + +export function go(delta?: number | undefined) { + window.history.go(delta) +} + +/** + * 路由回退 + */ +export function goback() { + // if (forceRefresh) { + // // ROUTER_CACHE + // } + + window.history.back() +} + +export default { + navigate, + go, + back: goback, +} diff --git a/src/utils/logIn.ts b/src/utils/logIn.ts new file mode 100644 index 0000000..7682de3 --- /dev/null +++ b/src/utils/logIn.ts @@ -0,0 +1,141 @@ +/* tslint:disable */ +import { Try, Success, Failure } from './fp/Try' +import { FetchError, encodeParams } from './fetch' +import { getLocalStorage, setLocalStorage, removeLocalStorage } from './storage' + +import host from '@/config/host' + +/** + * 访问令牌 + */ +export interface Token { + access_token: string + expires_in: number + refresh_token: string + token_type: string +} + +/** + * 从本地取得 access_token,如果过期尝试刷新 + */ +export async function getAccessToken(): Promise { + let accessToken = getLocalStorage('access_token') + + if (!accessToken) { + const refreshToken = getLocalStorage('refresh_token') as string + + if (!refreshToken) { + return '' + } + + const token = await getTokenByRefreshToken(refreshToken) + token + .fail(() => { + // TODO: 添加 refresh token 过期的处理 + }) + .succeed(token => { + const access_token = `${token.token_type} ${token.access_token}` + setLocalStorage('access_token', access_token, token.expires_in) + // refresh_token 有效期一个月 + setLocalStorage('refresh_token', token.refresh_token, 2592000) + + accessToken = access_token + }) + } + + return accessToken as string +} + +/** + * 使用refresh_token获取token + */ +async function getTokenByRefreshToken(refreshToken: string) { + const requestBody = { + client_id: '9a1fd200-8687-44b1-4c20-08d50a96e5cd', + client_secret: '8b53f727-08e2-4509-8857-e34bf92b27f2', + grant_type: 'refresh_token', + refresh_token: refreshToken, + } + + const response = await fetch(host.oauth, { + method: 'POST', + headers: new Headers({ + 'Content-Type': 'application/x-www-form-urlencoded', + }), + body: encodeParams(requestBody), + }) + + if (!(response.ok && response.status === 200)) { + // TODO: maybe can remove ? + return Try.of( + Failure.of({ + status: response.status, + msg: await response.text(), + response, + }) + ) + } + + return Try.of(Success.of(await response.json())) +} + +/** + * 登录 + */ +export async function logIn(username: string, password: string) { + /** + * 请求的正文部分 + * 密码模式需要 5个参数 + * 其中 client_id 和 client_secret 来自申请的应用,grant_type 值为 "password" + */ + const requestBody = { + client_id: '9a1fd200-8687-44b1-4c20-08d50a96e5cd', + client_secret: '8b53f727-08e2-4509-8857-e34bf92b27f2', + grant_type: 'password', + username, + password, + scope: 'cc98-api openid offline_access', + } + + const response = await fetch(host.oauth, { + method: 'POST', + headers: new Headers({ + 'Content-Type': 'application/x-www-form-urlencoded', + }), + body: encodeParams(requestBody), + }) + + if (!(response.ok && response.status === 200)) { + return Try.of( + Failure.of({ + status: response.status, + msg: await response.text(), + response, + }) + ) + } + + const token = await response.json() + + const access_token = `${token.token_type} ${token.access_token}` + setLocalStorage('access_token', access_token, token.expires_in) + // refresh_token 有效期一个月 + setLocalStorage('refresh_token', token.refresh_token, 2592000) + + return Try.of(Success.of(token)) +} + +/** + * 登出 + */ +export function logOut() { + removeLocalStorage('access_token') + removeLocalStorage('refresh_token') +} + +/** + * 判断是否登录 + */ +export function isLogIn() { + return !!getLocalStorage('refresh_token') +} diff --git a/src/utils/snackbar/MySnackbarContent.tsx b/src/utils/snackbar/MySnackbarContent.tsx new file mode 100644 index 0000000..16373e0 --- /dev/null +++ b/src/utils/snackbar/MySnackbarContent.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import styled from 'styled-components' + +import { IconButton, SnackbarContent } from '@material-ui/core' + +import InfoIcon from '@material-ui/icons/Info' +import CheckCircleIcon from '@material-ui/icons/CheckCircle' +import ErrorIcon from '@material-ui/icons/Error' +// import WarningIcon from '@material-ui/icons/Warning' + +import CloseIcon from '@material-ui/icons/Close' + +import green from '@material-ui/core/colors/green' +import red from '@material-ui/core/colors/red' + +const IconMap = { + info: InfoIcon, + success: CheckCircleIcon, + error: ErrorIcon, +} + +const ColorMap = { + info: undefined, + success: green[600], + error: red[600], +} + +interface Props { + variant: 'info' | 'success' | 'error' + message: string + onClose: () => void +} + +const MessageDiv = styled.div` + display: flex; + align-items: center; +` + +const Message = styled.div` + margin-left: 1rem; +` + +const MySnackbarContent = ({ variant, message, onClose }: Props) => { + const Icon = IconMap[variant] + + return ( + + + {message} + + } + action={ + + + } + /> + ) +} + +export default MySnackbarContent diff --git a/src/utils/snackbar/index.tsx b/src/utils/snackbar/index.tsx new file mode 100644 index 0000000..57d577e --- /dev/null +++ b/src/utils/snackbar/index.tsx @@ -0,0 +1,87 @@ +import React, { useState } from 'react' +import ReactDOM from 'react-dom' +import { Snackbar } from '@material-ui/core' +import { SnackbarProps } from '@material-ui/core/Snackbar' + +import MySnackbarContent from './MySnackbarContent' + +// https://material-ui.com/demos/snackbars/#snackbars +// Only one snackbar may be displayed at a time. + +type ISnackBar = Pick & { + message: string + variant?: 'info' | 'success' | 'error' +} + +/** + * 指向 MySnackBar 的引用 + */ +let snackBarRef!: (props: ISnackBar) => void + +const MySnackBar: React.FunctionComponent = () => { + const [open, setOpen] = useState(false) + const handleClose = () => setOpen(false) + + const [snackBarProps, setSnackBarProps] = useState({ + message: 'NO MSG', + }) + + const pushSnackBar = (props: ISnackBar) => { + setSnackBarProps(props) + setOpen(true) + } + + snackBarRef = pushSnackBar + + return ( + + + + ) +} + +function snackbar(snackBarProps: ISnackBar) { + snackBarRef(snackBarProps) +} + +snackbar.info = (message: string) => { + snackbar({ + variant: 'info', + message, + }) +} + +snackbar.success = (message: string) => { + snackbar({ + variant: 'success', + message, + }) +} + +snackbar.error = (message: string) => { + snackbar({ + variant: 'error', + message, + }) +} + +export default snackbar + +// mount the Component to DOM +function renderSnackBar() { + ReactDOM.render(, document.getElementById('snackbar')) +} +renderSnackBar() diff --git a/src/utils/storage.ts b/src/utils/storage.ts index f00539a..d75c7e7 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -36,8 +36,14 @@ export function getLocalStorage(key: string): string | object | null { if (!value) { return null } + if (value.startsWith('obj-')) { + return JSON.parse(value.slice(4)) + } + if (value.startsWith('str-')) { + return value.slice(4) + } - return value.startsWith('obj-') ? JSON.parse(value.slice(4)) : value.slice(4) + return value } /** diff --git a/src/version.ts b/src/version.ts index 4432742..d4c4f87 100644 --- a/src/version.ts +++ b/src/version.ts @@ -2,4 +2,4 @@ * 版本号 */ -export default 'v0.11.1-alpha' +export default 'v1.0.0-beta' diff --git a/tsconfig.json b/tsconfig.json index 111c434..ea7b08c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "allowJs": false, "target": "es6", "jsx": "preserve", + "noEmitOnError": true, "sourceMap": false, "baseUrl": "./", "rootDir": "src", diff --git a/tslint.json b/tslint.json index 72443f0..9c0d8fa 100644 --- a/tslint.json +++ b/tslint.json @@ -7,53 +7,50 @@ "tslint-eslint-rules", "tslint-lines-between-class-members" ], - "jsRules": { - "import-name": false - }, "rules": { "align": [true, "elements", "members", "parameters", "statements"], - "prefer-object-spread": true, "array-type": [true, "array"], - "no-unused-variable": true, - "indent": [true, "spaces", 2], - "ordered-imports": false, - "no-duplicate-imports": false, + "arrow-return-shorthand": [true, "multiline"], "eofline": true, + "function-name": false, + "import-name": false, + "indent": [true, "spaces", 2], + "interface-name": false, + "jsx-boolean-value": ["never"], + "jsx-no-lambda": false, + "jsx-no-multiline-js": false, + "lines-between-class-members": true, "max-classes-per-file": [true, 1], "max-file-line-count": [true, 500], - "arrow-return-shorthand": [true, "multiline"], + "member-access": false, + "member-ordering": false, "newline-before-return": true, + "no-any": true, + "no-boolean-literal-compare": true, "no-consecutive-blank-lines": [true], + "no-duplicate-imports": false, + "no-increment-decrement": false, "no-irregular-whitespace": true, + "no-shadowed-variable": false, "no-trailing-whitespace": true, - "lines-between-class-members": true, - "semicolon": false, - "no-boolean-literal-compare": true, + "no-unused-variable": true, "object-literal-sort-keys": false, - "member-ordering": false, - "member-access": false, - "jsx-boolean-value": ["never"], + "object-shorthand-properties-first": false, + "ordered-imports": false, + "prefer-object-spread": true, + "semicolon": false, "syntax-preference": [2, "string"], - "variable-name": false, - "import-name": false, "ter-arrow-parens": [true, "as-needed"], - "trailing-comma": [ - true, - { - "multiline": { - "objects": "always", - "arrays": "always", - "functions": "never", - "typeLiterals": "ignore" - }, - "esSpecCompliant": true - } - ], - "jsx-no-multiline-js": false, - "jsx-no-lambda": false, - "interface-name": false, - "no-any": true, - "function-name": false + "trailing-comma": [true, { + "multiline": { + "objects": "always", + "arrays": "always", + "functions": "never", + "typeLiterals": "ignore" + }, + "esSpecCompliant": true + }], + "variable-name": false }, "rulesDirectory": [] }