Roxas 3 years ago
commit
ae37bbea29
58 changed files with 31281 additions and 0 deletions
  1. 5
    0
      .gitignore
  2. 75
    0
      .storybook/main.js
  3. 3
    0
      .storybook/preview.js
  4. 17389
    0
      package-lock.json
  5. 31
    0
      package.json
  6. 56
    0
      src/components/comment/CommentItem.less
  7. 236
    0
      src/components/comment/CommentItem.tsx
  8. 3
    0
      src/components/comment/CommentList.less
  9. 32
    0
      src/components/comment/CommentList.tsx
  10. 13
    0
      src/components/comment/README.md
  11. 43
    0
      src/components/comment/common/AudioPlayer.less
  12. 149
    0
      src/components/comment/common/AudioPlayer.tsx
  13. 11
    0
      src/components/comment/common/CollapseBtn.less
  14. 29
    0
      src/components/comment/common/CollapseBtn.tsx
  15. 5
    0
      src/components/comment/common/ContentRender.less
  16. 96
    0
      src/components/comment/common/ContentRender.tsx
  17. 45
    0
      src/components/comment/common/ItemToolBar.less
  18. 175
    0
      src/components/comment/common/ItemToolBar.tsx
  19. 47
    0
      src/components/comment/common/ReplyCard.less
  20. 110
    0
      src/components/comment/common/ReplyCard.tsx
  21. 7
    0
      src/components/comment/common/ScheduleIconFont.tsx
  22. 38
    0
      src/components/comment/config/constant.ts
  23. 22
    0
      src/components/comment/context/index.ts
  24. 37
    0
      src/components/comment/demo.jsx
  25. 121
    0
      src/components/comment/index.jsx
  26. 3
    0
      src/components/comment/index.less
  27. 30
    0
      src/components/comment/utils/commentAjaxLogi.ts
  28. 11
    0
      src/components/comment/utils/dayjsImport.ts
  29. 51
    0
      src/components/comment/utils/index.ts
  30. 23
    0
      src/components/editor/OldCommentEditor.tsx
  31. 0
    0
      src/components/editor/OldCommontEditor.less
  32. 1
    0
      src/components/editor/index.tsx
  33. 2
    0
      src/components/lessStyles/globalColor.less
  34. 14
    0
      src/helper/index.ts
  35. 131
    0
      src/services/comment.ts
  36. 4
    0
      src/typings/index.d.ts
  37. 50
    0
      stories/Button.js
  38. 36
    0
      stories/Button.stories.js
  39. 17
    0
      stories/Comment.stories.js
  40. 16
    0
      stories/Editor.stories.js
  41. 52
    0
      stories/Header.js
  42. 18
    0
      stories/Header.stories.js
  43. 194
    0
      stories/Introduction.stories.mdx
  44. 71
    0
      stories/Page.js
  45. 21
    0
      stories/Page.stories.js
  46. 8
    0
      stories/assets/code-brackets.svg
  47. 13
    0
      stories/assets/colors.svg
  48. 9
    0
      stories/assets/comments.svg
  49. 8
    0
      stories/assets/direction.svg
  50. 8
    0
      stories/assets/flow.svg
  51. 8
    0
      stories/assets/plugin.svg
  52. 9
    0
      stories/assets/repo.svg
  53. 10
    0
      stories/assets/stackalt.svg
  54. 30
    0
      stories/button.css
  55. 26
    0
      stories/header.css
  56. 0
    0
      stories/page.css
  57. 50
    0
      tsconfig.json
  58. 11579
    0
      yarn.lock

+ 5
- 0
.gitignore View File

@@ -0,0 +1,5 @@
1
+node_modules
2
+build
3
+npm-debug.log
4
+.env
5
+.DS_Store

+ 75
- 0
.storybook/main.js View File

@@ -0,0 +1,75 @@
1
+const path = require("path");
2
+// const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
3
+
4
+module.exports = {
5
+  stories: [
6
+    "../stories/**/*.stories.mdx",
7
+    "../stories/**/*.stories.@(js|jsx|ts|tsx)",
8
+  ],
9
+  addons: ["@storybook/addon-links", "@storybook/addon-essentials"],
10
+  webpackFinal: async (config) => {
11
+    config.resolve.alias = {
12
+      "@": path.resolve(__dirname, "..", "src"),
13
+      "@components": path.resolve(__dirname, "..", "src/components"),
14
+      "@services": path.resolve(__dirname, "..", "src/services"),
15
+    };
16
+
17
+    // config.resolve.plugins = [new TsconfigPathsPlugin()];
18
+
19
+    config.module.rules.push({
20
+      test: /\.(ts|tsx)$/,
21
+      use: [
22
+        {
23
+          loader: require.resolve("babel-loader"),
24
+          options: {
25
+            presets: [require.resolve("babel-preset-react-app")],
26
+          },
27
+        },
28
+      ],
29
+    });
30
+
31
+    config.module.rules.push({
32
+      test: /\.less$/,
33
+      include: /node_modules|antd\.less/,
34
+      use: [
35
+        "style-loader",
36
+        "css-loader",
37
+        {
38
+          loader: "less-loader",
39
+          options: {
40
+            lessOptions: {
41
+              javascriptEnabled: true,
42
+            },
43
+          },
44
+        }
45
+      ]
46
+    });
47
+
48
+    config.module.rules.push({
49
+      test: /\.less$/,
50
+      loaders: [
51
+        "style-loader",
52
+        {
53
+          loader: "css-loader",
54
+          options: {
55
+            modules: {
56
+              localIdentName: '[name]__[local]___[hash:base64:5]',
57
+            },
58
+            importLoaders: 1,
59
+          },
60
+        },
61
+        {
62
+          loader: "less-loader",
63
+          options: {
64
+            lessOptions: {
65
+              javascriptEnabled: true,
66
+            },
67
+          },
68
+        },
69
+      ],
70
+      exclude: /node_modules|antd\.less/,
71
+    });
72
+
73
+    return config;
74
+  },
75
+};

+ 3
- 0
.storybook/preview.js View File

@@ -0,0 +1,3 @@
1
+export const parameters = {
2
+  actions: { argTypesRegex: "^on[A-Z].*" },
3
+}

+ 17389
- 0
package-lock.json
File diff suppressed because it is too large
View File


+ 31
- 0
package.json View File

@@ -0,0 +1,31 @@
1
+{
2
+  "name": "base-ui",
3
+  "version": "1.0.0",
4
+  "main": "index.js",
5
+  "license": "MIT",
6
+  "devDependencies": {
7
+    "@babel/core": "^7.11.1",
8
+    "@babel/preset-env": "^7.11.0",
9
+    "@storybook/addon-actions": "^6.0.5",
10
+    "@storybook/addon-essentials": "^6.0.5",
11
+    "@storybook/addon-info": "^5.3.19",
12
+    "@storybook/addon-links": "^6.0.5",
13
+    "@storybook/react": "^6.0.5",
14
+    "@types/classnames": "^2.2.10",
15
+    "@types/storybook__addon-info": "^5.2.1",
16
+    "babel-loader": "^8.1.0",
17
+    "babel-preset-react-app": "^9.1.2",
18
+    "less-loader": "^6.2.0",
19
+    "react-is": "^16.13.1",
20
+    "tsconfig-paths-webpack-plugin": "^3.3.0"
21
+  },
22
+  "dependencies": {
23
+    "@ant-design/icons": "^4.2.2",
24
+    "antd": "^4.5.4",
25
+    "axios": "^0.19.2"
26
+  },
27
+  "scripts": {
28
+    "storybook": "start-storybook -p 6006",
29
+    "build-storybook": "build-storybook"
30
+  }
31
+}

+ 56
- 0
src/components/comment/CommentItem.less View File

@@ -0,0 +1,56 @@
1
+@import "../lessStyles/globalColor.less";
2
+
3
+.wrapper {
4
+  display: flex; 
5
+}
6
+
7
+.contentWrapper {
8
+  margin-top: 8px;
9
+  margin-bottom: 8px;
10
+}
11
+
12
+.avatar {
13
+  width: 40px;
14
+  height: 40px;
15
+  margin-left: 22px;
16
+  margin-right: 22px;
17
+
18
+  .avatarImg {
19
+    width: 100%;
20
+    height: 100%;
21
+    border-radius: 50%;
22
+    background-position: center;
23
+    background-size: cover;
24
+  }
25
+}
26
+
27
+.content {
28
+  display: flex;
29
+  flex-direction: column;
30
+  justify-content: flex-start;
31
+  width: 100%;
32
+
33
+  & > header {
34
+    display: flex;
35
+
36
+    h4 { margin: 0; }
37
+    span { margin-left: 8px; }
38
+  }
39
+
40
+  .speakContent {
41
+    &:extend(.contentWrapper);
42
+    color: @comment_green;
43
+  }
44
+  .speakMediaWrapper {
45
+    width: 350px;
46
+    height: 44px;
47
+  }
48
+
49
+  .commentContent {
50
+    &:extend(.contentWrapper);
51
+  }
52
+}
53
+
54
+.replyWrapper {
55
+
56
+}

+ 236
- 0
src/components/comment/CommentItem.tsx View File

@@ -0,0 +1,236 @@
1
+import React, { Component } from "react";
2
+import classnames from "classnames";
3
+import { Tooltip, Divider } from "antd";
4
+
5
+import { LOCALE_DAYJS } from "./config/constant";
6
+import { CommentContext } from "./context";
7
+import { CollapseBtn } from "./common/CollapseBtn";
8
+import { ReplyCard } from "./common/ReplyCard";
9
+import IconFont from "./common/ScheduleIconFont";
10
+import ItemToolBar, { TOOL_ACTION_TYPE } from "./common/ItemToolBar";
11
+import { getUserType, AuthType } from "./utils";
12
+import dayjs from "./utils/dayjsImport";
13
+
14
+import styles from "./CommentItem.less";
15
+import AudioPlayer from "./common/AudioPlayer";
16
+import { ContentRender } from "./common/ContentRender";
17
+import { toggleFavor } from "./utils/commentAjaxLogi";
18
+import OldCommontEditor from '../editor/OldCommentEditor';
19
+
20
+export interface CommentItemData {
21
+  content: string;
22
+  created: number;
23
+  favor_count: number;
24
+  favored: boolean;
25
+  id: string;
26
+  is_speak: boolean;
27
+  medias: any;
28
+  replies: Array<any>;
29
+  reply_count: number;
30
+  user_avatar: string;
31
+  user_id: number;
32
+  user_name: string;
33
+}
34
+
35
+interface IP {
36
+  data: CommentItemData;
37
+  onChangeListItem({
38
+    commentId,
39
+    changeProp,
40
+  }: {
41
+    commentId: string;
42
+    changeProp: any;
43
+  }): any;
44
+}
45
+
46
+interface IS {
47
+  showReply: boolean;
48
+  showReplyEditor: boolean;
49
+}
50
+
51
+export default class CommentItem extends Component<IP, IS> {
52
+  static contextType = CommentContext;
53
+  context!: React.ContextType<typeof CommentContext>;
54
+
55
+  constructor(props: IP) {
56
+    super(props);
57
+    this.state = {
58
+      showReply: false,
59
+      showReplyEditor: false,
60
+    };
61
+  }
62
+
63
+  get authType(): AuthType {
64
+    const { currentUser } = this.context;
65
+    const { user_id } = this.props.data;
66
+    return getUserType(currentUser, user_id);
67
+  }
68
+
69
+  renderCommentTime = (created: number) => {
70
+    const { locale } = this.context;
71
+    return (
72
+      <Tooltip
73
+        placement="top"
74
+        title={dayjs(created * 1000).format("YYYY-MM-DD HH:mm:ss")}
75
+      >
76
+        <span>
77
+          {LOCALE_DAYJS[locale]
78
+            ? dayjs(created * 1000)
79
+                .locale(LOCALE_DAYJS[locale])
80
+                .fromNow()
81
+            : dayjs(created * 1000).fromNow()}
82
+        </span>
83
+      </Tooltip>
84
+    );
85
+  };
86
+
87
+  renderContent = () => {
88
+    const { is_speak, medias } = this.props.data;
89
+    if (is_speak) {
90
+      return (
91
+        <>
92
+          <div className={styles.speakContent}>[跟读语音]</div>
93
+          <div className={styles.speakMediaWrapper}>
94
+            <AudioPlayer src={medias && medias[0] && medias[0].url} />
95
+          </div>
96
+        </>
97
+      );
98
+    }
99
+    return (
100
+      <div className={styles.commentContent}>
101
+        <ContentRender data={this.props.data} />
102
+      </div>
103
+    );
104
+  };
105
+
106
+  renderToolBar = () => {
107
+    const { currentUser }: { currentUser: any } = this.context;
108
+    const { showReply, showReplyEditor } = this.state;
109
+    const { onChangeListItem } = this.props;
110
+    const { id, reply_count, favor_count, favored, user_id } = this.props.data;
111
+    // TODO: normal action and private action
112
+    // console.log('this.props.data: ', this.props.data);
113
+    return (
114
+      <ItemToolBar
115
+        authType={this.authType}
116
+        handleReplyClick={() => {
117
+          this.setState({ showReply: !showReply });
118
+          return true;
119
+        }}
120
+        handleNormalClick={async (actionType: string) => {
121
+          console.log("actionType: ", actionType);
122
+          if (actionType === TOOL_ACTION_TYPE.REPLY) {
123
+            this.setState({ showReplyEditor: !showReplyEditor });
124
+          }
125
+          if (actionType === TOOL_ACTION_TYPE.FAVOR) {
126
+            if (currentUser) {
127
+              const res = await toggleFavor({
128
+                favored,
129
+                id,
130
+                commentId: id,
131
+                userId: currentUser.id,
132
+              });
133
+              console.log("res: ", res);
134
+              if (res) {
135
+                onChangeListItem({
136
+                  commentId: res.id,
137
+                  changeProp: { favor_count: res.favor_count, favored: !favored },
138
+                });
139
+              }
140
+            }
141
+          }
142
+          return true;
143
+        }}
144
+        handlePrivateClick={(actionType: string) => {
145
+          return true;
146
+        }}
147
+        replyCount={reply_count}
148
+        favorCount={favor_count}
149
+        favored={favored}
150
+      />
151
+    );
152
+  };
153
+
154
+  renderCollapseReplyEditor = () => {
155
+    const { showReplyEditor } = this.state;
156
+    if (!showReplyEditor) return null;
157
+    return <div className={styles.replyEditorWrapper}>
158
+      <OldCommontEditor />
159
+    </div>;
160
+  };
161
+
162
+  renderCollapseReplyContent = (replyList: Array<any>) => {
163
+    const { locale } = this.context;
164
+    const { showReply } = this.state;
165
+    const { onChangeListItem } = this.props;
166
+    const { id: commentId, replies } = this.props.data;
167
+    if (!showReply) {
168
+      return null;
169
+    }
170
+    return (
171
+      <div className={styles.replyWrapper}>
172
+        <Divider />
173
+        {replyList.map((i, index) => {
174
+          return (
175
+            <div key={`reply_${i.id}`}>
176
+              <ReplyCard
177
+                commentId={commentId}
178
+                replyContent={i}
179
+                locale={locale}
180
+                renderTime={this.renderCommentTime}
181
+                onChangeListItem={async ({ replyId, changeProps }) => {
182
+                  const newReplies = replies.map(r => {
183
+                    if (r.id === replyId) {
184
+                      return {
185
+                        ...r,
186
+                        ...changeProps,
187
+                      }
188
+                    }
189
+                    return r;
190
+                  });
191
+                  return onChangeListItem({ commentId, changeProp: { replies: newReplies }, })
192
+                }}
193
+              />
194
+              {index === replyList.length - 1 ? null : <Divider />}
195
+            </div>
196
+          );
197
+        })}
198
+      </div>
199
+    );
200
+  };
201
+
202
+  render() {
203
+    console.log(`data: `, this.props.data);
204
+    const {
205
+      id,
206
+      user_avatar,
207
+      content,
208
+      user_name,
209
+      created,
210
+      replies,
211
+    } = this.props.data;
212
+    return (
213
+      <article className={styles.wrapper}>
214
+        <div className={styles.avatar}>
215
+          {/* <img src={user_avatar} alt={`avatar_${id}`} /> */}
216
+          <div
217
+            className={styles.avatarImg}
218
+            style={{
219
+              backgroundImage: `url(${user_avatar})`,
220
+            }}
221
+          />
222
+        </div>
223
+        <section className={styles.content}>
224
+          <header>
225
+            <h4>{user_name}</h4>
226
+            {this.renderCommentTime(created)}
227
+          </header>
228
+          {this.renderContent()}
229
+          {this.renderToolBar()}
230
+          {this.renderCollapseReplyEditor()}
231
+          {this.renderCollapseReplyContent(replies)}
232
+        </section>
233
+      </article>
234
+    );
235
+  }
236
+}

+ 3
- 0
src/components/comment/CommentList.less View File

@@ -0,0 +1,3 @@
1
+.wrapper {
2
+  width: 80%;
3
+}

+ 32
- 0
src/components/comment/CommentList.tsx View File

@@ -0,0 +1,32 @@
1
+import React, { PureComponent } from 'react'
2
+import CommentItem, { CommentItemData } from './CommentItem';
3
+import { Divider } from 'antd';
4
+import styles from './CommentList.less';
5
+
6
+interface IP {
7
+  list: Array<CommentItemData>;
8
+  topDivider: boolean;
9
+  onChangeListItem({ commentId, changeProp }: { commentId: string; changeProp: any; }): any;
10
+}
11
+
12
+interface IS {
13
+
14
+}
15
+
16
+export default class CommentList extends PureComponent<IP, IS> {
17
+  render() {
18
+    return (
19
+      <div className={styles.wrapper}>
20
+        {this.props.topDivider ? <Divider /> : null}
21
+        {this.props.list.map((i, index) => {
22
+          return (
23
+            <>
24
+              <CommentItem data={i} onChangeListItem={this.props.onChangeListItem} />
25
+              {index === this.props.list.length - 1 ? null : <Divider />}
26
+            </>
27
+          )
28
+        })}
29
+      </div>
30
+    )
31
+  }
32
+}

+ 13
- 0
src/components/comment/README.md View File

@@ -0,0 +1,13 @@
1
+# comment
2
+
3
+通用评论组件
4
+
5
+** `veresion 0.1` **
6
+
7
+## Getting Started
8
+
9
+
10
+## Running the tests
11
+
12
+
13
+## Built

+ 43
- 0
src/components/comment/common/AudioPlayer.less View File

@@ -0,0 +1,43 @@
1
+@import "../../lessStyles/globalColor.less";
2
+
3
+.wrapper {
4
+  max-width: 350px;
5
+  background-color: #f5f5f5;
6
+  border: 1px solid rgba(210, 210, 210, 1);
7
+  border-radius: 4px;
8
+  display: flex;
9
+  align-items: center;
10
+  max-height: 44px;
11
+  height: 100%;
12
+
13
+  .iconWrapper {
14
+    cursor: pointer;
15
+    width: 28px;
16
+    height: 28px;
17
+    line-height: 28px;
18
+    text-align: center;
19
+    border-radius: 14px;
20
+    background-color: #fff;
21
+    color: if(iscolor(@comment_green), @comment_green, green);;
22
+    margin-left: 12px;
23
+    font-size: 14px;
24
+    display: flex;
25
+    justify-content: center;
26
+    align-items: center;
27
+  }
28
+  .audioSlider {
29
+    margin: 0 0 0 16px;
30
+    width: 195px;
31
+
32
+    :global {
33
+      .ant-slider-rail {
34
+        background-color: #dfdfdf;
35
+      }
36
+    }
37
+  }
38
+  .timeText {
39
+    margin-left: 14px;
40
+    color: #848484;
41
+    font-size: 12px;
42
+  }
43
+}

+ 149
- 0
src/components/comment/common/AudioPlayer.tsx View File

@@ -0,0 +1,149 @@
1
+import React from "react";
2
+import PropTypes from "prop-types";
3
+import { PauseOutlined, LoadingOutlined } from "@ant-design/icons";
4
+import { Slider } from "antd";
5
+import IconFont from "@components/comment/common/ScheduleIconFont";
6
+import dayjs from "@components/comment/utils/dayjsImport";
7
+import styles from "./AudioPlayer.less";
8
+
9
+interface AudioPlayerProps {
10
+  src: string;
11
+}
12
+interface AudioPlayerState {
13
+  duration: number;
14
+  currentDuration: number;
15
+  isPlaying: boolean;
16
+  isLoading: boolean;
17
+}
18
+
19
+class AudioPlayer extends React.Component<AudioPlayerProps, AudioPlayerState> {
20
+  player: any;
21
+  constructor(props: AudioPlayerProps) {
22
+    super(props);
23
+    this.state = {
24
+      duration: 0,
25
+      currentDuration: 0,
26
+      isPlaying: false,
27
+      isLoading: false,
28
+    };
29
+  }
30
+
31
+  componentDidMount() {
32
+    if (this.player) {
33
+      const { src } = this.props;
34
+      this.player.oncanplay = (event: any) =>
35
+        this.setState({ duration: event.target.duration });
36
+      this.player.onended = () =>
37
+        this.setState({ isPlaying: false, currentDuration: 0 });
38
+      this.player.onpause = () =>
39
+        this.setState({ isPlaying: false, isLoading: false });
40
+      this.player.onplay = () =>
41
+        this.setState({ isPlaying: false, isLoading: true });
42
+      this.player.onplaying = () =>
43
+        this.setState({ isPlaying: true, isLoading: false });
44
+      this.player.ontimeupdate = (event: any) =>
45
+        this.setState({ currentDuration: event.target.currentTime });
46
+      this.player.src = src;
47
+    }
48
+  }
49
+
50
+  componentDidUpdate(prevProps: AudioPlayerProps) {
51
+    if (this.props.src !== prevProps.src) {
52
+      this.player.oncanplay = (event: any) =>
53
+        this.setState({ duration: event.target.duration });
54
+      this.player.onended = () =>
55
+        this.setState({ isPlaying: false, currentDuration: 0 });
56
+      this.player.onpause = () =>
57
+        this.setState({ isPlaying: false, isLoading: false });
58
+      this.player.onplay = () =>
59
+        this.setState({ isPlaying: false, isLoading: true });
60
+      this.player.onplaying = () =>
61
+        this.setState({ isPlaying: true, isLoading: false });
62
+      this.player.ontimeupdate = (event: any) =>
63
+        this.setState({ currentDuration: event.target.currentTime });
64
+      this.player.src = this.props.src;
65
+    }
66
+  }
67
+  clickPlayOrPause(isPlaying: boolean) {
68
+    if (!this.player) {
69
+      return;
70
+    }
71
+    if (isPlaying) {
72
+      this.player.pause();
73
+    } else {
74
+      // 如果是播放,先暂停其他的播放
75
+      const playerList = document.getElementsByTagName("audio");
76
+      if (playerList) {
77
+        Array.from(playerList).forEach((player) => {
78
+          if (!player.paused) {
79
+            player.pause();
80
+          }
81
+        });
82
+      }
83
+      this.player.play();
84
+    }
85
+  }
86
+
87
+  getPlayIcon() {
88
+    const { isPlaying, isLoading } = this.state;
89
+    let playIconElem;
90
+
91
+    if (isLoading) {
92
+      playIconElem = (
93
+        <LoadingOutlined />
94
+        // <img className="icon-loading" src={iconLoading} alt="iconLoading" />
95
+      );
96
+    } else if (isPlaying) {
97
+      playIconElem = <PauseOutlined />;
98
+    } else {
99
+      playIconElem = <IconFont type="schedule-icon_image_audio" />;
100
+    }
101
+    return playIconElem;
102
+  }
103
+
104
+  render() {
105
+    const { currentDuration, isPlaying, duration } = this.state;
106
+    return (
107
+      <div className={styles.wrapper}>
108
+        {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
109
+        <audio
110
+          ref={(ref) => {
111
+            this.player = ref;
112
+          }}
113
+          style={{ display: "none" }}
114
+        />
115
+        <span
116
+          className={styles.iconWrapper}
117
+          onClick={() => {
118
+            this.clickPlayOrPause(isPlaying);
119
+          }}
120
+        >
121
+          {this.getPlayIcon()}
122
+        </span>
123
+        <Slider
124
+          step={0.001}
125
+          className={styles.audioSlider}
126
+          tooltipVisible={false}
127
+          value={currentDuration}
128
+          max={duration}
129
+          onChange={(value: any) => {
130
+            if (this.player) {
131
+              this.player.currentTime = value;
132
+            }
133
+          }}
134
+        />
135
+        <span className={styles.timeText}>
136
+          {dayjs
137
+            .utc(dayjs.duration(currentDuration, "seconds").asMilliseconds())
138
+            .format("mm:ss")}
139
+          /
140
+          {dayjs
141
+            .utc(dayjs.duration(duration, "seconds").asMilliseconds())
142
+            .format("mm:ss")}
143
+        </span>
144
+      </div>
145
+    );
146
+  }
147
+}
148
+
149
+export default AudioPlayer;

+ 11
- 0
src/components/comment/common/CollapseBtn.less View File

@@ -0,0 +1,11 @@
1
+.wrapper {
2
+  display: flex;
3
+  justify-content: center;
4
+  align-items: center;
5
+
6
+  .icon {
7
+    margin-left: 4px;
8
+    margin-right: 4px;
9
+    display: inline-flex;
10
+  }
11
+}

+ 29
- 0
src/components/comment/common/CollapseBtn.tsx View File

@@ -0,0 +1,29 @@
1
+import React, { useMemo, useState } from "react";
2
+import { DownOutlined, UpOutlined } from "@ant-design/icons";
3
+import styles from './CollapseBtn.less';
4
+
5
+export const CollapseBtn = ({
6
+  text,
7
+  onChange,
8
+}: {
9
+  text: string;
10
+  onChange: Function;
11
+}) => {
12
+  const [isCollapse, setCollapse] = useState(false);
13
+
14
+  return (
15
+    <span
16
+      className={styles.wrapper}
17
+      onClick={() => {
18
+        if (onChange(!isCollapse)) {
19
+          setCollapse(!isCollapse);
20
+        }
21
+      }}
22
+    >
23
+      <span>{text}</span>
24
+      <span className={styles.icon}>
25
+        {isCollapse ? <UpOutlined /> : <DownOutlined />}
26
+      </span>
27
+    </span>
28
+  );
29
+};

+ 5
- 0
src/components/comment/common/ContentRender.less View File

@@ -0,0 +1,5 @@
1
+.contentWrap {
2
+  margin: 10px 0;
3
+  font-size: 16px;
4
+  word-break: break-all;
5
+}

+ 96
- 0
src/components/comment/common/ContentRender.tsx View File

@@ -0,0 +1,96 @@
1
+import React, { useMemo } from "react";
2
+import { IMAGE_SPLIT, REGEXP } from "../config/constant";
3
+import styles from "./ContentRender.less";
4
+import { htmlEncode, isUrl } from '../utils';
5
+
6
+export function renderContent(content: any) {
7
+  let newContent = content;
8
+  if (newContent.indexOf(IMAGE_SPLIT) !== -1) {
9
+    newContent = newContent.split(IMAGE_SPLIT);
10
+    newContent.pop();
11
+    newContent = newContent.join("");
12
+  }
13
+  // 不包含在标签内的链接
14
+  const innerUrl = /((http(s)?:)?\/\/)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[com|net|org|cn|edu|top|gov]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)(?![^<>]*>|[^"]*?<\/a)/g;
15
+  const data = htmlEncode(newContent)
16
+    .replace(REGEXP, function (a, b) {
17
+      const src = a.slice(1, -1);
18
+
19
+      // 兼容旧的评
20
+      // 因为旧的评论用 [img url] 方式存储的
21
+      if (isUrl(src)) {
22
+        return `<a href="${src}" rel="noopener noreferrer" target="_blank"><img class="comment-img" src="${src}" alt="${src}" /></a>`;
23
+      }
24
+      // 如果不存在对应的表情, 则返回原文
25
+      // const emoji = emojiObejct[src];
26
+      if (
27
+        sessionStorage.getItem("emojiMap") &&
28
+        JSON.parse(sessionStorage.getItem("emojiMap"))
29
+      ) {
30
+        const emoji = JSON.parse(sessionStorage.getItem("emojiMap"))[src];
31
+        if (emoji) {
32
+          return `<img class="comment-emoji" src="${emoji}" alt="${emoji.title}" style="width:28px"/>`;
33
+        }
34
+      }
35
+      return `[${src}]`;
36
+    })
37
+    .replace(innerUrl, function (a, b) {
38
+      const protocol = /^(https?:)?\/\//;
39
+      const hasProtocol = protocol.test(a);
40
+      const url = hasProtocol ? a : `//${a}`;
41
+      // target="_blank" 存在安全性问题
42
+      // return `<a href="${url}" target="_blank" rel="noopener noreferrer" >${a}</a>`;
43
+      return `<a href="${url}">${a}</a>`;
44
+    })
45
+    .replace(/\n/g, "<br />");
46
+  return data;
47
+}
48
+
49
+export function renderTextWithReply(
50
+  text: string,
51
+  replyContent: { content: any; reply: any }
52
+): string {
53
+  let newText = text;
54
+  const { reply } = replyContent;
55
+  if (reply) {
56
+    newText = `${newText} //@${reply.user_name} ${reply.content}`;
57
+    if (reply.reply) {
58
+      return renderTextWithReply(newText, reply);
59
+    }
60
+  }
61
+  return newText;
62
+}
63
+
64
+export function contentPreTreatment(contentString: string): string {
65
+  let newContentString: string | Array<string> = contentString;
66
+  let imagesString: string | undefined = "";
67
+  if (contentString.indexOf(IMAGE_SPLIT) !== -1) {
68
+    newContentString = newContentString.split(IMAGE_SPLIT);
69
+    imagesString = newContentString.pop();
70
+    newContentString = newContentString.join("");
71
+  }
72
+  return contentString;
73
+}
74
+
75
+export const ContentRender = ({ data, renderContent: propsRenderContent }: { data: any, renderContent?(data: any): any }) => {
76
+  if (propsRenderContent) {
77
+    return (
78
+      <div className={styles.contentWrap}>
79
+        {propsRenderContent(data.content)}
80
+      </div>
81
+    )
82
+  }
83
+  const contentString: string = useMemo(
84
+    () => contentPreTreatment(data.content),
85
+    [data]
86
+  );
87
+  return (
88
+    <div className={styles.contentWrap}>
89
+      <div
90
+        dangerouslySetInnerHTML={{
91
+          __html: renderContent(renderTextWithReply(contentString, data)),
92
+        }}
93
+      />
94
+    </div>
95
+  );
96
+};

+ 45
- 0
src/components/comment/common/ItemToolBar.less View File

@@ -0,0 +1,45 @@
1
+@import "../../lessStyles/globalColor.less";
2
+
3
+.wrapper {
4
+  display: flex;
5
+  justify-content: flex-end;
6
+  align-items: center;
7
+
8
+  .toolsItem {
9
+    margin-left: 8px;
10
+    margin-right: 8px;
11
+    display: flex;
12
+    cursor: pointer;
13
+
14
+    .btnCount {
15
+      margin-left: 2px;
16
+    }
17
+
18
+    .replyBtn {
19
+      cursor: pointer;
20
+      color: if(iscolor(@comment_blue), @comment_blue, blue);
21
+
22
+      &:hover {
23
+        color: lighten(if(iscolor(@comment_blue), @comment_blue, blue), 10%);
24
+      }
25
+    }
26
+
27
+    &.favorBtnWrapper {
28
+      min-width: 32px;
29
+
30
+      .iconWrapper {
31
+        min-width: 20px;
32
+        display: inline-flex;
33
+      }
34
+    }
35
+  }
36
+
37
+  .replyCollapseBtn {
38
+    cursor: pointer;
39
+    color: if(iscolor(@comment_green), @comment_green, green);
40
+
41
+    &:hover {
42
+      color: if(iscolor(@comment_blue), @comment_blue, blue);
43
+    }
44
+  }
45
+}

+ 175
- 0
src/components/comment/common/ItemToolBar.tsx View File

@@ -0,0 +1,175 @@
1
+import React from "react";
2
+import classnames from "classnames";
3
+import PropTypes from "prop-types";
4
+import { Divider, Popconfirm } from "antd";
5
+import IconFont from "./ScheduleIconFont";
6
+import { CollapseBtn } from "./CollapseBtn";
7
+import { getActionAuth, AuthType } from "../utils";
8
+
9
+import styles from "./ItemToolBar.less";
10
+import { LoadingOutlined } from "@ant-design/icons";
11
+
12
+export enum TOOL_ACTION_TYPE {
13
+  REPLY = "reply",
14
+  FAVOR = "favor",
15
+  EDIT = "edit",
16
+  DELETE = "delete",
17
+}
18
+
19
+interface ItemToolBarProps {
20
+  replyCount: number;
21
+  authType: AuthType;
22
+  handleReplyClick(): boolean;
23
+  handlePrivateClick(actionType: string): Promise<boolean> | boolean;
24
+  handleNormalClick(actionType: string): Promise<boolean> | boolean;
25
+  favorCount: number;
26
+  favored: boolean;
27
+}
28
+interface ItemToolBarState {
29
+  favorAjaxLoading: boolean;
30
+}
31
+
32
+class ItemToolBar extends React.Component<ItemToolBarProps, ItemToolBarState> {
33
+  constructor(props: ItemToolBarProps) {
34
+    super(props);
35
+    this.state = {
36
+      favorAjaxLoading: false,
37
+    };
38
+  }
39
+
40
+  renderPrivateTool = () => {
41
+    const {
42
+      authType,
43
+      replyCount,
44
+      handleReplyClick,
45
+      handlePrivateClick,
46
+    } = this.props;
47
+    const showReplyCollapse = replyCount > 0;
48
+    const showEdit = getActionAuth(authType, TOOL_ACTION_TYPE.EDIT);
49
+    const showDelete = getActionAuth(authType, TOOL_ACTION_TYPE.DELETE);
50
+    const notShowPrivate =
51
+      [showReplyCollapse, showEdit, showDelete].reduce(
52
+        (a, b) => Number(a) + Number(b),
53
+        0
54
+      ) === 0;
55
+    console.log("notShowPrivate: ", notShowPrivate);
56
+    if (notShowPrivate) return null;
57
+    return (
58
+      <>
59
+        {showReplyCollapse && (
60
+          <span
61
+            className={classnames(styles.toolsItem, styles.replyCollapseBtn)}
62
+          >
63
+            <CollapseBtn
64
+              text={`${replyCount} 回复`}
65
+              onChange={handleReplyClick}
66
+            />
67
+          </span>
68
+        )}
69
+        {showEdit && (
70
+          <span
71
+            className={classnames(styles.toolsItem)}
72
+            onClick={() => {
73
+              handlePrivateClick("edit");
74
+            }}
75
+          >
76
+            <IconFont
77
+              type="schedule-icon_answer"
78
+              style={{
79
+                fontSize: "18px",
80
+              }}
81
+            />
82
+          </span>
83
+        )}
84
+        {showDelete && (
85
+          <span className={classnames(styles.toolsItem)}>
86
+            <Popconfirm
87
+              title="是否删除"
88
+              onConfirm={() => {
89
+                handlePrivateClick("delete");
90
+              }}
91
+            >
92
+              <IconFont
93
+                type="schedule-icon_deletecopy"
94
+                style={{
95
+                  fontSize: "18px",
96
+                }}
97
+              />
98
+            </Popconfirm>
99
+          </span>
100
+        )}
101
+        <Divider
102
+          type="vertical"
103
+          style={{ borderLeftColor: "#c6c6c6", fontSize: "24px" }}
104
+        />
105
+      </>
106
+    );
107
+  };
108
+
109
+  renderNormalTool = () => {
110
+    const { favorAjaxLoading } = this.state;
111
+    const { favorCount, favored, handleNormalClick } = this.props;
112
+    return (
113
+      <>
114
+        <span
115
+          className={classnames(styles.toolsItem, styles.favorBtnWrapper)}
116
+          onClick={async (e) => {
117
+            e.preventDefault();
118
+            if (!favorAjaxLoading) {
119
+              this.setState({ favorAjaxLoading: true });
120
+              await handleNormalClick(TOOL_ACTION_TYPE.FAVOR);
121
+              this.setState({ favorAjaxLoading: false });
122
+            }
123
+          }}
124
+        >
125
+          <span className={styles.iconWrapper}>
126
+            {favorAjaxLoading ? (
127
+              <LoadingOutlined
128
+                style={{
129
+                  color: "#fc4747",
130
+                  display: "inline-flex",
131
+                  alignItems: "center",
132
+                }}
133
+              />
134
+            ) : (
135
+              <IconFont
136
+                type={
137
+                  favored
138
+                    ? "schedule-icon_like_fill1"
139
+                    : "schedule-icon_like_line"
140
+                }
141
+                style={{
142
+                  fontSize: "18px",
143
+                  transform: "translateY(1px)",
144
+                  color: favored ? "#fc4747" : "inherit",
145
+                }}
146
+              />
147
+            )}
148
+          </span>
149
+
150
+          <span className={styles.btnCount}>{favorCount}</span>
151
+        </span>
152
+        <span
153
+          className={styles.toolsItem}
154
+          onClick={(e) => {
155
+            e.preventDefault();
156
+            handleNormalClick(TOOL_ACTION_TYPE.REPLY);
157
+          }}
158
+        >
159
+          <span className={styles.replyBtn}>回复</span>
160
+        </span>
161
+      </>
162
+    );
163
+  };
164
+
165
+  render() {
166
+    return (
167
+      <div className={styles.wrapper}>
168
+        {this.renderPrivateTool()}
169
+        {this.renderNormalTool()}
170
+      </div>
171
+    );
172
+  }
173
+}
174
+
175
+export default ItemToolBar;

+ 47
- 0
src/components/comment/common/ReplyCard.less View File

@@ -0,0 +1,47 @@
1
+.wrapper {
2
+  display: flex;
3
+
4
+  .avatar {
5
+    width: 40px;
6
+    height: 40px;
7
+    margin-left: 22px;
8
+    margin-right: 22px;
9
+  
10
+    .avatarImg {
11
+      width: 100%;
12
+      height: 100%;
13
+      border-radius: 50%;
14
+      background-position: center;
15
+      background-size: cover;
16
+    }
17
+  }
18
+  .content {
19
+    display: flex;
20
+    flex-direction: column;
21
+    justify-content: flex-start;
22
+    width: 100%;
23
+  
24
+    & > header {
25
+      display: flex;
26
+  
27
+      h4 { margin: 0; }
28
+      span { margin-left: 8px; }
29
+    }
30
+
31
+    .toolsWrapper {
32
+      display: flex;
33
+      justify-content: flex-end;
34
+      align-items: center;
35
+  
36
+      .toolsItem {
37
+        margin-left: 8px;
38
+        margin-right: 8px;
39
+        display: flex;
40
+  
41
+        .btnCount {
42
+          margin-left: 2px;
43
+        }
44
+      }
45
+    }
46
+  }
47
+}

+ 110
- 0
src/components/comment/common/ReplyCard.tsx View File

@@ -0,0 +1,110 @@
1
+import React, { useState, useContext } from "react";
2
+import { Divider } from "antd";
3
+import ItemToolBar, { TOOL_ACTION_TYPE } from "./ItemToolBar";
4
+import styles from "./ReplyCard.less";
5
+import { CommentContext } from "../context";
6
+import { getUserType } from "../utils";
7
+import { toggleFavor } from "../utils/commentAjaxLogi";
8
+
9
+export const ReplyCard = ({
10
+  replyContent: {
11
+    user_name,
12
+    user_id,
13
+    user_avatar,
14
+    reply_target = null,
15
+    id,
16
+    favored = false,
17
+    favor_count,
18
+    created,
19
+    content,
20
+  },
21
+  commentId,
22
+  locale,
23
+  renderTime = () => null,
24
+  onChangeListItem,
25
+}: {
26
+  replyContent: {
27
+    user_name: string;
28
+    user_id: number;
29
+    user_avatar: string;
30
+    reply_target: null;
31
+    id: string;
32
+    favored: boolean;
33
+    favor_count: number;
34
+    created: number;
35
+    content: string;
36
+  };
37
+  commentId: string;
38
+  locale: "zh" | "en" | string;
39
+  renderTime: (created: number) => any;
40
+  onChangeListItem?({
41
+    replyId,
42
+    changeProps,
43
+  }: {
44
+    replyId: string;
45
+    changeProps: any;
46
+  }): Promise<boolean> | boolean;
47
+}) => {
48
+  const [showReplyEditor, setShowReplyEditor] = useState(false);
49
+  const context = useContext(CommentContext);
50
+  const { currentUser }: { currentUser: any } = context;
51
+
52
+  return (
53
+    <article className={styles.wrapper}>
54
+      <div className={styles.avatar}>
55
+        <div
56
+          className={styles.avatarImg}
57
+          style={{
58
+            backgroundImage: `url(${user_avatar})`,
59
+          }}
60
+        />
61
+      </div>
62
+      <section className={styles.content}>
63
+        <header>
64
+          <h4>{user_name}</h4>
65
+          {renderTime(created)}
66
+        </header>
67
+        <div>{content}</div>
68
+        <ItemToolBar
69
+          authType={getUserType(currentUser, user_id)}
70
+          handleReplyClick={() => {
71
+            return true;
72
+          }}
73
+          handleNormalClick={async (actionType: string) => {
74
+            console.log("actionType: ", actionType);
75
+            if (actionType === TOOL_ACTION_TYPE.REPLY) {
76
+              setShowReplyEditor(!showReplyEditor);
77
+            }
78
+            if (actionType === TOOL_ACTION_TYPE.FAVOR) {
79
+              const res = await toggleFavor({
80
+                favored,
81
+                id: commentId,
82
+                commentId: id,
83
+                userId: currentUser.id,
84
+              });
85
+              if (res && onChangeListItem) {
86
+                onChangeListItem({
87
+                  replyId: id,
88
+                  changeProps: {
89
+                    favor_count: res.favor_count,
90
+                    favored: !favored,
91
+                  },
92
+                });
93
+              }
94
+            }
95
+            return true;
96
+          }}
97
+          handlePrivateClick={(actionType: string) => {
98
+            return true;
99
+          }}
100
+          replyCount={0}
101
+          favorCount={favor_count}
102
+          favored={favored}
103
+        />
104
+        {showReplyEditor ? (
105
+          <div className={styles.replyEditorWrapper}>Editor</div>
106
+        ) : null}
107
+      </section>
108
+    </article>
109
+  );
110
+};

+ 7
- 0
src/components/comment/common/ScheduleIconFont.tsx View File

@@ -0,0 +1,7 @@
1
+import { createFromIconfontCN } from "@ant-design/icons";
2
+
3
+export const IconFont = createFromIconfontCN({
4
+  scriptUrl: "//at.alicdn.com/t/font_1158309_j9nd4fj9z.js",
5
+});
6
+
7
+export default IconFont;

+ 38
- 0
src/components/comment/config/constant.ts View File

@@ -0,0 +1,38 @@
1
+export const ERROR_DEFAULT = "出错了!";
2
+
3
+export const LIMIT = 10; // 默认 limit
4
+
5
+export const DRIVER_LICENSE_PATH = "/comment";
6
+
7
+export const OSS_LINK = "//links-comment.oss-cn-beijing.aliyuncs.com";
8
+
9
+export const MAX_UPLOAD_NUMBER = 4;
10
+
11
+export const URL_REGEXP = /((http(s)?:)?\/\/)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g;
12
+
13
+export const REGEXP = /\[[^[\]]+?\]/g;
14
+
15
+export const AVATAR = "";
16
+
17
+export const IMAGE_SPLIT = "IMAGE_SPLIT";
18
+
19
+export const IMAGE_PROCESS = "?x-oss-process=image/resize,h_350";
20
+export const IMAGE_PROCESS_SMALL = "?x-oss-process=image/resize,h_100";
21
+export const IMAGE_PROCESS_LARGE = "?x-oss-process=image/resize,h_500";
22
+
23
+export const COMMENT_TYPE = {
24
+  COMMENT: "comment",
25
+  REPLY: "reply"
26
+};
27
+
28
+// 线上静态库地址
29
+export const LANGUAGE_LINK =
30
+  process.env.NODE_ENV === "production"
31
+    ? "//a.links123.cn/lang"
32
+    : "//a.links123.net/lang";
33
+
34
+export const COMMENT_API = `http://api.links123.net/comment/v1`
35
+
36
+export const LOCALE_DAYJS: any = {
37
+  'zh': 'zh-cn',
38
+}

+ 22
- 0
src/components/comment/context/index.ts View File

@@ -0,0 +1,22 @@
1
+import React from 'react';
2
+import { LIMIT } from "@components/comment/config/constant";
3
+
4
+// const DEFAULT_TYPE = 1;
5
+// const DEFAULT_BUSSINESSID = 'test';
6
+// const DEFAULT_BUSSINESS_USER_ID = 4;
7
+// const DEFAULT_PAGE = 1;
8
+// const DEFAULT_LIMIT = LIMIT;
9
+
10
+export const DefaultValue = {
11
+  type: 3,
12
+  bussiness_id: '5ea8320dedd68200018e733d',
13
+  bussiness_user_id: 4,
14
+  page: 1,
15
+  limit: LIMIT,
16
+  locale: 'zh',
17
+  currentUser: null,
18
+}
19
+
20
+// type=3&business_id=5ea8320dedd68200018e733d&is_speak=0&page=1&limit=10
21
+
22
+export const CommentContext = React.createContext(DefaultValue)

+ 37
- 0
src/components/comment/demo.jsx View File

@@ -0,0 +1,37 @@
1
+import React from "react";
2
+import PropTypes from "prop-types";
3
+import CommentCombine from "./index";
4
+import styles from "./index.less";
5
+
6
+export class CommentDemo extends React.Component {
7
+  render() {
8
+    return (
9
+      <div className={styles.playgroundWrapper}>
10
+        <CommentCombine
11
+          locale={this.props.locale}
12
+          currentUser={{
13
+            avatar:
14
+              "https://links123-images.oss-cn-hangzhou.aliyuncs.com/avatar/2020/7/9/c495ab20cba85676bc91f0ea504275bf.jpg",
15
+            birthday: "1991-11-14",
16
+            email: "foureyed@qq.com",
17
+            gender: 1,
18
+            id: 45,
19
+            introduction:
20
+              "的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试的测试",
21
+            linker: "L10158",
22
+            nickname: "Adam的测试",
23
+            phone: "170****1235",
24
+            phone_code: "86",
25
+            reside: "江西上饶的",
26
+          }}
27
+        />
28
+      </div>
29
+    );
30
+  }
31
+}
32
+
33
+CommentDemo.propTypes = {
34
+  locale: PropTypes.oneOf(["zh", "en"]),
35
+};
36
+
37
+export default CommentDemo;

+ 121
- 0
src/components/comment/index.jsx View File

@@ -0,0 +1,121 @@
1
+import React from "react";
2
+import { getComment } from "@services/comment";
3
+import { CommentContext, DefaultValue } from "./context";
4
+import CommentList from "./CommentList";
5
+import styles from "./index.less";
6
+
7
+export class CommentCombine extends React.PureComponent {
8
+  constructor(props) {
9
+    super(props);
10
+    this.state = {
11
+      ...DefaultValue,
12
+      locale: props.locale || "zh",
13
+      currentUser: props.currentUser || null,
14
+    };
15
+  }
16
+
17
+  static getDerivedStateFromProps(props, state) {
18
+    return {
19
+      locale: props.locale,
20
+      currentUser: props.currentUser,
21
+    };
22
+  }
23
+
24
+  render() {
25
+    return (
26
+      <CommentContext.Provider value={this.state}>
27
+        <Comment />
28
+      </CommentContext.Provider>
29
+    );
30
+  }
31
+}
32
+
33
+export class Comment extends React.PureComponent {
34
+  static contextType = CommentContext;
35
+  static propTypes = {};
36
+  static defaultProps = {};
37
+
38
+  constructor(props) {
39
+    super(props);
40
+    this.state = {
41
+      comment_data: null,
42
+    };
43
+  }
44
+
45
+  componentDidMount = async () => {
46
+    const { type, limit, page, bussiness_id } = this.context;
47
+    const res = await getComment({
48
+      type,
49
+      limit,
50
+      page,
51
+      businessId: bussiness_id,
52
+    });
53
+    console.log("res:", res);
54
+    this.setState({
55
+      comment_data: res,
56
+    });
57
+  };
58
+
59
+  renderCommentList = (list) => {
60
+    // content: "[e101][e102]"
61
+    // created: 1582352617
62
+    // favor_count: 0
63
+    // favored: false
64
+    // id: "5e50c8e9818eda000192773d"
65
+    // is_speak: false
66
+    // medias: null
67
+    // replies: []
68
+    // reply_count: 0
69
+    // user_avatar: "https://links123-images.oss-cn-hangzhou.aliyuncs.com/avatar/2020/2/7/1326acc4b1609c8ecf52e94d58a38384.jpg?x-oss-process=image/resize,h_100"
70
+    // user_id: 71770
71
+    // user_name: "EvoKou"
72
+    return (
73
+      <CommentList
74
+        list={list}
75
+        onChangeListItem={({ commentId, changeProp }) => {
76
+          const newList = list.map(i => {
77
+            if (i.id === commentId) {
78
+              return {
79
+                ...i,
80
+                ...changeProp,
81
+              };
82
+            }
83
+            return i;
84
+          });
85
+          this.setState({
86
+            comment_data: {
87
+              ...this.state.comment_data,
88
+              list: newList,
89
+            }
90
+          })
91
+        }}
92
+        topDivider
93
+      />
94
+    );
95
+  };
96
+
97
+  renderComment = () => {
98
+    const { comment_data } = this.state;
99
+    if (comment_data === null) return "数据载入中";
100
+    const { list, total, page } = comment_data;
101
+    return (
102
+      <div>
103
+        total: {total}
104
+        page: {page}
105
+        <div>{this.renderCommentList(list)}</div>
106
+      </div>
107
+    );
108
+  };
109
+
110
+  render() {
111
+    console.log("context", this.context);
112
+    return (
113
+      <div>
114
+        {JSON.stringify(this.context)}
115
+        {this.renderComment()}
116
+      </div>
117
+    );
118
+  }
119
+}
120
+
121
+export default CommentCombine;

+ 3
- 0
src/components/comment/index.less View File

@@ -0,0 +1,3 @@
1
+.playgroundWrapper {
2
+  min-height: 100%;
3
+}

+ 30
- 0
src/components/comment/utils/commentAjaxLogi.ts View File

@@ -0,0 +1,30 @@
1
+import { favorComment, unFavorComment } from '@/services/comment';
2
+
3
+export const toggleFavor = async ({
4
+  favored,
5
+  id,
6
+  commentId,
7
+  userId,
8
+}: {
9
+  id: string;
10
+  favored: boolean;
11
+  commentId: string;
12
+  userId: number;
13
+}) => {
14
+  let responseData;
15
+  if (!favored) {
16
+    responseData = await favorComment({
17
+      id,
18
+      commentId,
19
+      userId,
20
+    })
21
+  } else {
22
+    responseData = await unFavorComment({
23
+      id,
24
+      commentId,
25
+      userId,
26
+    })
27
+  }
28
+
29
+  return responseData.data;
30
+}

+ 11
- 0
src/components/comment/utils/dayjsImport.ts View File

@@ -0,0 +1,11 @@
1
+import dayjs from "dayjs";
2
+import "dayjs/locale/zh-cn";
3
+import relativeTime from "dayjs/plugin/relativeTime";
4
+import utcPlugin from "dayjs/plugin/utc";
5
+import durationPlugin from "dayjs/plugin/duration";
6
+
7
+dayjs.extend(relativeTime);
8
+dayjs.extend(utcPlugin);
9
+dayjs.extend(durationPlugin);
10
+
11
+export default dayjs;

+ 51
- 0
src/components/comment/utils/index.ts View File

@@ -0,0 +1,51 @@
1
+import { TOOL_ACTION_TYPE } from '@components/comment/common/ItemToolBar';
2
+export type AuthType = 'guest'|'user'|'owner'|'unknow'
3
+
4
+/**
5
+ * HTML 编码
6
+ * 将 < > 等字符串进行编码
7
+ * @param {string} str 文本
8
+ */
9
+export function htmlEncode(str: string) {
10
+  if (!str) return "";
11
+  return str.replace(/[<>]/gim, function(i) {
12
+    return "&#" + i.charCodeAt(0) + ";";
13
+  });
14
+}
15
+
16
+export const getUserType = (currentUser: any, targetUserId: number): AuthType => {
17
+  if (!currentUser) return 'guest';
18
+  if (currentUser.id === targetUserId) return 'owner';
19
+  if (currentUser.id) return 'user';
20
+  return 'unknow';
21
+}
22
+
23
+export const getActionAuth = (userType: AuthType, actionType: TOOL_ACTION_TYPE) => {
24
+  if (userType === 'guest') {
25
+    return [
26
+      TOOL_ACTION_TYPE.FAVOR
27
+    ].includes(actionType);
28
+  }
29
+  if (userType === 'owner') {
30
+    return [
31
+      TOOL_ACTION_TYPE.FAVOR,
32
+      TOOL_ACTION_TYPE.REPLY,
33
+      TOOL_ACTION_TYPE.EDIT,
34
+      TOOL_ACTION_TYPE.DELETE,
35
+    ].includes(actionType);
36
+  }
37
+  if (userType === 'user') {
38
+    return [
39
+      TOOL_ACTION_TYPE.FAVOR,
40
+      TOOL_ACTION_TYPE.REPLY,
41
+    ].includes(actionType);
42
+  }
43
+}
44
+
45
+export function isUrl(inputString: string) {
46
+  // 需完整匹配
47
+  const regexp = /^((http(s)?:)?\/\/.)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g;
48
+  var res = inputString.match(regexp);
49
+  if (res === null) return false;
50
+  else return true;
51
+}

+ 23
- 0
src/components/editor/OldCommentEditor.tsx View File

@@ -0,0 +1,23 @@
1
+import React from 'react';
2
+import PropTypes from 'prop-types';
3
+import styles from './OldCommontEditor.less';
4
+
5
+interface OldCommontEditorProps {}
6
+interface OldCommontEditorState {}
7
+
8
+class OldCommontEditor extends React.Component<OldCommontEditorProps, OldCommontEditorState> {
9
+  constructor(props: OldCommontEditorProps) {
10
+    super(props);
11
+    this.state = {};
12
+  }
13
+
14
+  render() {
15
+    return (
16
+      <div className={styles.wrapper}>
17
+        Component
18
+      </div>
19
+    )
20
+  }
21
+}
22
+
23
+export default OldCommontEditor;

+ 0
- 0
src/components/editor/OldCommontEditor.less View File


+ 1
- 0
src/components/editor/index.tsx View File

@@ -0,0 +1 @@
1
+import React from 'react';

+ 2
- 0
src/components/lessStyles/globalColor.less View File

@@ -0,0 +1,2 @@
1
+@comment_green: #71c135;
2
+@comment_blue: #3c9cfe;

+ 14
- 0
src/helper/index.ts View File

@@ -0,0 +1,14 @@
1
+export function converObj(data: any) {
2
+  let _result = [];
3
+  for (let _key in data) {
4
+      let v = data[_key];
5
+      if (v.constructor === Array) {
6
+          v.forEach(function(_value) {
7
+              _result.push(`${_key}=${_value}`)
8
+          });
9
+      } else {
10
+          _result.push(`${_key}=${v}`)
11
+      }
12
+  }
13
+  return _result.join('&');
14
+}

+ 131
- 0
src/services/comment.ts View File

@@ -0,0 +1,131 @@
1
+import axios from "axios";
2
+import { converObj } from "@/helper";
3
+import { COMMENT_API } from "@/components/comment/config/constant";
4
+
5
+type CommonListResponse = {
6
+  list: Array<any>;
7
+  total: number;
8
+  page: number;
9
+};
10
+
11
+interface CommonResponse {
12
+  config: any;
13
+  data: CommonListResponse;
14
+  headers: any;
15
+  request: any;
16
+  status: number;
17
+  statusText: string;
18
+}
19
+
20
+export const getComment = async ({
21
+  type,
22
+  businessId,
23
+  isSpeak,
24
+  page,
25
+  limit,
26
+}: {
27
+  type: string;
28
+  businessId: string;
29
+  isSpeak?: boolean;
30
+  page: string;
31
+  limit: string;
32
+}): Promise<any> => {
33
+  const r: CommonResponse = await axios.get(
34
+    `${COMMENT_API}/comments?${converObj({
35
+      type,
36
+      business_id: businessId,
37
+      page,
38
+      limit,
39
+    })}`,
40
+    { withCredentials: true }
41
+  );
42
+  return r.data;
43
+};
44
+
45
+export const createComment = async ({
46
+  type,
47
+  businessId,
48
+  businessUserId,
49
+  content,
50
+}: {
51
+  type: string;
52
+  businessId: string;
53
+  businessUserId: string;
54
+  content: string;
55
+}): Promise<any> => {
56
+  const r = await axios.post(`${COMMENT_API}/comments`, {
57
+    method: "post",
58
+    withCredentials: true,
59
+    data: {
60
+      type,
61
+      businessId,
62
+      businessUserId,
63
+      content,
64
+    },
65
+  });
66
+  return r;
67
+};
68
+
69
+export const updateComment = async ({
70
+  id,
71
+  content,
72
+}: {
73
+  id: string;
74
+  content: string;
75
+}) => {
76
+  const r = await axios.post(`${COMMENT_API}/comments/${id}`, {
77
+    method: "post",
78
+    withCredentials: true,
79
+    data: {
80
+      content,
81
+    },
82
+  });
83
+  return r;
84
+};
85
+
86
+export const deleteComment = async ({ id }: { id: string }) => {
87
+  const r = await axios.delete(`${COMMENT_API}/comments/${id}`);
88
+  return r;
89
+};
90
+
91
+export const favorComment = async ({
92
+  id,
93
+  commentId,
94
+  userId,
95
+}: {
96
+  id: string;
97
+  commentId: string;
98
+  userId: number;
99
+}) => {
100
+  const r = await axios(`${COMMENT_API}/comments/${id}/favor`, {
101
+    method: "put",
102
+    withCredentials: true,
103
+    data: {
104
+      commentID: commentId,
105
+      userID: userId,
106
+    },
107
+  });
108
+  return r;
109
+};
110
+
111
+export const unFavorComment = async ({
112
+  id,
113
+  commentId,
114
+  userId,
115
+}: {
116
+  id: string;
117
+  commentId: string;
118
+  userId: number;
119
+}) => {
120
+  const r = await axios(`${COMMENT_API}/comments/${id}/favor`, {
121
+    method: "delete",
122
+    withCredentials: true,
123
+    data: {
124
+      commentID: commentId,
125
+      userID: userId,
126
+    },
127
+  });
128
+  return r;
129
+};
130
+
131
+// export const getFavorUserList = async () => {}

+ 4
- 0
src/typings/index.d.ts View File

@@ -0,0 +1,4 @@
1
+declare module '*.less' {
2
+  const classes: { [key: string]: string };
3
+  export default classes;
4
+}

+ 50
- 0
stories/Button.js View File

@@ -0,0 +1,50 @@
1
+import React from 'react';
2
+import PropTypes from 'prop-types';
3
+import './button.css';
4
+
5
+/**
6
+ * Primary UI component for user interaction
7
+ */
8
+export const Button = ({ primary, backgroundColor, size, label, ...props }) => {
9
+  const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
10
+  return (
11
+    <button
12
+      type="button"
13
+      className={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
14
+      style={backgroundColor && { backgroundColor }}
15
+      {...props}
16
+    >
17
+      {label}
18
+    </button>
19
+  );
20
+};
21
+
22
+Button.propTypes = {
23
+  /**
24
+   * Is this the principal call to action on the page?
25
+   */
26
+  primary: PropTypes.bool,
27
+  /**
28
+   * What background color to use
29
+   */
30
+  backgroundColor: PropTypes.string,
31
+  /**
32
+   * How large should the button be?
33
+   */
34
+  size: PropTypes.oneOf(['small', 'medium', 'large']),
35
+  /**
36
+   * Button contents
37
+   */
38
+  label: PropTypes.string.isRequired,
39
+  /**
40
+   * Optional click handler
41
+   */
42
+  onClick: PropTypes.func,
43
+};
44
+
45
+Button.defaultProps = {
46
+  backgroundColor: null,
47
+  primary: false,
48
+  size: 'medium',
49
+  onClick: undefined,
50
+};

+ 36
- 0
stories/Button.stories.js View File

@@ -0,0 +1,36 @@
1
+import React from 'react';
2
+
3
+import { Button } from './Button';
4
+
5
+export default {
6
+  title: 'Example/Button',
7
+  component: Button,
8
+  argTypes: {
9
+    backgroundColor: { control: 'color' },
10
+  },
11
+};
12
+
13
+const Template = (args) => <Button {...args} />;
14
+
15
+export const Primary = Template.bind({});
16
+Primary.args = {
17
+  primary: true,
18
+  label: 'Button',
19
+};
20
+
21
+export const Secondary = Template.bind({});
22
+Secondary.args = {
23
+  label: 'Button',
24
+};
25
+
26
+export const Large = Template.bind({});
27
+Large.args = {
28
+  size: 'large',
29
+  label: 'Button',
30
+};
31
+
32
+export const Small = Template.bind({});
33
+Small.args = {
34
+  size: 'small',
35
+  label: 'Button',
36
+};

+ 17
- 0
stories/Comment.stories.js View File

@@ -0,0 +1,17 @@
1
+import React from "react";
2
+import CommentDemo from "@components/comment/demo.jsx";
3
+import "antd/dist/antd.less";
4
+
5
+export default {
6
+  title: "Components/Comment",
7
+  component: CommentDemo,
8
+};
9
+
10
+const Template = (args) => (
11
+  <div>
12
+    <CommentDemo {...args} />
13
+  </div>
14
+);
15
+
16
+export const Primary = Template.bind({});
17
+Primary.args = {};

+ 16
- 0
stories/Editor.stories.js View File

@@ -0,0 +1,16 @@
1
+import React from "react";
2
+import "antd/dist/antd.less";
3
+
4
+export default {
5
+  title: "Components/Editor",
6
+  component: () => {},
7
+};
8
+
9
+const Template = (args) => (
10
+  <div>
11
+    Component
12
+  </div>
13
+);
14
+
15
+export const Primary = Template.bind({});
16
+Primary.args = {};

+ 52
- 0
stories/Header.js View File

@@ -0,0 +1,52 @@
1
+import React from 'react';
2
+import PropTypes from 'prop-types';
3
+
4
+import { Button } from './Button';
5
+import './header.css';
6
+
7
+export const Header = ({ user, onLogin, onLogout, onCreateAccount }) => (
8
+  <header>
9
+    <div className="wrapper">
10
+      <div>
11
+        <svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
12
+          <g fill="none" fillRule="evenodd">
13
+            <path
14
+              d="M10 0h12a10 10 0 0110 10v12a10 10 0 01-10 10H10A10 10 0 010 22V10A10 10 0 0110 0z"
15
+              fill="#FFF"
16
+            />
17
+            <path
18
+              d="M5.3 10.6l10.4 6v11.1l-10.4-6v-11zm11.4-6.2l9.7 5.5-9.7 5.6V4.4z"
19
+              fill="#555AB9"
20
+            />
21
+            <path
22
+              d="M27.2 10.6v11.2l-10.5 6V16.5l10.5-6zM15.7 4.4v11L6 10l9.7-5.5z"
23
+              fill="#91BAF8"
24
+            />
25
+          </g>
26
+        </svg>
27
+        <h1>Acme</h1>
28
+      </div>
29
+      <div>
30
+        {user ? (
31
+          <Button size="small" onClick={onLogout} label="Log out" />
32
+        ) : (
33
+          <>
34
+            <Button size="small" onClick={onLogin} label="Log in" />
35
+            <Button primary size="small" onClick={onCreateAccount} label="Sign up" />
36
+          </>
37
+        )}
38
+      </div>
39
+    </div>
40
+  </header>
41
+);
42
+
43
+Header.propTypes = {
44
+  user: PropTypes.shape({}),
45
+  onLogin: PropTypes.func.isRequired,
46
+  onLogout: PropTypes.func.isRequired,
47
+  onCreateAccount: PropTypes.func.isRequired,
48
+};
49
+
50
+Header.defaultProps = {
51
+  user: null,
52
+};

+ 18
- 0
stories/Header.stories.js View File

@@ -0,0 +1,18 @@
1
+import React from 'react';
2
+
3
+import { Header } from './Header';
4
+
5
+export default {
6
+  title: 'Example/Header',
7
+  component: Header,
8
+};
9
+
10
+const Template = (args) => <Header {...args} />;
11
+
12
+export const LoggedIn = Template.bind({});
13
+LoggedIn.args = {
14
+  user: {},
15
+};
16
+
17
+export const LoggedOut = Template.bind({});
18
+LoggedOut.args = {};

+ 194
- 0
stories/Introduction.stories.mdx View File

@@ -0,0 +1,194 @@
1
+import { Meta } from '@storybook/addon-docs/blocks';
2
+import Code from './assets/code-brackets.svg';
3
+import Colors from './assets/colors.svg';
4
+import Comments from './assets/comments.svg';
5
+import Direction from './assets/direction.svg';
6
+import Flow from './assets/flow.svg';
7
+import Plugin from './assets/plugin.svg';
8
+import Repo from './assets/repo.svg';
9
+import StackAlt from './assets/stackalt.svg';
10
+
11
+<Meta title="Example/Introduction" />
12
+
13
+<style>{`
14
+  .subheading {
15
+    --mediumdark: '#999999';
16
+    font-weight: 900;
17
+    font-size: 13px;
18
+    color: #999;
19
+    letter-spacing: 6px;
20
+    line-height: 24px;
21
+    text-transform: uppercase;
22
+    margin-bottom: 12px;
23
+    margin-top: 40px;
24
+  }
25
+
26
+  .link-list {
27
+    display: grid;
28
+    grid-template-columns: 1fr;
29
+    grid-template-rows: 1fr 1fr;
30
+    row-gap: 10px;
31
+  }
32
+
33
+  @media (min-width: 620px) {
34
+    .link-list {
35
+      row-gap: 20px;
36
+      column-gap: 20px;
37
+      grid-template-columns: 1fr 1fr;
38
+    }
39
+  }
40
+
41
+  @media all and (-ms-high-contrast:none) {
42
+  .link-list {
43
+      display: -ms-grid;
44
+      -ms-grid-columns: 1fr 1fr;
45
+      -ms-grid-rows: 1fr 1fr;
46
+    }
47
+  }
48
+
49
+  .link-item {
50
+    display: block;
51
+    padding: 20px 30px 20px 15px;
52
+    border: 1px solid #00000010;
53
+    border-radius: 5px;
54
+    transition: background 150ms ease-out, border 150ms ease-out, transform 150ms ease-out;
55
+    color: #333333;
56
+    display: flex;
57
+    align-items: flex-start;
58
+  }
59
+
60
+  .link-item:hover {
61
+    border-color: #1EA7FD50;
62
+    transform: translate3d(0, -3px, 0);
63
+    box-shadow: rgba(0, 0, 0, 0.08) 0 3px 10px 0;
64
+  }
65
+
66
+  .link-item:active {
67
+    border-color: #1EA7FD;
68
+    transform: translate3d(0, 0, 0);
69
+  }
70
+
71
+  .link-item strong {
72
+    font-weight: 700;
73
+    display: block;
74
+    margin-bottom: 2px;
75
+  }
76
+  
77
+  .link-item img {
78
+    height: 40px;
79
+    width: 40px;
80
+    margin-right: 15px;
81
+    flex: none;
82
+  }
83
+
84
+  .link-item span {
85
+    font-size: 14px;
86
+    line-height: 20px;
87
+  }
88
+
89
+  .tip {
90
+    display: inline-block;
91
+    border-radius: 1em;
92
+    font-size: 11px;
93
+    line-height: 12px;
94
+    font-weight: 700;
95
+    background: #E7FDD8;
96
+    color: #66BF3C;
97
+    padding: 4px 12px;
98
+    margin-right: 10px;
99
+    vertical-align: top;
100
+  }
101
+
102
+  .tip-wrapper {
103
+    font-size: 13px;
104
+    line-height: 20px;
105
+    margin-top: 40px;
106
+    margin-bottom: 40px;
107
+  }
108
+
109
+  .tip-wrapper code {
110
+    font-size: 12px;
111
+    display: inline-block;
112
+  }
113
+
114
+  
115
+`}</style>
116
+
117
+# Welcome to Storybook
118
+
119
+Storybook helps you build UI components in isolation from your app's business logic, data, and context.
120
+That makes it easy to develop hard-to-reach states. Save these UI states as **stories** to revisit during development, testing, or QA.
121
+
122
+Browse example stories now by navigating to them in the sidebar.
123
+View their code in the `src/storybook-examples` directory to learn how they work.
124
+We recommend building UIs with a [**component-driven**](https://componentdriven.org) process starting with atomic components and ending with pages.
125
+
126
+<div class="subheading">Configure</div>
127
+
128
+<div class="link-list">
129
+  <a class="link-item" href="https://storybook.js.org/docs/react/api/presets" target="_blank">
130
+    <img src={Plugin} alt="plugin" />
131
+    <span>
132
+      <strong>Presets for popular tools</strong>
133
+      Easy setup for TypeScript, SCSS and more.
134
+    </span>
135
+  </a>
136
+  <a class="link-item" href="https://storybook.js.org/docs/react/configure/webpack" target="_blank">
137
+    <img src={StackAlt} alt="Build" />
138
+    <span>
139
+      <strong>Build configuration</strong>
140
+      How to customize webpack and Babel
141
+    </span>
142
+  </a>
143
+  <a class="link-item" href="https://storybook.js.org/docs/react/configure/styling-and-css" target="_blank">
144
+    <img src={Colors} alt="colors" />
145
+    <span>
146
+      <strong>Styling</strong>
147
+      How to load and configure CSS libraries
148
+    </span>
149
+  </a>
150
+  <a class="link-item" href="https://storybook.js.org/docs/react/get-started/setup#configure-storybook-for-your-stack" target="_blank">
151
+    <img src={Flow} alt="flow" />
152
+    <span>
153
+      <strong>Data</strong>
154
+      Providers and mocking for data libraries
155
+    </span>
156
+  </a>
157
+</div>
158
+
159
+<div class="subheading">Learn</div>
160
+
161
+<div class="link-list">
162
+  <a class="link-item" href="https://storybook.js.org/docs" target="_blank">
163
+    <img src={Repo} alt="repo" />
164
+    <span>
165
+      <strong>Storybook documentation</strong>
166
+      Configure, customize, and extend
167
+    </span>
168
+  </a>
169
+  <a class="link-item" href="https://www.learnstorybook.com" target="_blank">
170
+    <img src={Direction} alt="direction" />
171
+    <span>
172
+      <strong>In-depth guides</strong>
173
+      Best practices from leading teams
174
+    </span>
175
+  </a>
176
+  <a class="link-item" href="https://github.com/storybookjs/storybook" target="_blank">
177
+    <img src={Code} alt="code" />
178
+    <span>
179
+      <strong>GitHub project</strong>
180
+      View the source and add issues
181
+    </span>
182
+  </a>
183
+  <a class="link-item" href="https://discord.gg/UUt2PJb" target="_blank">
184
+    <img src={Comments} alt="comments" />
185
+    <span>
186
+      <strong>Discord chat</strong>
187
+      Chat with maintainers and the community
188
+    </span>
189
+  </a>
190
+</div>
191
+
192
+<div class="tip-wrapper">
193
+  <span class="tip">Tip</span>Edit the Markdown in <code>src/storybook-examples/welcome.mdx</code>
194
+</div>

+ 71
- 0
stories/Page.js View File

@@ -0,0 +1,71 @@
1
+import React from 'react';
2
+import PropTypes from 'prop-types';
3
+
4
+import { Header } from './Header';
5
+import './page.css';
6
+
7
+export const Page = ({ user, onLogin, onLogout, onCreateAccount }) => (
8
+  <article>
9
+    <Header user={user} onLogin={onLogin} onLogout={onLogout} onCreateAccount={onCreateAccount} />
10
+
11
+    <section>
12
+      <h2>Pages in Storybook</h2>
13
+      <p>
14
+        We recommend building UIs with a{' '}
15
+        <a href="https://componentdriven.org" target="_blank" rel="noopener noreferrer">
16
+          <strong>component-driven</strong>
17
+        </a>{' '}
18
+        process starting with atomic components and ending with pages.
19
+      </p>
20
+      <p>
21
+        Render pages with mock data. This makes it easy to build and review page states without
22
+        needing to navigate to them in your app. Here are some handy patterns for managing page data
23
+        in Storybook:
24
+      </p>
25
+      <ul>
26
+        <li>
27
+          Use a higher-level connected component. Storybook helps you compose such data from the
28
+          "args" of child component stories
29
+        </li>
30
+        <li>
31
+          Assemble data in the page component from your services. You can mock these services out
32
+          using Storybook.
33
+        </li>
34
+      </ul>
35
+      <p>
36
+        Get a guided tutorial on component-driven development at{' '}
37
+        <a href="https://www.learnstorybook.com" target="_blank" rel="noopener noreferrer">
38
+          Learn Storybook
39
+        </a>
40
+        . Read more in the{' '}
41
+        <a href="https://storybook.js.org/docs" target="_blank" rel="noopener noreferrer">
42
+          docs
43
+        </a>
44
+        .
45
+      </p>
46
+      <div className="tip-wrapper">
47
+        <span className="tip">Tip</span> Adjust the width of the canvas with the{' '}
48
+        <svg width="10" height="10" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
49
+          <g fill="none" fillRule="evenodd">
50
+            <path
51
+              d="M1.5 5.2h4.8c.3 0 .5.2.5.4v5.1c-.1.2-.3.3-.4.3H1.4a.5.5 0 01-.5-.4V5.7c0-.3.2-.5.5-.5zm0-2.1h6.9c.3 0 .5.2.5.4v7a.5.5 0 01-1 0V4H1.5a.5.5 0 010-1zm0-2.1h9c.3 0 .5.2.5.4v9.1a.5.5 0 01-1 0V2H1.5a.5.5 0 010-1zm4.3 5.2H2V10h3.8V6.2z"
52
+              id="a"
53
+              fill="#999"
54
+            />
55
+          </g>
56
+        </svg>
57
+        Viewports addon in the toolbar
58
+      </div>
59
+    </section>
60
+  </article>
61
+);
62
+Page.propTypes = {
63
+  user: PropTypes.shape({}),
64
+  onLogin: PropTypes.func.isRequired,
65
+  onLogout: PropTypes.func.isRequired,
66
+  onCreateAccount: PropTypes.func.isRequired,
67
+};
68
+
69
+Page.defaultProps = {
70
+  user: null,
71
+};

+ 21
- 0
stories/Page.stories.js View File

@@ -0,0 +1,21 @@
1
+import React from 'react';
2
+
3
+import { Page } from './Page';
4
+import * as HeaderStories from './Header.stories';
5
+
6
+export default {
7
+  title: 'Example/Page',
8
+  component: Page,
9
+};
10
+
11
+const Template = (args) => <Page {...args} />;
12
+
13
+export const LoggedIn = Template.bind({});
14
+LoggedIn.args = {
15
+  ...HeaderStories.LoggedIn.args,
16
+};
17
+
18
+export const LoggedOut = Template.bind({});
19
+LoggedOut.args = {
20
+  ...HeaderStories.LoggedOut.args,
21
+};

+ 8
- 0
stories/assets/code-brackets.svg View File

@@ -0,0 +1,8 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3
+    <title>illustration/code-brackets</title>
4
+    <g id="illustration/code-brackets" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
5
+        <path d="M11.4139325,12 C11.7605938,12 12,12.5059743 12,13.3779712 L12,17.4951758 L6.43502246,23.3839989 C5.85499251,23.9978337 5.85499251,25.0021663 6.43502246,25.6160011 L12,31.5048242 L12,35.6220288 C12,36.4939606 11.7605228,37 11.4139325,37 C11.2725831,37 11.1134406,36.9158987 10.9453839,36.7379973 L0.435022463,25.6160011 C-0.145007488,25.0021663 -0.145007488,23.9978337 0.435022463,23.3839989 L10.9453839,12.2620027 C11.1134051,12.0841663 11.2725831,12 11.4139325,12 Z M36.5860675,12 C36.7274169,12 36.8865594,12.0841013 37.0546161,12.2620027 L47.5649775,23.3839989 C48.1450075,23.9978337 48.1450075,25.0021663 47.5649775,25.6160011 L37.0546161,36.7379973 C36.8865949,36.9158337 36.7274169,37 36.5860675,37 C36.2394062,37 36,36.4940257 36,35.6220288 L36,31.5048242 L41.5649775,25.6160011 C42.1450075,25.0021663 42.1450075,23.9978337 41.5649775,23.3839989 L36,17.4951758 L36,13.3779712 C36,12.5060394 36.2394772,12 36.5860675,12 Z" id="Combined-Shape" fill="#87E6E5"></path>
6
+        <rect id="Rectangle-7-Copy-5" fill="#A0DB77" transform="translate(22.793959, 24.662305) rotate(-75.000000) translate(-22.793959, -24.662305) " x="5.00895926" y="22.6623047" width="35.5699997" height="4" rx="2"></rect>
7
+    </g>
8
+</svg>

+ 13
- 0
stories/assets/colors.svg
File diff suppressed because it is too large
View File


+ 9
- 0
stories/assets/comments.svg View File

@@ -0,0 +1,9 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3
+    <title>illustration/comments</title>
4
+    <g id="illustration/comments" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
5
+        <path d="M2.52730803,17.9196415 C2.44329744,17.9745167 2.36370847,18.000488 2.29303375,18.000488 C2.1197031,18.000488 2,17.8443588 2,17.5752855 L2,4 C2,1.790861 3.790861,3.23296945e-13 6,3.23296945e-13 L33.9995117,3.23296945e-13 C36.2086507,3.23296945e-13 37.9995117,1.790861 37.9995117,4 L37.9995117,9.999512 C37.9995117,12.208651 36.2086507,13.999512 33.9995117,13.999512 L8,13.999512 C7.83499225,13.999512 7.6723181,13.9895206 7.51254954,13.9701099 L2.52730803,17.9196415 Z" id="Path" fill="#96D07C"></path>
6
+        <path d="M7.51066,44.9703679 L2.52730803,47.9186655 C2.44329744,47.9735407 2.36370847,47.999512 2.29303375,47.999512 C2.1197031,47.999512 2,47.8433828 2,47.5743095 L2,35 C2,32.790861 3.790861,31 6,31 L26,31 C28.209139,31 30,32.790861 30,35 L30,41 C30,43.209139 28.209139,45 26,45 L8,45 C7.8343417,45 7.67103544,44.9899297 7.51066,44.9703679 Z" id="Path" fill="#73E1E0"></path>
7
+        <path d="M46,19.5 L46,33.0747975 C46,33.3438708 45.8802969,33.5 45.7069663,33.5 C45.6362915,33.5 45.5567026,33.4740287 45.472692,33.4191535 L40.4887103,29.4704446 C40.3285371,29.489956 40.1654415,29.5 40,29.5 L18,29.5 C15.790861,29.5 14,27.709139 14,25.5 L14,19.5 C14,17.290861 15.790861,15.5 18,15.5 L42,15.5 C44.209139,15.5 46,17.290861 46,19.5 Z" id="Path" fill="#FFD476"></path>
8
+    </g>
9
+</svg>

+ 8
- 0
stories/assets/direction.svg View File

@@ -0,0 +1,8 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3
+    <title>illustration/direction</title>
4
+    <g id="illustration/direction" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
5
+        <path d="M23.4917015,33.6030641 L2.93840258,31.4321033 C2.38917316,31.3740904 1.99096346,30.8818233 2.04897631,30.3325939 C2.0747515,30.0885705 2.18934861,29.8625419 2.37095722,29.6975265 L34.2609105,0.721285325 C34.6696614,0.349881049 35.3021022,0.38015648 35.6735064,0.788907393 C35.9232621,1.06377731 36.0001133,1.45442096 35.8730901,1.80341447 L24.5364357,32.9506164 C24.3793473,33.3822133 23.9484565,33.6513092 23.4917015,33.6030641 L23.4917015,33.6030641 Z" id="Combined-Shape" fill="#FFD476"></path>
6
+        <path d="M24.3163597,33.2881029 C24.0306575,33.0138462 23.9337246,32.5968232 24.069176,32.2246735 L35.091923,1.9399251 C35.2266075,1.56988243 35.5659249,1.31333613 35.9586669,1.28460955 C36.5094802,1.24432106 36.9886628,1.65818318 37.0289513,2.20899647 L40.2437557,46.1609256 C40.2644355,46.4436546 40.1641446,46.7218752 39.9678293,46.9263833 C39.5853672,47.3248067 38.9523344,47.3377458 38.5539111,46.9552837 L24.3163597,33.2881029 L24.3163597,33.2881029 Z" id="Combined-Shape-Copy" fill="#FFC445"></path>
7
+    </g>
8
+</svg>

+ 8
- 0
stories/assets/flow.svg View File

@@ -0,0 +1,8 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3
+    <title>illustration/flow</title>
4
+    <g id="illustration/flow" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
5
+        <path d="M30,29 C32.7614237,29 35,26.7614237 35,24 C35,14.6111593 27.3888407,7 18,7 C8.61115925,7 1,14.6111593 1,24 C1,33.3888407 8.61115925,41 18,41 C19.3333404,41 20.6447683,40.8466238 21.9154603,40.5471706 C19.5096374,39.3319645 17.5510566,37.8612875 16.0456579,36.1314815 C14.1063138,33.9030427 12.769443,31.0725999 12.0293806,27.6556449 C11.360469,26.565281 11,25.3082308 11,24 C11,20.1340068 14.1340068,17 18,17 C21.8659932,17 25,20.1340068 25,24 C25,26.125 27.7040312,29 30,29 Z" id="Combined-Shape" fill="#79C9FC" fill-rule="nonzero"></path>
6
+        <path d="M42,29 C44.7614237,29 47,26.7614237 47,24 C47,14.6111593 39.3888407,7 30,7 C20.6111593,7 13,14.6111593 13,24 C13,33.3888407 20.6111593,41 30,41 C31.3333404,41 32.6447683,40.8466238 33.9154603,40.5471706 C31.5096374,39.3319645 29.4051056,37.9781963 28.0456579,36.1314815 C26.0625,33.4375 23,27.1875 23,24 C23,20.1340068 26.1340068,17 30,17 C33.8659932,17 37,20.1340068 37,24 C37.02301,26.3435241 39.7040312,29 42,29 Z" id="Combined-Shape-Copy" fill="#FFC445" fill-rule="nonzero" transform="translate(30.000000, 24.000000) scale(-1, -1) translate(-30.000000, -24.000000) "></path>
7
+    </g>
8
+</svg>

+ 8
- 0
stories/assets/plugin.svg View File

@@ -0,0 +1,8 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3
+    <title>illustration/plugin</title>
4
+    <g id="illustration/plugin" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
5
+        <path d="M26,15.3994248 C26,15.4091303 26,15.4188459 26,15.4285714 L26,21.4694881 C25.8463595,21.4969567 25.6941676,21.51275 25.5873784,21.51275 C25.4974117,21.51275 25.4230979,21.4768034 25.377756,21.4206259 L25.2660784,21.2822603 L25.1317423,21.1657666 C24.2436317,20.3956144 23.100098,19.9633214 21.895551,19.9633214 C19.2039137,19.9633214 17,22.1075558 17,24.7804643 C17,27.4533728 19.2039137,29.5976071 21.895551,29.5976071 C23.1972122,29.5976071 24.3149423,29.2878193 25.1231445,28.3613697 C25.4542273,27.9818463 25.568273,27.9073214 25.5873784,27.9073214 C25.681532,27.9073214 25.8352452,27.9239643 26,27.9524591 L26,32.5714286 C26,32.5811541 26,32.5908697 26,32.6005752 L26,33 C26,35.209139 24.209139,37 22,37 L4,37 C1.790861,37 0,35.209139 0,33 L0,15 C0,12.790861 1.790861,11 4,11 L22,11 C24.209139,11 26,12.790861 26,15 L26,15.3994248 Z" id="Combined-Shape" fill="#79C9FC"></path>
6
+        <path d="M27.9998779,32.5714286 C27.9998779,33.3604068 28.6572726,34 29.4682101,34 L46.5315458,34 C47.3424832,34 47.9998779,33.3604068 47.9998779,32.5714286 L47.9998779,15.4285714 C47.9998779,14.6395932 47.3424832,14 46.5315458,14 L29.4682101,14 C28.6572726,14 27.9998779,14.6395932 27.9998779,15.4285714 L27.9998779,21.8355216 C27.9334367,22.2650514 27.8567585,22.6454496 27.746391,22.8084643 C27.4245309,23.2838571 26.2402709,23.51275 25.5873784,23.51275 C24.8705773,23.51275 24.2322714,23.1857725 23.8214379,22.6767605 C23.3096996,22.2329909 22.6349941,21.9633214 21.895551,21.9633214 C20.2963823,21.9633214 19,23.2245992 19,24.7804643 C19,26.3363293 20.2963823,27.5976071 21.895551,27.5976071 C22.5398535,27.5976071 23.2399343,27.477727 23.6160247,27.0466112 C24.1396029,26.4464286 24.7367044,25.9073214 25.5873784,25.9073214 C26.2402709,25.9073214 27.5912951,26.1766031 27.8226692,26.6116071 C27.8819199,26.7230038 27.9403239,26.921677 27.9998779,27.1556219 L27.9998779,32.5714286 Z" id="Path" fill="#87E6E5"></path>
7
+    </g>
8
+</svg>

+ 9
- 0
stories/assets/repo.svg View File

@@ -0,0 +1,9 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3
+    <title>illustration/repo</title>
4
+    <g id="illustration/repo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
5
+        <path d="M27.2217723,9.04506931 L41.2217723,6.2682098 C43.3886973,5.83840648 45.4937616,7.2466219 45.9235649,9.41354696 C45.9743993,9.66983721 46,9.93049166 46,10.1917747 L46,32.581381 C46,34.4904961 44.650862,36.1335143 42.7782277,36.5049459 L28.7782277,39.2818054 C26.6113027,39.7116087 24.5062384,38.3033933 24.0764351,36.1364682 C24.0256007,35.880178 24,35.6195235 24,35.3582405 L24,12.9686342 C24,11.0595191 25.349138,9.4165009 27.2217723,9.04506931 Z" id="Rectangle-62-Copy" fill="#B7F0EF" opacity="0.699999988"></path>
6
+        <path d="M6.77822775,6.2682098 L20.7782277,9.04506931 C22.650862,9.4165009 24,11.0595191 24,12.9686342 L24,35.3582405 C24,37.5673795 22.209139,39.3582405 20,39.3582405 C19.738717,39.3582405 19.4780625,39.3326398 19.2217723,39.2818054 L5.22177225,36.5049459 C3.34913798,36.1335143 2,34.4904961 2,32.581381 L2,10.1917747 C2,7.98263571 3.790861,6.19177471 6,6.19177471 C6.26128305,6.19177471 6.5219375,6.21737537 6.77822775,6.2682098 Z" id="Combined-Shape" fill="#87E6E5"></path>
7
+        <path d="M22,10 C23.1666667,10.2291667 24.0179036,10.625 24.5537109,11.1875 C25.0895182,11.75 25.5716146,12.875 26,14.5625 C26,29.3020833 26,37.5208333 26,39.21875 C26,40.9166667 26.4241536,42.9583333 27.2724609,45.34375 L24.5537109,41.875 L22.9824219,45.34375 C22.327474,43.1979167 22,41.2291667 22,39.4375 C22,37.6458333 22,27.8333333 22,10 Z" id="Rectangle-63-Copy-2" fill="#61C1FD"></path>
8
+    </g>
9
+</svg>

+ 10
- 0
stories/assets/stackalt.svg View File

@@ -0,0 +1,10 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3
+    <title>illustration/stackalt</title>
4
+    <g id="illustration/stackalt" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
5
+        <path d="M23.8628277,0 L23.8628277,48 L3.32291648,36.2491883 L3.32155653,11.9499781 L23.8628277,0 Z M23.8670509,0 L44.408322,11.9499781 L44.4069621,36.2491883 L23.8670509,48 L23.8670509,0 Z" id="Combined-Shape" fill="#FFAE00" opacity="0.195595562"></path>
6
+        <path d="M15.8232279,19.1155258 L24.7368455,21.4714881 C29.6053842,22.7582937 33.4077423,26.5606518 34.694548,31.4291905 L37.0505103,40.3428082 C37.6150232,42.4786032 36.3412474,44.6676353 34.2054524,45.2321482 C33.5569474,45.4035549 32.87575,45.4091235 32.2245294,45.2483418 L23.3459013,43.0562718 C18.2976962,41.809906 14.3561301,37.8683399 13.1097642,32.8201348 L10.9176943,23.9415066 C10.3881737,21.7967682 11.6975664,19.6288529 13.8423049,19.0993322 C14.4935255,18.9385505 15.1747229,18.9441191 15.8232279,19.1155258 Z" id="Rectangle-46-Copy-3" fill="#66BF3C" opacity="0.5" transform="translate(23.999997, 32.166058) rotate(-45.000000) translate(-23.999997, -32.166058) "></path>
7
+        <path d="M15.8232279,11.2216893 L24.7368455,13.5776516 C29.6053842,14.8644572 33.4077423,18.6668153 34.694548,23.5353541 L37.0505103,32.4489717 C37.6150232,34.5847667 36.3412474,36.7737988 34.2054524,37.3383117 C33.5569474,37.5097184 32.87575,37.515287 32.2245294,37.3545053 L23.3459013,35.1624353 C18.2976962,33.9160695 14.3561301,29.9745034 13.1097642,24.9262983 L10.9176943,16.0476701 C10.3881737,13.9029317 11.6975664,11.7350164 13.8423049,11.2054957 C14.4935255,11.044714 15.1747229,11.0502826 15.8232279,11.2216893 Z" id="Rectangle-46-Copy-2" fill="#FFAE00" opacity="0.5" transform="translate(23.999997, 24.272222) rotate(-45.000000) translate(-23.999997, -24.272222) "></path>
8
+        <path d="M15.8232279,3.32785281 L24.7368455,5.68381509 C29.6053842,6.97062075 33.4077423,10.7729788 34.694548,15.6415176 L37.0505103,24.5551352 C37.6150232,26.6909302 36.3412474,28.8799623 34.2054524,29.4444752 C33.5569474,29.6158819 32.87575,29.6214505 32.2245294,29.4606688 L23.3459013,27.2685988 C18.2976962,26.022233 14.3561301,22.0806669 13.1097642,17.0324618 L10.9176943,8.15383364 C10.3881737,6.00909519 11.6975664,3.84117987 13.8423049,3.31165925 C14.4935255,3.15087753 15.1747229,3.15644615 15.8232279,3.32785281 Z" id="Rectangle-46-Copy" fill="#FC521F" opacity="0.5" transform="translate(23.999997, 16.378385) rotate(-45.000000) translate(-23.999997, -16.378385) "></path>
9
+    </g>
10
+</svg>

+ 30
- 0
stories/button.css View File

@@ -0,0 +1,30 @@
1
+.storybook-button {
2
+  font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
3
+  font-weight: 700;
4
+  border: 0;
5
+  border-radius: 3em;
6
+  cursor: pointer;
7
+  display: inline-block;
8
+  line-height: 1;
9
+}
10
+.storybook-button--primary {
11
+  color: white;
12
+  background-color: #1ea7fd;
13
+}
14
+.storybook-button--secondary {
15
+  color: #333;
16
+  background-color: transparent;
17
+  box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
18
+}
19
+.storybook-button--small {
20
+  font-size: 12px;
21
+  padding: 10px 16px;
22
+}
23
+.storybook-button--medium {
24
+  font-size: 14px;
25
+  padding: 11px 20px;
26
+}
27
+.storybook-button--large {
28
+  font-size: 16px;
29
+  padding: 12px 24px;
30
+}

+ 26
- 0
stories/header.css View File

@@ -0,0 +1,26 @@
1
+.wrapper {
2
+  font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
3
+  border-bottom: 1px solid rgba(0, 0, 0, 0.1);
4
+  padding: 15px 20px;
5
+  display: flex;
6
+  align-items: center;
7
+  justify-content: space-between;
8
+}
9
+
10
+svg {
11
+  display: inline-block;
12
+  vertical-align: top;
13
+}
14
+
15
+h1 {
16
+  font-weight: 900;
17
+  font-size: 20px;
18
+  line-height: 1;
19
+  margin: 6px 0 6px 10px;
20
+  display: inline-block;
21
+  vertical-align: top;
22
+}
23
+
24
+button + button {
25
+  margin-left: 10px;
26
+}

+ 0
- 0
stories/page.css View File


+ 50
- 0
tsconfig.json View File

@@ -0,0 +1,50 @@
1
+{
2
+  "compilerOptions": {
3
+    "target": "esnext",
4
+    "rootDirs": [
5
+      "src",
6
+      "stories"
7
+    ],
8
+    "lib": [
9
+      "dom",
10
+      "dom.iterable",
11
+      "esnext"
12
+    ],
13
+    "allowJs": true,
14
+    // rollup build:lib plugin require
15
+    "noEmitHelpers": false,
16
+    "importHelpers": true,
17
+    "noResolve": false,
18
+    "noEmit": false,
19
+    "inlineSourceMap": false,
20
+    // 
21
+    "declaration": true,
22
+    // "declarationDir": "./declare",
23
+    "skipLibCheck": true,
24
+    "esModuleInterop": true,
25
+    "removeComments": true,
26
+    "allowSyntheticDefaultImports": true,
27
+    "strict": true,
28
+    "forceConsistentCasingInFileNames": true,
29
+    "moduleResolution": "node",
30
+    "module": "esnext",
31
+    "resolveJsonModule": true,
32
+    "isolatedModules": true,
33
+    "noEmit": true,
34
+    "traceResolution": true,
35
+    "jsx": "react",
36
+    "baseUrl": ".",
37
+    "paths": {
38
+      "@/*": ["src/*"],
39
+      "@components/*": [ "src/components/*" ],
40
+      "@services": [ "src/services/*" ],
41
+    }
42
+  },
43
+  "include": [
44
+    "src/**/*",
45
+    "stories/**/*"
46
+  ],
47
+  "exclude": [
48
+    "node_modules"
49
+  ]
50
+}

+ 11579
- 0
yarn.lock
File diff suppressed because it is too large
View File