root: upload source code

pull/8/head
fantasticit 2022-02-20 19:51:55 +08:00
commit 306d7765ec
383 changed files with 36979 additions and 0 deletions

19
.gitignore vendored 100644
View File

@ -0,0 +1,19 @@
node_modules
.DS_Store
.idea
dist
dist-ssr
coverage
test-results
.pnpm-store
.npmrc
tsconfig.tsbuildinfo
.env
*.local
*.cache
*error.log
*debug.log
packages/config/yaml/prod.yaml

181
README.md 100644
View File

@ -0,0 +1,181 @@
# think
## 简介
Think 是一款开源知识管理工具。通过独立的知识库空间,结构化地组织在线协作文档,实现知识的积累与沉淀,促进知识的复用与流通。同时支持多人协作文档。使用的技术如下:
- `MySQL`:数据存储
- `next.js`:前端页面框架
- `nest.js`:服务端框架
- `AliyunOSS`:对象存储
- `tiptap`:编辑器及文档协作
可访问[云策文档帮助中心](https://think.wipi.tech/share/wiki/4e3d0cfb-b169-4308-8037-e7d3df996af3),查看更多功能文档。
## 链接
[云策文档](https://think.wipi.tech/)已经部署上线,可前往注册使用。
## 项目运行
本项目依赖 pnpm 使用 monorepo 形式进行代码组织,分包如下:
- `@think/config`: 管理项目整体配置
- `@think/share`:数据类型定义、枚举、配置等
- `@think/server`:服务端
- `@think/client`:客户端
### pnpm
项目依赖 pnpm请安装后运行`npm i -g pnpm`)。
### 数据库
首先安装 `MySQL`,推荐使用 docker 进行安装。
```bash
docker image pull mysql:5.7
docker run -d --restart=always --name think -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root mysql:5.7
```
然后在 `MySQL` 中创建数据库。
```bash
docker container exec -it think bash;
mysql -u root -p;
CREATE DATABASE `think` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
### 本地运行
首先clone 项目。
```bash
git clone --depth=1 https://github.com/fantasticit/think.git your-project-name
```
然后,安装项目依赖。
```bash
pnpm install
```
- 启动项目
```bash
pnpm run dev
```
前台页面地址:`http://localhost:3000`。
服务接口地址:`http://localhost:5001`。
协作接口地址:`http://localhost:5003`。
如需修改配置,可在 `packages/config/yaml` 下进行配置。
### 配置文件
默认加载 `dev.yaml` 中的配置(生产环境使用 `prod.yaml` )。
```yaml
# 开发环境配置
server:
prefix: "/api"
port: 5001
collaborationPort: 5003
client:
assetPrefix: "/"
apiUrl: "http://localhost:5001/api"
collaborationUrl: "ws://localhost:5003"
# 数据库配置
db:
mysql:
host: "127.0.0.1"
username: "root"
password: "root"
database: "think"
port: 3306
charset: "utf8mb4"
timezone: "+08:00"
synchronize: true
# oss 文件存储服务
oss:
aliyun:
accessKeyId: ""
accessKeySecret: ""
bucket: ""
https: true
region: ""
# jwt 配置
jwt:
secretkey: "zA_Think+KNOWLEDGE+WIKI+DOCUMENTS@2022"
expiresIn: "6h"
```
### 项目部署
生产环境部署的脚本如下:
```bash
node -v
npm -v
npm config set registry http://registry.npmjs.org
npm i -g pm2 @nestjs/cli pnpm
pnpm install
pnpm run build
pnpm run pm2
pm2 startup
pm2 save
```
### nginx 配置
采用反向代理进行 `nginx` 配置,**同时设置 `proxy_set_header X-Real-IP $remote_addr;` 以便服务端获取到真实 ip 地址**。
```bash
upstream wipi_client {
server 127.0.0.1:3000;
keepalive 64;
}
# http -> https 重定向
server {
listen 80;
server_name 域名;
rewrite ^(.*)$ https://$host$1 permanent;
}
server {
listen 443 ssl;
server_name 域名;
ssl_certificate 证书存放路径;
ssl_certificate_key 证书存放路径;
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Nginx-Proxy true;
proxy_cache_bypass $http_upgrade;
proxy_pass http://wipi_client; #反向代理
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
## 资料
- next.js 源码https://github.com/vercel/next.js
- next.js 文档https://nextjs.org/
- nest.js 源码https://github.com/nestjs/nest
- nest.js 文档https://nestjs.com/

34
package.json 100644
View File

@ -0,0 +1,34 @@
{
"name": "think",
"private": true,
"author": "fantasticit",
"scripts": {
"clean": "rimraf node_modules && rimraf ./**/node_modules",
"dev": "concurrently \"pnpm:dev:*\"",
"dev:server": "pnpm run --dir packages/server dev",
"dev:client": "pnpm run --dir packages/client dev",
"build": "pnpm build:share && pnpm build:server && pnpm build:client",
"build:config": "pnpm run --dir packages/config build",
"build:share": "pnpm run --dir packages/share build",
"build:server": "pnpm run --dir packages/server build",
"build:client": "pnpm run --dir packages/client build",
"start": "concurrently \"pnpm:start:*\"",
"start:server": "pnpm run --dir packages/server start",
"start:client": "pnpm run --dir packages/client start",
"pm2": "concurrently \"pnpm:pm2:*\"",
"pm2:server": "pnpm run --dir packages/server pm2",
"pm2:client": "pnpm run --dir packages/client pm2"
},
"dependencies": {
"concurrently": "^7.0.0",
"cross-env": "^7.0.3",
"fs-extra": "^10.0.0",
"rimraf": "^3.0.2"
},
"engines": {
"node": ">=16.5.0"
},
"devDependencies": {
"typescript": "^4.5.5"
}
}

View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

37
packages/client/.gitignore vendored 100644
View File

@ -0,0 +1,37 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# typescript
*.tsbuildinfo

View File

@ -0,0 +1,34 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

5
packages/client/next-env.d.ts vendored 100644
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@ -0,0 +1,23 @@
const semi = require("@douyinfe/semi-next").default({});
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
const { getConfig } = require("@think/config");
const config = getConfig().client;
/** @type {import('next').NextConfig} */
const nextConfig = semi({
reactStrictMode: true,
assetPrefix: config.assetPrefix,
env: {
SERVER_API_URL: config.apiUrl,
COLLABORATION_API_URL: config.collaborationUrl,
},
webpack: (config, { dev, isServer }) => {
config.resolve.plugins.push(new TsconfigPathsPlugin());
return config;
},
eslint: {
ignoreDuringBuilds: true,
},
});
module.exports = nextConfig;

View File

@ -0,0 +1,82 @@
{
"name": "@think/client",
"private": true,
"scripts": {
"dev": "next dev",
"prebuild": "rimraf .next",
"build": "next build",
"start": "cross-env NODE_ENV=production next start -p 5002",
"lint": "next lint",
"pm2": "pm2 start npm --name @think/client -- start"
},
"dependencies": {
"@douyinfe/semi-icons": "^2.3.1",
"@douyinfe/semi-next": "^2.3.1",
"@douyinfe/semi-ui": "^2.3.1",
"@hocuspocus/provider": "^1.0.0-alpha.29",
"@think/config": "workspace:^1.0.0",
"@think/share": "workspace:^1.0.0",
"@tiptap/core": "^2.0.0-beta.171",
"@tiptap/extension-blockquote": "^2.0.0-beta.26",
"@tiptap/extension-bold": "^2.0.0-beta.25",
"@tiptap/extension-bullet-list": "^2.0.0-beta.26",
"@tiptap/extension-code": "^2.0.0-beta.26",
"@tiptap/extension-code-block": "^2.0.0-beta.37",
"@tiptap/extension-code-block-lowlight": "^2.0.0-beta.68",
"@tiptap/extension-collaboration": "^2.0.0-beta.33",
"@tiptap/extension-collaboration-cursor": "^2.0.0-beta.34",
"@tiptap/extension-color": "^2.0.0-beta.9",
"@tiptap/extension-document": "^2.0.0-beta.15",
"@tiptap/extension-dropcursor": "^2.0.0-beta.25",
"@tiptap/extension-gapcursor": "^2.0.0-beta.34",
"@tiptap/extension-hard-break": "^2.0.0-beta.30",
"@tiptap/extension-heading": "^2.0.0-beta.26",
"@tiptap/extension-highlight": "^2.0.0-beta.33",
"@tiptap/extension-horizontal-rule": "^2.0.0-beta.31",
"@tiptap/extension-image": "^2.0.0-beta.25",
"@tiptap/extension-italic": "^2.0.0-beta.25",
"@tiptap/extension-link": "^2.0.0-beta.36",
"@tiptap/extension-list-item": "^2.0.0-beta.20",
"@tiptap/extension-ordered-list": "^2.0.0-beta.27",
"@tiptap/extension-paragraph": "^2.0.0-beta.23",
"@tiptap/extension-placeholder": "^2.0.0-beta.47",
"@tiptap/extension-strike": "^2.0.0-beta.27",
"@tiptap/extension-table": "^2.0.0-beta.48",
"@tiptap/extension-table-cell": "^2.0.0-beta.20",
"@tiptap/extension-table-header": "^2.0.0-beta.22",
"@tiptap/extension-table-row": "^2.0.0-beta.19",
"@tiptap/extension-task-item": "^2.0.0-beta.31",
"@tiptap/extension-task-list": "^2.0.0-beta.26",
"@tiptap/extension-text": "^2.0.0-beta.15",
"@tiptap/extension-text-align": "^2.0.0-beta.29",
"@tiptap/extension-text-style": "^2.0.0-beta.23",
"@tiptap/extension-underline": "^2.0.0-beta.23",
"@tiptap/react": "^2.0.0-beta.107",
"axios": "^0.25.0",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.1",
"deep-equal": "^2.0.5",
"dompurify": "^2.3.5",
"interactjs": "^1.10.11",
"katex": "^0.15.2",
"lowlight": "^2.5.0",
"marked": "^4.0.12",
"next": "12.0.10",
"prosemirror-markdown": "^1.7.0",
"prosemirror-view": "^1.23.6",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-helmet": "^6.1.0",
"react-split-pane": "^0.1.92",
"swr": "^1.2.0",
"tippy.js": "^6.3.7"
},
"devDependencies": {
"@types/node": "17.0.13",
"@types/react": "17.0.38",
"eslint": "8.8.0",
"eslint-config-next": "12.0.10",
"tsconfig-paths-webpack-plugin": "^3.5.2",
"typescript": "4.5.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,20 @@
import { Space, Typography } from "@douyinfe/semi-ui";
import { IconLikeHeart } from "@douyinfe/semi-icons";
const { Text } = Typography;
export const Author = () => {
return (
<div style={{ padding: "16px 0", textAlign: "center" }}>
<Text>
<Space>
Develop by
<Text link={{ href: "https://github.com/fantasticit/think" }}>
fantasticit
</Text>
with <IconLikeHeart style={{ color: "red" }} />
</Space>
</Text>
</div>
);
};

View File

@ -0,0 +1,43 @@
import React from "react";
import { Empty, Spin, Typography } from "@douyinfe/semi-ui";
type RenderProps = React.ReactNode | (() => React.ReactNode);
interface IProps {
loading: boolean;
error: Error | null;
loadingContent?: RenderProps;
errorContent?: RenderProps;
normalContent: RenderProps;
}
const { Text } = Typography;
const defaultLoading = () => {
return <Spin />;
};
const defaultRenderError = (error) => {
return <Text>{(error && error.message) || "未知错误"}</Text>;
};
const runRender = (fn, ...args) =>
typeof fn === "function" ? fn.apply(null, args) : fn;
export const DataRender: React.FC<IProps> = ({
loading,
error,
loadingContent = defaultLoading,
errorContent = defaultRenderError,
normalContent,
}) => {
if (loading) {
return runRender(loadingContent);
}
if (error) {
return runRender(errorContent, error);
}
return runRender(normalContent);
};

View File

@ -0,0 +1,36 @@
import React from "react";
import { Button } from "@douyinfe/semi-ui";
import { useToggle } from "hooks/useToggle";
import { useQuery } from "hooks/useQuery";
import { DocumentCreator as DocumenCreatorForm } from "components/document/create";
interface IProps {
onCreateDocument?: () => void;
}
export const DocumentCreator: React.FC<IProps> = ({
onCreateDocument,
children,
}) => {
const { wikiId, docId } = useQuery<{ wikiId?: string; docId?: string }>();
const [visible, toggleVisible] = useToggle(false);
return (
<>
{children || (
<Button type="primary" theme="solid" onClick={toggleVisible}>
</Button>
)}
{wikiId && (
<DocumenCreatorForm
wikiId={wikiId}
parentDocumentId={docId}
visible={visible}
toggleVisible={toggleVisible}
onCreate={onCreateDocument}
/>
)}
</>
);
};

View File

@ -0,0 +1,112 @@
import React, { useCallback } from "react";
import { Dropdown, Button, Typography, Space } from "@douyinfe/semi-ui";
import { IconMore, IconStar, IconPlus } from "@douyinfe/semi-icons";
import { DocumentLinkCopyer } from "components/document/link";
import { DocumentDeletor } from "components/document/delete";
import { DocumentCreator } from "components/document/create";
import { DocumentStar } from "components/document/star";
import { useToggle } from "hooks/useToggle";
interface IProps {
wikiId: string;
documentId: string;
onStar?: () => void;
onCreate?: () => void;
onDelete?: () => void;
onVisibleChange?: () => void;
showCreateDocument?: boolean;
}
const { Text } = Typography;
export const DocumentActions: React.FC<IProps> = ({
wikiId,
documentId,
onStar,
onCreate,
onDelete,
onVisibleChange,
showCreateDocument,
children,
}) => {
const [visible, toggleVisible] = useToggle(false);
const prevent = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
}, []);
return (
<>
<Dropdown
onVisibleChange={onVisibleChange}
render={
<Dropdown.Menu>
{showCreateDocument && (
<Dropdown.Item onClick={prevent}>
<Text onClick={toggleVisible}>
<Space>
<IconPlus />
</Space>
</Text>
</Dropdown.Item>
)}
<Dropdown.Item onClick={prevent}>
<DocumentStar
documentId={documentId}
render={({ star, toggleStar, text }) => (
<Text
onClick={() => {
toggleStar().then(onStar);
}}
>
<Space>
<IconStar
style={{
color: star
? "rgba(var(--semi-amber-4), 1)"
: "rgba(var(--semi-grey-3), 1)",
}}
/>
{text}
</Space>
</Text>
)}
/>
</Dropdown.Item>
<Dropdown.Item onClick={prevent}>
<DocumentLinkCopyer wikiId={wikiId} documentId={documentId} />
</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item onClick={prevent}>
<DocumentDeletor
wikiId={wikiId}
documentId={documentId}
onDelete={onDelete}
/>
</Dropdown.Item>
</Dropdown.Menu>
}
>
{children || (
<Button
onClick={prevent}
icon={<IconMore />}
theme="borderless"
type="tertiary"
/>
)}
</Dropdown>
{showCreateDocument && (
<DocumentCreator
wikiId={wikiId}
parentDocumentId={documentId}
visible={visible}
toggleVisible={toggleVisible}
onCreate={onCreate}
/>
)}
</>
);
};

View File

@ -0,0 +1,40 @@
.cardWrap {
width: 100%;
> a {
margin: 8px 0;
display: flex;
flex-direction: column;
width: 100%;
max-height: 260px;
padding: 12px 16px 16px;
border-radius: 5px;
border: 1px solid var(--semi-color-border);
cursor: pointer;
> header {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--semi-color-primary);
margin-bottom: 12px;
.rightWrap {
opacity: 0;
}
}
&:hover {
box-shadow: var(--box-shadow);
> header .rightWrap {
opacity: 1;
}
}
> footer {
margin-top: 12px;
}
}
}

View File

@ -0,0 +1,123 @@
import { useCallback } from "react";
import Router from "next/router";
import Link from "next/link";
import {
Button,
Space,
Typography,
Tooltip,
Avatar,
Skeleton,
} from "@douyinfe/semi-ui";
import { IconEdit, IconUser } from "@douyinfe/semi-icons";
import { IDocument } from "@think/share";
import { LocaleTime } from "components/locale-time";
import { IconDocument } from "components/icons/IconDocument";
import { DocumentShare } from "components/document/share";
import { DocumentStar } from "components/document/star";
import styles from "./index.module.scss";
const { Text } = Typography;
export const DocumentCard: React.FC<{ document: IDocument }> = ({
document,
}) => {
const gotoEdit = useCallback(() => {
Router.push(`/wiki/${document.wikiId}/document/${document.id}/edit`);
}, [document]);
return (
<div className={styles.cardWrap}>
<Link
href={{
pathname: `/wiki/[wikiId]/document/[documentId]`,
query: { wikiId: document.wikiId, documentId: document.id },
}}
>
<a>
<header
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<IconDocument />
<div className={styles.rightWrap}>
<Space>
<DocumentShare documentId={document.id} />
<Tooltip key="edit" content="编辑" position="bottom">
<Button
type="tertiary"
theme="borderless"
icon={<IconEdit />}
onClick={gotoEdit}
/>
</Tooltip>
<DocumentStar documentId={document.id} />
</Space>
</div>
</header>
<main>
<div style={{ marginBottom: 12 }}>
<Text strong>{document.title}</Text>
</div>
<div>
<Text type="tertiary" size="small">
<Space>
<Avatar
size="extra-extra-small"
src={document.createUser && document.createUser.avatar}
>
<IconUser />
</Avatar>
{document.createUser && document.createUser.name}
</Space>
</Text>
</div>
</main>
<footer>
<Text type="tertiary" size="small">
<LocaleTime date={document.updatedAt} />
</Text>
</footer>
</a>
</Link>
</div>
);
};
export const DocumentCardPlaceholder = () => {
return (
<div className={styles.cardWrap}>
<header>
<IconDocument />
</header>
<main>
<div style={{ marginBottom: 12 }}>
<Skeleton.Title style={{ width: 160 }} />
</div>
<div>
<Text type="tertiary" size="small">
<Space>
<Avatar size="extra-extra-small">
<IconUser />
</Avatar>
<Skeleton.Paragraph rows={1} style={{ width: 100 }} />
</Space>
</Text>
</div>
</main>
<footer>
<Text type="tertiary" size="small">
<div style={{ display: "flex" }}>
<Skeleton.Paragraph rows={1} style={{ width: 100 }} />
</div>
</Text>
</footer>
</div>
);
};

View File

@ -0,0 +1,244 @@
import React, { useEffect, useState } from "react";
import {
Button,
Modal,
Spin,
Input,
Typography,
Tooltip,
Table,
Tabs,
TabPane,
Checkbox,
Toast,
Popconfirm,
AvatarGroup,
Avatar,
} from "@douyinfe/semi-ui";
import { IconUserAdd, IconDelete } from "@douyinfe/semi-icons";
import { useUser } from "data/user";
import { EventEmitter } from "helpers/event-emitter";
import { useToggle } from "hooks/useToggle";
import { useCollaborationDocument } from "data/document";
import { DataRender } from "components/data-render";
import { DocumentLinkCopyer } from "components/document/link";
interface IProps {
wikiId: string;
documentId: string;
}
const { Paragraph } = Typography;
const { Column } = Table;
const CollaborationEventEmitter = new EventEmitter();
const KEY = "JOIN_USER";
export const joinUser = (users) => {
CollaborationEventEmitter.emit(KEY, users);
};
const renderChecked =
(onChange, authKey: "readable" | "editable") => (checked, docAuth) => {
const handle = (evt) => {
const data = {
...docAuth.auth,
userName: docAuth.user.name,
};
data[authKey] = evt.target.checked;
onChange(data);
};
return (
<Checkbox
style={{ display: "inline-block" }}
checked={checked}
onChange={handle}
/>
);
};
export const DocumentCollaboration: React.FC<IProps> = ({
wikiId,
documentId,
}) => {
const { user: currentUser } = useUser();
const [visible, toggleVisible] = useToggle(false);
const { users, loading, error, addUser, updateUser, deleteUser } =
useCollaborationDocument(documentId);
const [inviteUser, setInviteUser] = useState("");
const [collaborationUsers, setCollaborationUsers] = useState([]);
const handleOk = () => {
addUser(inviteUser).then(() => {
Toast.success("添加成功");
setInviteUser("");
});
};
const handleDelete = (docAuth) => {
const data = {
...docAuth.auth,
userName: docAuth.user.name,
};
deleteUser(data);
};
useEffect(() => {
CollaborationEventEmitter.on(KEY, ({ states: users }) => {
const newCollaborationUsers = users
.filter(Boolean)
.map((state) => ({ ...state.user, clientId: state.clientId }))
.filter(Boolean);
if (
collaborationUsers.length === newCollaborationUsers.length &&
newCollaborationUsers.every((newUser) => {
return collaborationUsers.find(
(existUser) => existUser.id === newUser.id
);
})
) {
return;
}
newCollaborationUsers.forEach((newUser) => {
if (currentUser && newUser.name !== currentUser.name) {
Toast.info(`${newUser.name}加入文档`);
}
});
setCollaborationUsers(newCollaborationUsers);
});
return () => {
CollaborationEventEmitter.destroy();
};
}, [collaborationUsers, currentUser]);
if (error)
return (
<Tooltip content="邀请他人协作" position="bottom">
<Button
theme="borderless"
type="tertiary"
icon={<IconUserAdd />}
></Button>
</Tooltip>
);
return (
<>
<AvatarGroup maxCount={5} size="extra-small">
{collaborationUsers.map((user) => {
return (
<Tooltip
key={user.id}
content={`${user.name}-${user.clientId}`}
position="bottom"
>
<Avatar src={user.avatar} size="extra-small">
{user.name && user.name.charAt(0)}
</Avatar>
</Tooltip>
);
})}
</AvatarGroup>
<Tooltip content="邀请他人协作" position="bottom">
<Button
theme="borderless"
type="tertiary"
icon={<IconUserAdd />}
onClick={toggleVisible}
></Button>
</Tooltip>
<Modal
title={"文档协作"}
okText={"邀请对方"}
visible={visible}
onOk={handleOk}
onCancel={() => toggleVisible(false)}
maskClosable={false}
style={{ maxWidth: "96vw" }}
footer={null}
>
<Tabs type="line">
<TabPane tab="添加成员" itemKey="add">
<div style={{ marginTop: 16 }}>
<Input
placeholder="输入对方用户名"
value={inviteUser}
onChange={setInviteUser}
></Input>
<Paragraph style={{ marginTop: 16 }}>
<span style={{ verticalAlign: "middle" }}>
<DocumentLinkCopyer wikiId={wikiId} documentId={documentId} />
</span>
</Paragraph>
<Button
theme="solid"
block
style={{ margin: "24px 0" }}
disabled={!inviteUser}
onClick={handleOk}
>
</Button>
</div>
</TabPane>
<TabPane tab="协作成员" itemKey="list">
<DataRender
loading={loading}
error={error}
loadingContent={<Spin />}
normalContent={() => (
<Table
style={{ margin: "24px 0" }}
dataSource={users}
size="small"
pagination
>
<Column title="用户名" dataIndex="user.name" key="name" />
<Column
title="是否可读"
dataIndex="auth.readable"
key="readable"
render={renderChecked(updateUser, "readable")}
align="center"
/>
<Column
title="是否可编辑"
dataIndex="auth.editable"
key="editable"
render={renderChecked(updateUser, "editable")}
align="center"
/>
<Column
title="操作"
dataIndex="operate"
key="operate"
render={(_, document) => (
<Popconfirm
showArrow
title="确认删除该成员?"
onConfirm={() => handleDelete(document)}
>
<Button
type="tertiary"
theme="borderless"
icon={<IconDelete />}
/>
</Popconfirm>
)}
/>
</Table>
)}
/>
</TabPane>
</Tabs>
</Modal>
</>
);
};

View File

@ -0,0 +1,36 @@
.wrap {
display: flex;
padding: 9px 0px 9px 0;
+ .wrap {
margin-top: 16px;
}
.rightWrap {
flex: 1;
margin-left: 16px;
overflow: auto;
> main {
margin: 10px 0;
color: var(--semi-color-text-0);
}
> footer {
// height: 0;
// transition: all ease-in-out 0.3s;
span {
cursor: pointer;
}
}
}
// &:hover {
// .rightWrap {
// > footer {
// height: 40px;
// }
// }
// }
}

View File

@ -0,0 +1,105 @@
import React from "react";
import type { IComment, IUser } from "@think/share";
import {
Avatar,
Typography,
Space,
Popconfirm,
Skeleton,
} from "@douyinfe/semi-ui";
import { IconUser } from "@douyinfe/semi-icons";
import { LocaleTime } from "components/locale-time";
import { useUser } from "data/user";
import styles from "./index.module.scss";
interface IProps {
comment: IComment;
replyComment: (comment: IComment) => void;
editComment: (comment: IComment) => void;
deleteComment: (comment: IComment) => void;
}
const { Text } = Typography;
export const CommentItem: React.FC<IProps> = ({
comment,
replyComment,
editComment,
deleteComment,
}) => {
if (!comment) return null;
const { user } = useUser();
const { createUser = {} } = comment;
return (
<div className={styles.wrap}>
<div className={styles.leftWrap}>
<Avatar size="small" src={(createUser as IUser).avatar}>
<IconUser />
</Avatar>
</div>
<div className={styles.rightWrap}>
<header>
<Space>
<Text strong>{(createUser as IUser).name}</Text>
<Text type="tertiary">
<LocaleTime date={comment.createdAt} timeago />
</Text>
</Space>
</header>
<main className="ProseMirror">
<div dangerouslySetInnerHTML={{ __html: comment.html }}></div>
</main>
<footer>
<Space>
<Text
type="secondary"
size="small"
onClick={() => replyComment(comment)}
>
</Text>
{user && user.id === comment.createUserId && (
<Text
type="secondary"
size="small"
onClick={() => editComment(comment)}
>
</Text>
)}
<Popconfirm
showArrow
title="确认删除该评论?"
onConfirm={() => deleteComment(comment)}
>
<Text type="secondary" size="small">
</Text>
</Popconfirm>
</Space>
</footer>
</div>
</div>
);
};
export const CommentItemPlaceholder = () => {
return (
<div className={styles.wrap}>
<div className={styles.leftWrap}>
<Skeleton.Avatar size="small" />
</div>
<div className={styles.rightWrap}>
<header>
<Skeleton.Title style={{ width: 120 }} />
</header>
<main>
<div>
<Skeleton.Paragraph style={{ width: "100%" }} rows={3} />
</div>
</main>
</div>
</div>
);
};

View File

@ -0,0 +1,70 @@
import React from "react";
import type { IComment } from "@think/share";
import { CommentItem } from "./Item";
interface IProps {
comments: Array<IComment>;
replyComment: (comment: IComment) => void;
editComment: (comment: IComment) => void;
deleteComment: (comment: IComment) => void;
}
const PADDING_LEFT = 32;
const CommentInner = ({
data,
depth,
replyComment,
editComment,
deleteComment,
}) => {
return (
<div
key={"comment" + depth}
style={{ paddingLeft: depth > 0 ? PADDING_LEFT : 0 }}
>
{(data || []).map((item) => {
const hasChildren = item.children && item.children.length;
return (
<>
<CommentItem
key={item.id}
comment={item}
replyComment={replyComment}
editComment={editComment}
deleteComment={deleteComment}
></CommentItem>
{hasChildren ? (
<CommentInner
key={"comment-inner" + depth}
data={item.children}
depth={depth + 1}
replyComment={replyComment}
editComment={editComment}
deleteComment={deleteComment}
/>
) : null}
</>
);
})}
</div>
);
};
export const Comments: React.FC<IProps> = ({
comments,
replyComment,
editComment,
deleteComment,
}) => {
return (
<CommentInner
key={"root-menu"}
data={comments}
depth={0}
replyComment={replyComment}
editComment={editComment}
deleteComment={deleteComment}
/>
);
};

View File

@ -0,0 +1,43 @@
.commentsWrap {
padding: 16px 0;
border-bottom: 1px solid var(--semi-color-border);
.paginationWrap {
display: flex;
justify-content: center;
padding: 16px 0;
}
}
.editorOuterWrap {
padding-top: 24px;
display: flex;
.rightWrap {
flex: 1;
margin-left: 16px;
overflow: auto;
.placeholderWrap {
padding: 12px 16px;
border: 1px solid var(--semi-color-border);
border-radius: var(--semi-border-radius-small);
}
.editorWrap {
padding: 12px 16px;
border: 1px solid var(--semi-color-border);
border-radius: var(--semi-border-radius-small);
.innerWrap {
max-height: 240px;
padding: 16px 0;
overflow: auto;
}
}
.btnWrap {
margin-top: 16px;
}
}
}

View File

@ -0,0 +1,215 @@
import React, { useRef, useState } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import {
Avatar,
Button,
Space,
Typography,
Banner,
Pagination,
} from "@douyinfe/semi-ui";
import { useToggle } from "hooks/useToggle";
import { useClickOutside } from "hooks/use-click-outside";
import { DEFAULT_EXTENSION, Document, CommentMenuBar } from "components/tiptap";
import { DataRender } from "components/data-render";
import { useUser } from "data/user";
import { useComments } from "data/comment";
import { Comments } from "./comments";
import { CommentItemPlaceholder } from "./comments/Item";
import styles from "./index.module.scss";
interface IProps {
documentId: string;
}
const { Text, Paragraph } = Typography;
export const CommentEditor: React.FC<IProps> = ({ documentId }) => {
const { user } = useUser();
const {
data: commentsData,
loading,
error,
setPage,
addComment,
updateComment,
deleteComment,
} = useComments(documentId);
const [isEdit, toggleIsEdit] = useToggle(false);
const $container = useRef<HTMLDivElement>();
const [replyComment, setReplyComment] = useState(null);
const [editComment, setEditComment] = useState(null);
useClickOutside($container, {
out: () => isEdit && toggleIsEdit(false),
});
const editor = useEditor({
editable: true,
extensions: [...DEFAULT_EXTENSION, Document],
});
const openEditor = () => {
toggleIsEdit(true);
editor.chain().focus();
};
const handleClose = () => {
setReplyComment(null);
setEditComment(null);
toggleIsEdit(false);
};
const save = () => {
const html = editor.getHTML();
if (editComment) {
return updateComment({
id: editComment.id,
html,
}).then(() => {
editor.commands.clearNodes();
editor.commands.clearContent();
handleClose();
});
}
return addComment({
html,
parentCommentId: (replyComment && replyComment.id) || null,
replyUserId: (replyComment && replyComment.createUserId) || null,
}).then(() => {
editor.commands.clearNodes();
editor.commands.clearContent();
handleClose();
});
};
const handleReplyComment = (comment) => {
setReplyComment(comment);
setEditComment(null);
openEditor();
};
const handleEditComment = (comment) => {
setReplyComment(null);
setEditComment(comment);
openEditor();
};
return (
<div ref={$container}>
<DataRender
loading={loading}
error={error}
loadingContent={
<>
{Array.from({ length: 5 }, (_, i) => i).map((i) => (
<CommentItemPlaceholder key={i} />
))}
</>
}
normalContent={() => (
<>
{commentsData.total > 0 && (
<Space>
<Text strong></Text>
</Space>
)}
{commentsData.total > 0 && (
<div className={styles.commentsWrap}>
<Comments
comments={commentsData && commentsData.data}
replyComment={handleReplyComment}
editComment={handleEditComment}
deleteComment={deleteComment}
/>
<div className={styles.paginationWrap}>
<Pagination
total={commentsData.total}
showTotal
onPageChange={setPage}
></Pagination>
</div>
</div>
)}
</>
)}
/>
{replyComment && replyComment.createUser && (
<Banner
key={replyComment.id}
fullMode={false}
type="info"
icon={null}
title={<Text> {replyComment.createUser.name}</Text>}
description={
<Paragraph ellipsis={{ rows: 2 }}>
<div
dangerouslySetInnerHTML={{ __html: replyComment.html }}
></div>
</Paragraph>
}
onClose={handleClose}
/>
)}
{editComment && (
<Banner
key={editComment.id}
fullMode={false}
type="info"
icon={null}
title={<Text></Text>}
description={
<Paragraph ellipsis={{ rows: 2 }}>
<div dangerouslySetInnerHTML={{ __html: editComment.html }}></div>
</Paragraph>
}
onClose={handleClose}
/>
)}
<div className={styles.editorOuterWrap}>
<div className={styles.leftWrap}>
{user && (
<Avatar size="small" src={user.avatar}>
{user.name.charAt(0)}
</Avatar>
)}
</div>
<div className={styles.rightWrap}>
{isEdit ? (
<>
<div className={styles.editorWrap}>
<div style={{ width: "100%", overflow: "auto" }}>
<CommentMenuBar editor={editor} />
</div>
<div className={styles.innerWrap}>
<EditorContent autoFocus editor={editor} />
</div>
</div>
<div className={styles.btnWrap}>
<Space>
<Button theme="solid" type="primary" onClick={save}>
</Button>
<Button
theme="borderless"
type="tertiary"
onClick={handleClose}
>
</Button>
</Space>
</div>
</>
) : (
<div className={styles.placeholderWrap} onClick={openEditor}>
<Text type="tertiary"> ...</Text>
</div>
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,3 @@
.isActive {
border: 1px solid var(--semi-color-link);
}

View File

@ -0,0 +1,118 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import Router from "next/router";
import { Modal, Tabs, TabPane, Checkbox } from "@douyinfe/semi-ui";
import { useCreateDocument } from "data/document";
import { usePublicTemplates, useOwnTemplates } from "data/template";
import { TemplateList } from "components/template/list";
import { TemplateCardEmpty } from "components/template/card";
import styles from "./index.module.scss";
interface IProps {
wikiId: string;
parentDocumentId?: string;
visible: boolean;
toggleVisible: Dispatch<SetStateAction<boolean>>;
onCreate?: () => void;
}
export const DocumentCreator: React.FC<IProps> = ({
wikiId,
parentDocumentId,
visible,
toggleVisible,
onCreate,
}) => {
const { loading, create } = useCreateDocument();
const [createChildDoc, setCreateChildDoc] = useState(false);
const [templateId, setTemplateId] = useState("");
const handleOk = () => {
const data = {
wikiId,
parentDocumentId: createChildDoc ? parentDocumentId : null,
templateId,
};
create(data).then((res) => {
toggleVisible(false);
onCreate && onCreate();
setTemplateId("");
Router.push({
pathname: `/wiki/${wikiId}/document/${res.id}/edit`,
});
});
};
const handleCancel = useCallback(() => {
toggleVisible(false);
}, [toggleVisible]);
useEffect(() => {
setCreateChildDoc(!!parentDocumentId);
}, [parentDocumentId]);
return (
<Modal
title="模板库"
visible={visible}
onOk={handleOk}
onCancel={handleCancel}
okButtonProps={{ loading }}
style={{
maxWidth: "96vw",
width: "calc(100vh - 120px)",
}}
bodyStyle={{
maxHeight: "calc(90vh - 120px)",
overflow: "auto",
}}
key={wikiId}
>
<Tabs
type="button"
tabBarExtraContent={
parentDocumentId && (
<Checkbox
checked={createChildDoc}
onChange={(e) => setCreateChildDoc(e.target.checked)}
>
</Checkbox>
)
}
>
<TabPane tab="公开模板" itemKey="all">
<TemplateList
hook={usePublicTemplates}
onClick={(id) => setTemplateId(id)}
getClassNames={(id) => id === templateId && styles.isActive}
firstListItem={
<TemplateCardEmpty
getClassNames={() => !templateId && styles.isActive}
onClick={() => setTemplateId("")}
/>
}
/>
</TabPane>
<TabPane tab="我创建的" itemKey="own">
<TemplateList
hook={useOwnTemplates}
onClick={(id) => setTemplateId(id)}
getClassNames={(id) => id === templateId && styles.isActive}
firstListItem={
<TemplateCardEmpty
getClassNames={() => !templateId && styles.isActive}
onClick={() => setTemplateId("")}
/>
}
/>
</TabPane>
</Tabs>
</Modal>
);
};

View File

@ -0,0 +1,48 @@
import React, { useCallback } from "react";
import Router from "next/router";
import { Typography, Space, Modal } from "@douyinfe/semi-ui";
import { IconDelete } from "@douyinfe/semi-icons";
import { useDeleteDocument } from "data/document";
interface IProps {
wikiId: string;
documentId: string;
onDelete?: () => void;
}
const { Text } = Typography;
export const DocumentDeletor: React.FC<IProps> = ({
wikiId,
documentId,
onDelete,
}) => {
const { deleteDocument: api, loading } = useDeleteDocument(documentId);
const deleteAction = useCallback(() => {
Modal.error({
title: "确定删除吗?",
content: "文档删除后不可恢复!",
onOk: () => {
api().then(() => {
onDelete
? onDelete()
: Router.push({
pathname: `/wiki/${wikiId}`,
});
});
},
okButtonProps: { loading, type: "danger" },
style: { maxWidth: "96vw" },
});
}, [wikiId, documentId, api, loading, onDelete]);
return (
<Text type="danger" onClick={deleteAction}>
<Space>
<IconDelete />
</Space>
</Text>
);
};

View File

@ -0,0 +1,106 @@
import React, { useMemo, useEffect } from "react";
import cls from "classnames";
import { useEditor, EditorContent } from "@tiptap/react";
import { Layout, Nav, BackTop, Toast } from "@douyinfe/semi-ui";
import { IUser, IAuthority } from "@think/share";
import { useToggle } from "hooks/useToggle";
import {
DEFAULT_EXTENSION,
DocumentWithTitle,
getCollaborationExtension,
getCollaborationCursorExtension,
getProvider,
destoryProvider,
MenuBar,
Toc,
} from "components/tiptap";
import { DataRender } from "components/data-render";
import { joinUser } from "components/document/collaboration";
import styles from "./index.module.scss";
const { Header, Content } = Layout;
interface IProps {
user: IUser;
documentId: string;
authority: IAuthority;
className: string;
style: React.CSSProperties;
}
export const Editor: React.FC<IProps> = ({
user,
documentId,
authority,
className,
style,
}) => {
if (!user) return null;
const provider = useMemo(() => {
return getProvider({
targetId: documentId,
token: user.token,
cacheType: "EDITOR",
user,
docType: "document",
events: {
onAwarenessUpdate({ states }) {
joinUser({ states });
},
},
});
}, [documentId, user.token]);
const editor = useEditor({
editable: authority && authority.editable,
extensions: [
...DEFAULT_EXTENSION,
DocumentWithTitle,
getCollaborationExtension(provider),
getCollaborationCursorExtension(provider, user),
],
});
const [loading, toggleLoading] = useToggle(true);
useEffect(() => {
provider.on("synced", () => {
toggleLoading(false);
});
provider.on("status", async ({ status }) => {
console.log("status", status);
});
return () => {
destoryProvider(provider, "EDITOR");
};
}, []);
return (
<DataRender
loading={loading}
error={null}
normalContent={() => {
return (
<div className={styles.editorWrap}>
<header className={className}>
<div>
<MenuBar editor={editor} />
</div>
</header>
<main id="js-template-editor-container" style={style}>
<div className={cls(styles.contentWrap, className)}>
<EditorContent editor={editor} />
</div>
<BackTop
target={() =>
document.querySelector("#js-template-editor-container")
}
/>
</main>
</div>
);
}}
/>
);
};

View File

@ -0,0 +1,72 @@
.wrap {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
> header {
height: 60px;
> div {
overflow: auto;
}
}
> main {
height: calc(100% - 60px);
flex: 1;
overflow: hidden;
background-color: var(--semi-color-nav-bg);
}
}
.editorWrap {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
> header {
height: 50px;
padding: 0 24px;
display: flex;
align-items: center;
overflow: hidden;
border-bottom: 1px solid var(--semi-color-border);
&.isStandardWidth {
justify-content: center;
}
&.isFullWidth {
justify-content: flex-start;
}
> div {
display: inline-flex;
align-items: center;
height: 100%;
overflow: auto;
}
}
> main {
flex: 1;
height: calc(100% - 50px);
overflow: auto;
.contentWrap {
padding: 24px 24px 96px;
&.isStandardWidth {
width: 96%;
max-width: 750px;
margin: 0 auto;
}
&.isFullWidth {
width: 100%;
margin: 0 auto;
}
}
}
}

View File

@ -0,0 +1,153 @@
import Router from "next/router";
import React, { useCallback, useMemo } from "react";
import {
Layout,
Nav,
Skeleton,
Typography,
Space,
Button,
Tooltip,
Spin,
Popover,
} from "@douyinfe/semi-ui";
import { IconChevronLeft, IconArticle } from "@douyinfe/semi-icons";
import { useUser } from "data/user";
import { useDocumentDetail } from "data/document";
import { Seo } from "components/seo";
import { Theme } from "components/theme";
import { DataRender } from "components/data-render";
import { DocumentShare } from "components/document/share";
import { DocumentStar } from "components/document/star";
import { DocumentCollaboration } from "components/document/collaboration";
import { DocumentStyle } from "components/document/style";
import { useDocumentStyle } from "hooks/useDocumentStyle";
import { Editor } from "./editor";
import styles from "./index.module.scss";
const { Header, Content } = Layout;
const { Text } = Typography;
interface IProps {
documentId: string;
}
export const DocumentEditor: React.FC<IProps> = ({ documentId }) => {
if (!documentId) return null;
const { width, fontSize } = useDocumentStyle();
const editorWrapClassNames = useMemo(() => {
return width === "standardWidth"
? styles.isStandardWidth
: styles.isFullWidth;
}, [width]);
const { user } = useUser();
const {
data: documentAndAuth,
loading: docAuthLoading,
error: docAuthError,
} = useDocumentDetail(documentId);
const { document, authority } = documentAndAuth || {};
const goback = useCallback(() => {
Router.push({
pathname: `/wiki/${document.wikiId}/document/${documentId}`,
});
}, [document]);
const DocumentTitle = (
<>
<Tooltip content="返回" position="bottom">
<Button
onClick={goback}
icon={<IconChevronLeft />}
style={{ marginRight: 16 }}
/>
</Tooltip>
<DataRender
loading={docAuthLoading}
error={docAuthError}
loadingContent={
<Skeleton
active
placeholder={
<Skeleton.Title style={{ width: 80, marginBottom: 8 }} />
}
loading={true}
/>
}
normalContent={() => (
<Text ellipsis={{ showTooltip: true }} style={{ width: 120 }}>
{document.title}
</Text>
)}
/>
</>
);
return (
<div className={styles.wrap}>
<header>
<Nav
className={styles.headerOuterWrap}
mode="horizontal"
header={DocumentTitle}
footer={
<Space>
{document && authority.readable && (
<DocumentCollaboration
key="collaboration"
wikiId={document.wikiId}
documentId={documentId}
/>
)}
<DocumentShare key="share" documentId={documentId} />
<DocumentStar key="star" documentId={documentId} />
<Popover
key="style"
zIndex={1061}
position="bottomLeft"
content={<DocumentStyle />}
>
<Button
icon={<IconArticle />}
theme="borderless"
type="tertiary"
/>
</Popover>
<Theme />
</Space>
}
/>
</header>
<main className={styles.contentWrap}>
<DataRender
loading={docAuthLoading}
loadingContent={
<div style={{ margin: 24 }}>
<Spin></Spin>
</div>
}
error={null}
normalContent={() => {
return (
// <div style={{ fontSize }}>
<>
<Seo title={document.title} />
<Editor
key={document.id}
user={user}
documentId={document.id}
authority={authority}
className={editorWrapClassNames}
style={{ fontSize }}
/>
</>
);
}}
/>
</main>
</div>
);
};

View File

@ -0,0 +1,30 @@
import React, { useCallback } from "react";
import { Typography, Space } from "@douyinfe/semi-ui";
import { IconLink } from "@douyinfe/semi-icons";
import { copy } from "helpers/copy";
import { buildUrl } from "helpers/url";
interface IProps {
wikiId: string;
documentId: string;
}
const { Text } = Typography;
export const DocumentLinkCopyer: React.FC<IProps> = ({
wikiId,
documentId,
}) => {
const handle = useCallback(() => {
copy(buildUrl(`/wiki/${wikiId}/document/${documentId}`));
}, [wikiId, documentId]);
return (
<Text onClick={handle} style={{ cursor: "pointer" }}>
<Space>
<IconLink />
</Space>
</Text>
);
};

View File

@ -0,0 +1,32 @@
import React from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { Layout } from "@douyinfe/semi-ui";
import { IDocument } from "@think/share";
import { DEFAULT_EXTENSION, DocumentWithTitle } from "components/tiptap";
import { safeJSONParse } from "helpers/json";
interface IProps {
document: IDocument;
}
export const DocumentContent: React.FC<IProps> = ({ document }) => {
const c = safeJSONParse(document.content);
let json = c.default || c;
if (json && json.content) {
json = {
type: "doc",
content: json.content.slice(1),
};
}
const editor = useEditor({
editable: false,
extensions: [...DEFAULT_EXTENSION, DocumentWithTitle],
content: json,
});
if (!json) return null;
return <EditorContent editor={editor} />;
};

View File

@ -0,0 +1,78 @@
import React, { useMemo, useEffect } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { Layout } from "@douyinfe/semi-ui";
import { IUser } from "@think/share";
import { useToggle } from "hooks/useToggle";
import {
DEFAULT_EXTENSION,
DocumentWithTitle,
getCollaborationExtension,
getCollaborationCursorExtension,
getProvider,
destoryProvider,
} from "components/tiptap";
import { DataRender } from "components/data-render";
import { joinUser } from "components/document/collaboration";
import styles from "./index.module.scss";
const { Content } = Layout;
interface IProps {
user: IUser;
documentId: string;
}
export const Editor: React.FC<IProps> = ({ user, documentId }) => {
if (!user) return null;
const provider = useMemo(() => {
return getProvider({
targetId: documentId,
token: user.token,
cacheType: "READER",
user,
docType: "document",
events: {
onAwarenessUpdate({ states }) {
joinUser({ states });
},
},
});
}, [documentId, user.token]);
const editor = useEditor({
editable: false,
extensions: [
...DEFAULT_EXTENSION,
DocumentWithTitle,
getCollaborationExtension(provider),
getCollaborationCursorExtension(provider, user),
],
});
const [loading, toggleLoading] = useToggle(true);
useEffect(() => {
provider.on("synced", () => {
toggleLoading(false);
});
return () => {
destoryProvider(provider, "READER");
};
}, []);
return (
<DataRender
loading={loading}
error={null}
normalContent={() => {
return (
<>
<Content className={styles.editorWrap}>
<EditorContent editor={editor} />
</Content>
</>
);
}}
/>
);
};

View File

@ -0,0 +1,35 @@
.wrap {
margin-top: -16px;
height: 100%;
.headerWrap {
position: sticky;
}
.contentWrap {
height: 100%;
padding: 24px 0;
overflow: hidden;
.editorWrap {
padding-bottom: 24px;
overflow: auto;
&.isStandardWidth {
width: 96%;
max-width: 750px;
margin: 0 auto;
}
&.isFullWidth {
width: 100%;
margin: 0 auto;
}
}
.commentWrap {
padding: 16px 0;
border-top: 1px solid var(--semi-color-border);
}
}
}

View File

@ -0,0 +1,161 @@
import Router from "next/router";
import React, { useCallback, useMemo } from "react";
import cls from "classnames";
import {
Layout,
Nav,
Space,
Button,
Typography,
Skeleton,
Tooltip,
Popover,
BackTop,
} from "@douyinfe/semi-ui";
import { IconEdit, IconArticle } from "@douyinfe/semi-icons";
import { Seo } from "components/seo";
import { DataRender } from "components/data-render";
import { DocumentShare } from "components/document/share";
import { DocumentStar } from "components/document/star";
import { DocumentCollaboration } from "components/document/collaboration";
import { DocumentStyle } from "components/document/style";
import { CommentEditor } from "components/document/comments";
import { useDocumentStyle } from "hooks/useDocumentStyle";
import { useUser } from "data/user";
import { useDocumentDetail } from "data/document";
import { DocumentSkeleton } from "components/tiptap";
import { Editor } from "./editor";
import { CreateUser } from "./user";
import styles from "./index.module.scss";
const { Header } = Layout;
const { Text } = Typography;
interface IProps {
documentId: string;
}
export const DocumentReader: React.FC<IProps> = ({ documentId }) => {
if (!documentId) return null;
const { width, fontSize } = useDocumentStyle();
const editorWrapClassNames = useMemo(() => {
return width === "standardWidth"
? styles.isStandardWidth
: styles.isFullWidth;
}, [width]);
const { user } = useUser();
const {
data: documentAndAuth,
loading: docAuthLoading,
error: docAuthError,
} = useDocumentDetail(documentId);
const { document, authority } = documentAndAuth || {};
const gotoEdit = useCallback(() => {
Router.push(`/wiki/${document.wikiId}/document/${document.id}/edit`);
}, [document]);
return (
<div className={styles.wrap}>
<Header className={styles.headerWrap}>
<Nav
style={{ overflow: "auto", paddingLeft: 0, paddingRight: 0 }}
mode="horizontal"
header={
<DataRender
loading={docAuthLoading}
error={docAuthError}
loadingContent={
<Skeleton
active
placeholder={
<Skeleton.Title style={{ width: 80, marginBottom: 8 }} />
}
loading={true}
/>
}
normalContent={() => (
<Text
strong
ellipsis={{ showTooltip: true }}
style={{ width: 120 }}
>
{document.title}
</Text>
)}
/>
}
footer={
<Space>
{document && authority.readable && (
<DocumentCollaboration
key="collaboration"
wikiId={document.wikiId}
documentId={documentId}
/>
)}
{authority && authority.editable && (
<Tooltip key="edit" content="编辑" position="bottom">
<Button icon={<IconEdit />} onClick={gotoEdit} />
</Tooltip>
)}
{authority && authority.readable && (
<>
<DocumentShare key="share" documentId={documentId} />
<DocumentStar key="star" documentId={documentId} />
</>
)}
<Popover
key="style"
zIndex={1061}
position="bottomLeft"
content={<DocumentStyle />}
>
<Button
icon={<IconArticle />}
theme="borderless"
type="tertiary"
/>
</Popover>
</Space>
}
></Nav>
</Header>
<Layout className={styles.contentWrap}>
<div
className={cls(styles.editorWrap, editorWrapClassNames)}
style={{ fontSize }}
>
<DataRender
loading={docAuthLoading}
error={docAuthError}
loadingContent={<DocumentSkeleton />}
normalContent={() => {
return (
<>
<Seo title={document.title} />
<Editor
key={document.id}
user={user}
documentId={document.id}
/>
<div style={{ marginBottom: 24 }}>
<CreateUser document={document} />
</div>
<div className={styles.commentWrap}>
<CommentEditor documentId={document.id} />
</div>
<BackTop
target={() => window.document.querySelector(".Pane2")}
/>
</>
);
}}
/>
</div>
</Layout>
</div>
);
};

View File

@ -0,0 +1,33 @@
.wrap {
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--semi-color-nav-bg);
.headerWrap {
position: sticky;
}
.contentWrap {
padding: 24px 24px 48px;
flex: 1;
overflow: auto;
.editorWrap {
padding-bottom: 24px;
&.isStandardWidth {
width: 96%;
max-width: 750px;
margin: 0 auto;
overflow: auto;
}
&.isFullWidth {
width: 100%;
margin: 0 auto;
overflow: auto;
}
}
}
}

View File

@ -0,0 +1,178 @@
import React, { useMemo, useEffect } from "react";
import cls from "classnames";
import {
Layout,
Nav,
Space,
Button,
Typography,
Skeleton,
Input,
Popover,
Modal,
BackTop,
} from "@douyinfe/semi-ui";
import { IconArticle } from "@douyinfe/semi-icons";
import { Seo } from "components/seo";
import { LogoImage, LogoText } from "components/logo";
import { DataRender } from "components/data-render";
import { DocumentStyle } from "components/document/style";
import { User } from "components/user";
import { Theme } from "components/theme";
import { useDocumentStyle } from "hooks/useDocumentStyle";
import { usePublicDocument } from "data/document";
import { DocumentSkeleton } from "components/tiptap";
import { DocumentContent } from "../content";
import { CreateUser } from "../user";
import styles from "./index.module.scss";
const { Header, Content } = Layout;
const { Text, Title } = Typography;
interface IProps {
documentId: string;
hideLogo?: boolean;
}
export const DocumentPublicReader: React.FC<IProps> = ({
documentId,
hideLogo = true,
}) => {
if (!documentId) return null;
const { data, loading, error, query } = usePublicDocument(documentId);
const { width, fontSize } = useDocumentStyle();
const editorWrapClassNames = useMemo(() => {
return width === "standardWidth"
? styles.isStandardWidth
: styles.isFullWidth;
}, [width]);
useEffect(() => {
if (!error) return;
if (error.statusCode !== 400) return;
Modal.confirm({
title: "请输入密码",
content: (
<>
<Seo title={"输入密码后查看"} />
<Input
id="js-share-document-password"
style={{ marginTop: 24 }}
autofocus
mode="password"
placeholder="请输入密码"
/>
</>
),
closable: false,
hasCancel: false,
maskClosable: false,
onOk() {
const $input = document.querySelector(
"#js-share-document-password"
) as HTMLInputElement;
query($input.value);
},
});
}, [error, query]);
return (
<Layout className={styles.wrap}>
<Header className={styles.headerWrap}>
<Nav
mode="horizontal"
header={
!hideLogo ? (
<>
<LogoImage />
<LogoText />
</>
) : null
}
footer={
<Space>
<Popover
key="style"
zIndex={1061}
position="bottomLeft"
content={<DocumentStyle />}
>
<Button
icon={<IconArticle />}
theme="borderless"
type="tertiary"
/>
</Popover>
<Theme />
<User />
</Space>
}
>
<DataRender
loading={loading}
error={error}
loadingContent={
<Skeleton
active
placeholder={
<Skeleton.Title style={{ width: 80, marginBottom: 8 }} />
}
loading={true}
/>
}
normalContent={() => (
<Text
strong
ellipsis={{ showTooltip: true }}
style={{ width: 120 }}
>
{data.title}
</Text>
)}
/>
</Nav>
</Header>
<Content className={styles.contentWrap}>
<DataRender
loading={loading}
error={error}
loadingContent={
<div
className={cls(styles.editorWrap, editorWrapClassNames)}
style={{ fontSize }}
>
<DocumentSkeleton />
</div>
}
normalContent={() => {
return (
<>
<Seo title={data.title} />
<div
className={cls(styles.editorWrap, editorWrapClassNames)}
style={{ fontSize }}
id="js-share-document-editor-container"
>
<Title>{data.title}</Title>
<div style={{ margin: "24px 0" }}>
<CreateUser document={data} />
</div>
<DocumentContent document={data} />
</div>
<BackTop
target={() =>
document.querySelector(
"#js-share-document-editor-container"
).parentNode
}
/>
</>
);
}}
/>
</Content>
</Layout>
);
};

View File

@ -0,0 +1,35 @@
import { Space, Typography, Avatar } from "@douyinfe/semi-ui";
import { IconUser } from "@douyinfe/semi-icons";
import { IDocument } from "@think/share";
import { LocaleTime } from "components/locale-time";
const { Text } = Typography;
export const CreateUser: React.FC<{ document: IDocument }> = ({ document }) => {
if (!document.createUser) return null;
return (
<Text type="tertiary" size="small">
<Space>
<Avatar
size="extra-extra-small"
src={document.createUser && document.createUser.avatar}
>
<IconUser />
</Avatar>
<div>
<p>
{document.createUser && document.createUser.name}
</p>
<p>
<LocaleTime date={document.updatedAt} timeago />
{" ⦁ "}
{document.views}
</p>
</div>
</Space>
</Text>
);
};

View File

@ -0,0 +1,106 @@
import React, { useMemo, useState, useEffect } from "react";
import { Button, Modal, Input, Typography, Toast } from "@douyinfe/semi-ui";
import { IconLink } from "@douyinfe/semi-icons";
import { isPublicDocument, getDocumentShareURL } from "@think/share";
import { ShareIllustration } from "illustrations/share";
import { DataRender } from "components/data-render";
import { useToggle } from "hooks/useToggle";
import { useDocumentDetail } from "data/document";
interface IProps {
documentId: string;
render?: (arg: {
isPublic: boolean;
toggleVisible: (arg: boolean) => void;
}) => React.ReactNode;
}
const { Text } = Typography;
export const DocumentShare: React.FC<IProps> = ({ documentId, render }) => {
const [visible, toggleVisible] = useToggle(false);
const { data, loading, error, toggleStatus } = useDocumentDetail(documentId);
const [sharePassword, setSharePassword] = useState("");
const isPublic = useMemo(
() => data && isPublicDocument(data.document.status),
[data]
);
const shareUrl = useMemo(
() => data && getDocumentShareURL(data.document.id),
[data]
);
const handleOk = () => {
toggleStatus({ sharePassword: isPublic ? "" : sharePassword });
};
useEffect(() => {
if (loading || !data) return;
setSharePassword(data.document && data.document.sharePassword);
}, [loading, data]);
return (
<>
{render ? (
render({ isPublic, toggleVisible })
) : (
<Button type="primary" theme="light" onClick={toggleVisible}>
{isPublic ? "分享中" : "分享"}
</Button>
)}
<Modal
title={isPublic ? "关闭分享" : "开启分享"}
okText={isPublic ? "关闭分享" : "开启分享"}
visible={visible}
onOk={handleOk}
onCancel={() => toggleVisible(false)}
maskClosable={false}
style={{ maxWidth: "96vw" }}
>
<DataRender
loading={loading}
error={error}
normalContent={() => {
return (
<div>
<div style={{ textAlign: "center" }}>
<ShareIllustration />
</div>
{isPublic ? (
<Text
ellipsis
icon={<IconLink />}
copyable={{
onCopy: () => Toast.success({ content: "复制文本成功" }),
}}
style={{
width: 320,
}}
>
{shareUrl}
</Text>
) : (
<Input
autofocus
mode="password"
placeholder="设置访问密码"
value={sharePassword}
onChange={setSharePassword}
></Input>
)}
<div style={{ marginTop: 16 }}>
<Text type="tertiary">
{isPublic
? "分享开启后,该页面包含的所有内容均可访问,请谨慎开启"
: " 分享关闭后,其他人将不能继续访问该页面"}
</Text>
</div>
</div>
);
}}
/>
</Modal>
</>
);
};

View File

@ -0,0 +1,45 @@
import React from "react";
import { Typography, Tooltip, Button } from "@douyinfe/semi-ui";
import { IconStar } from "@douyinfe/semi-icons";
import { useDocumentStar } from "data/document";
interface IProps {
documentId: string;
render?: (arg: {
star: boolean;
text: string;
toggleStar: () => Promise<void>;
}) => React.ReactNode;
}
const { Text } = Typography;
export const DocumentStar: React.FC<IProps> = ({ documentId, render }) => {
const { data, toggleStar } = useDocumentStar(documentId);
const text = data ? "取消收藏" : "收藏文档";
return (
<>
{render ? (
render({ star: data, toggleStar, text })
) : (
<Tooltip content={text} position="bottom">
<Button
icon={<IconStar />}
theme="borderless"
style={{
color: data
? "rgba(var(--semi-amber-4), 1)"
: "rgba(var(--semi-grey-3), 1)",
}}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
toggleStar();
}}
/>
</Tooltip>
)}
</>
);
};

View File

@ -0,0 +1,17 @@
.wrap {
padding: 16px;
.item {
:global {
.semi-slider {
padding: 0 4px;
}
.semi-slider-handle {
width: 12px;
height: 12px;
margin-top: 10px;
}
}
}
}

View File

@ -0,0 +1,41 @@
import React from "react";
import { RadioGroup, Radio, Typography, Slider } from "@douyinfe/semi-ui";
import { useDocumentStyle } from "hooks/useDocumentStyle";
import styles from "./index.module.scss";
const { Text } = Typography;
export const DocumentStyle = () => {
const { width, fontSize, setWidth, setFontSize } = useDocumentStyle();
return (
<div className={styles.wrap}>
<div className={styles.item}>
<Text></Text>
<Text style={{ fontSize: "0.8em" }}> {fontSize}px</Text>
<Slider
min={12}
max={24}
step={1}
tooltipVisible={false}
value={fontSize}
onChange={setFontSize}
/>
</div>
<div className={styles.item}>
<Text></Text>
<div>
<RadioGroup
type="button"
value={width}
onChange={(e) => setWidth(e.target.value)}
style={{ marginTop: "0.5em" }}
>
<Radio value={"standardWidth"}></Radio>
<Radio value={"fullWidth"}></Radio>
</RadioGroup>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,29 @@
import React from "react";
import { Typography } from "@douyinfe/semi-ui";
interface IProps {
illustration?: React.ReactNode;
message: React.ReactNode;
}
const { Text } = Typography;
export const Empty: React.FC<IProps> = ({ illustration = null, message }) => {
return (
<div
style={{
display: "flex",
justifyContent: "center",
flexDirection: "column",
margin: "16px 0",
}}
>
{illustration && (
<main style={{ textAlign: "center" }}>{illustration}</main>
)}
<footer style={{ textAlign: "center" }}>
<Text type="tertiary">{message}</Text>
</footer>
</div>
);
};

View File

@ -0,0 +1,73 @@
import React from "react";
import { MouseEventHandler } from "react";
type CellProperties = {
active: boolean;
hover: boolean;
disabled: boolean;
cellSize: number;
onClick: MouseEventHandler<HTMLDivElement>;
onMouseEnter: MouseEventHandler<HTMLDivElement>;
styles: Record<string, React.CSSProperties>;
id: string;
};
const getBaseStyles = (cellSize) => ({
cell: {
width: cellSize,
height: cellSize,
background: "#fff",
cursor: "pointer",
borderRadius: 3,
border: "1px solid #bababa",
},
active: {
border: "1px solid #4d6cdd",
background: "#4d6cdd",
},
hover: {
border: "1px solid #fff",
background: "#4d6cdd",
},
disabled: {
filter: "brightness(0.7)",
},
});
const getMergedStyle = (baseStyles, styles, styleClass) => ({
...baseStyles[styleClass],
...(styles && styles[styleClass] ? styles[styleClass] : {}),
});
export const GridCell = ({
active,
hover,
disabled,
onClick,
onMouseEnter,
cellSize,
styles,
id,
}: CellProperties) => {
const baseStyles = getBaseStyles(cellSize);
const cellStyles = {
cell: getMergedStyle(baseStyles, styles, "cell"),
active: getMergedStyle(baseStyles, styles, "active"),
hover: getMergedStyle(baseStyles, styles, "hover"),
disabled: getMergedStyle(baseStyles, styles, "disabled"),
};
return (
<div
id={id}
style={{
...cellStyles.cell,
...(active && cellStyles.active),
...(hover && cellStyles.hover),
...(!active && disabled && cellStyles.disabled),
}}
onClick={onClick}
onMouseEnter={onMouseEnter}
/>
);
};

View File

@ -0,0 +1,143 @@
import React, { useMemo } from "react";
import { useState, useCallback } from "react";
import { Typography } from "@douyinfe/semi-ui";
import { debounce } from "helpers/debounce";
import { GridCell } from "./grid-cell";
const { Text } = Typography;
export type RegionSelectionProps = {
rows?: number;
cols?: number;
onSelect: (arg: { rows: number; cols: number }) => void;
cellSize?: number;
disabled?: boolean;
styles?: {
active?: React.CSSProperties;
hover?: React.CSSProperties;
cell?: React.CSSProperties;
grid?: React.CSSProperties;
disabled?: React.CSSProperties;
};
};
type CoordsType = {
x: number;
y: number;
};
const getBaseStyles = (cols, cellSize) => ({
grid: {
position: "relative",
display: "grid",
color: "#444",
margin: "8px 0",
gridGap: "4px 6px",
gridTemplateColumns: Array(cols).fill(`${cellSize}px`).join(" "),
},
});
export const GridSelect = ({
onSelect,
rows = 10,
cols = 10,
disabled = false,
cellSize = 16,
styles,
}: RegionSelectionProps) => {
const [activeCell, setActiveCell] = useState<CoordsType>({
x: -1,
y: -1,
});
const [hoverCell, setHoverCell] = useState<CoordsType>(null);
const onClick = useCallback(
({ x, y, isCellDisabled }) => {
// if (isCellDisabled) {
// return null;
// }
// if (activeCell.x === x && activeCell.y === y) {
// return null;
// }
// setActiveCell({ x, y });
onSelect({
rows: y + 1,
cols: x + 1,
});
},
[onSelect]
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const onHover = useCallback(
debounce(({ x, y, isCellDisabled }) => {
if (isCellDisabled) {
return setHoverCell(null);
}
setHoverCell({ x, y });
}, 5),
[disabled]
);
const cells = useMemo(() => {
const cells = [];
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const isActive = x <= activeCell.x && y <= activeCell.y;
const isHover = hoverCell && x <= hoverCell.x && y <= hoverCell.y;
const isCellDisabled = disabled;
cells.push(
<GridCell
id={x + "-" + y}
key={x + "-" + y}
onClick={() => onClick({ x, y, isCellDisabled })}
onMouseEnter={onHover.bind(null, { x, y, isCellDisabled })}
active={isActive}
hover={isHover}
disabled={isCellDisabled}
styles={styles}
cellSize={cellSize}
/>
);
}
}
return cells;
}, [
rows,
cols,
disabled,
activeCell.x,
activeCell.y,
cellSize,
hoverCell,
styles,
onClick,
onHover,
]);
const baseStyles = useMemo(
() => getBaseStyles(cols, cellSize),
[cols, cellSize]
);
return (
<div>
<div
style={
{
...baseStyles.grid,
...(styles && styles.grid ? styles.grid : {}),
} as React.CSSProperties
}
onMouseLeave={() => setHoverCell(null)}
>
{cells}
</div>
<footer style={{ textAlign: "center" }}>
<Text>
{hoverCell ? `${hoverCell.y + 1} x ${hoverCell.x + 1}` : null}
</Text>
</footer>
</div>
);
};

View File

@ -0,0 +1 @@
export * from "./grid-select";

View File

@ -0,0 +1,22 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconAddColumnAfter: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
>
<path d="M469.333333 85.333333C516.266667 85.333333 554.666667 123.733333 554.666667 170.666667L554.666667 853.333333C554.666667 900.266667 516.266667 938.666667 469.333333 938.666667L85.333333 938.666667 85.333333 85.333333 469.333333 85.333333M170.666667 426.666667 170.666667 597.333333 469.333333 597.333333 469.333333 426.666667 170.666667 426.666667M170.666667 682.666667 170.666667 853.333333 469.333333 853.333333 469.333333 682.666667 170.666667 682.666667M170.666667 170.666667 170.666667 341.333333 469.333333 341.333333 469.333333 170.666667 170.666667 170.666667M640 469.333333 768 469.333333 768 341.333333 853.333333 341.333333 853.333333 469.333333 981.333333 469.333333 981.333333 554.666667 853.333333 554.666667 853.333333 682.666667 768 682.666667 768 554.666667 640 554.666667 640 469.333333Z"></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,22 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconAddColumnBefore: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
>
<path d="M554.666667 85.333333C507.733333 85.333333 469.333333 123.733333 469.333333 170.666667L469.333333 853.333333C469.333333 900.266667 507.733333 938.666667 554.666667 938.666667L938.666667 938.666667 938.666667 85.333333 554.666667 85.333333M853.333333 426.666667 853.333333 597.333333 554.666667 597.333333 554.666667 426.666667 853.333333 426.666667M853.333333 682.666667 853.333333 853.333333 554.666667 853.333333 554.666667 682.666667 853.333333 682.666667M853.333333 170.666667 853.333333 341.333333 554.666667 341.333333 554.666667 170.666667 853.333333 170.666667M384 469.333333 256 469.333333 256 341.333333 170.666667 341.333333 170.666667 469.333333 42.666667 469.333333 42.666667 554.666667 170.666667 554.666667 170.666667 682.666667 256 682.666667 256 554.666667 384 554.666667 384 469.333333Z"></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,25 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconAddRowAfter: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
>
<path
d="M938.666667 426.666667a85.333333 85.333333 0 0 1-85.333334 85.333333H170.666667a85.333333 85.333333 0 0 1-85.333334-85.333333V128h85.333334v85.333333h170.666666V128h85.333334v85.333333h170.666666V128h85.333334v85.333333h170.666666V128h85.333334v298.666667M170.666667 426.666667h170.666666V298.666667H170.666667v128m256 0h170.666666V298.666667h-170.666666v128m426.666666 0V298.666667h-170.666666v128h170.666666m-384 170.666666h85.333334v128h128v85.333334h-128v128h-85.333334v-128H341.333333v-85.333334h128v-128z"
fill=""
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,25 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconAddRowBefore: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
>
<path
d="M938.666667 597.333333a85.333333 85.333333 0 0 0-85.333334-85.333333H170.666667a85.333333 85.333333 0 0 0-85.333334 85.333333v298.666667h85.333334v-85.333333h170.666666v85.333333h85.333334v-85.333333h170.666666v85.333333h85.333334v-85.333333h170.666666v85.333333h85.333334v-298.666667M170.666667 597.333333h170.666666v128H170.666667v-128m256 0h170.666666v128h-170.666666v-128m426.666666 0v128h-170.666666v-128h170.666666m-384-170.666666h85.333334V298.666667h128V213.333333h-128V85.333333h-85.333334v128H341.333333v85.333334h128v128z"
fill="currentColor"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,20 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconAttachment: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 256 256" role="presentation">
<path
d="M152.765 31.222c16.836-16.774 44.065-16.774 60.9 0 16.617 16.554 16.832 43.341.61 60.16l-.61.62-88.75 88.422c-10.261 10.223-26.858 10.223-37.119 0-10.139-10.101-10.27-26.446-.372-36.709l.372-.378 64.689-64.449c3.912-3.898 10.244-3.886 14.142.026 3.827 3.842 3.885 10.015.183 13.927l-.21.215-64.704 64.466a6.176 6.176 0 0 0 .016 8.734 6.296 6.296 0 0 0 8.7.178l.187-.178 88.81-88.483c8.927-8.959 8.9-23.457-.06-32.383-8.934-8.902-23.327-8.997-32.378-.284l-.29.284-99.113 98.747c-15.186 15.243-15.14 39.91.102 55.096 15.19 15.135 39.667 15.286 55.044.454l.463-.454 74.95-74.672c3.912-3.898 10.244-3.887 14.142.026 3.827 3.841 3.885 10.015.183 13.927l-.21.215-74.949 74.672c-23.149 23.064-60.59 23.064-83.74 0-22.836-22.752-23.132-59.567-.836-82.684l.837-.85 99.011-98.645Z"
fill="currentColor"
fillRule="nonzero"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,20 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconCenter: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="24" height="24" role="presentation">
<path
d="M6 17h12a1 1 0 010 2H6a1 1 0 010-2zm4-8h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4a1 1 0 011-1zM6 5h12a1 1 0 010 2H6a1 1 0 110-2z"
fill="currentColor"
fillRule="evenodd"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,25 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconClear: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg
viewBox="0 0 1170 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
>
<path
d="M1138.249143 551.936a109.494857 109.494857 0 0 0 0-154.843429l-364.982857-364.982857a109.494857 109.494857 0 0 0-154.916572 0L34.377143 616.009143a109.494857 109.494857 0 0 0 0 154.843428l218.989714 218.989715c20.48 20.553143 48.347429 32.109714 77.385143 32.109714h812.178286a27.355429 27.355429 0 0 0 27.355428-27.428571v-91.209143a27.355429 27.355429 0 0 0-27.355428-27.355429H814.08l324.022857-324.096z m-690.468572-142.848l313.417143 313.344-153.6 153.6H345.965714L163.401143 693.394286l284.452571-284.379429z"
fill="currentColor"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,23 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconCodeBlock: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 256 256" role="presentation">
<g fill="currentColor" fill-rule="evenodd">
<path
d="M64 40c5.523 0 10 4.477 10 10 0 5.43-4.327 9.848-9.72 9.996L64 60H54a6 6 0 0 0-5.996 5.775L48 66v42.5a10 10 0 0 1-1.667 5.529l-.21.303L36.31 128l9.813 13.668a10 10 0 0 1 1.87 5.463l.007.369V190a6 6 0 0 0 5.775 5.996L54 196h10c5.523 0 10 4.477 10 10 0 5.43-4.327 9.848-9.72 9.996L64 216H54c-14.216 0-25.767-11.409-25.997-25.57L28 190v-39.281l-12.123-16.887a10 10 0 0 1-.179-11.407l.179-.257L28 105.28V66c0-14.216 11.409-25.767 25.57-25.997L54 40h10ZM192 40c-5.523 0-10 4.477-10 10 0 5.43 4.327 9.848 9.72 9.996L192 60h10a6 6 0 0 1 5.996 5.775L208 66v42.5a10 10 0 0 0 1.667 5.529l.21.303L219.69 128l-9.813 13.668a10 10 0 0 0-1.87 5.463l-.007.369V190a6 6 0 0 1-5.775 5.996L202 196h-10c-5.523 0-10 4.477-10 10 0 5.43 4.327 9.848 9.72 9.996l.28.004h10c14.216 0 25.767-11.409 25.997-25.57L228 190v-39.281l12.123-16.887a10 10 0 0 0 .179-11.407l-.179-.257L228 105.28V66c0-14.216-11.409-25.767-25.57-25.997L202 40h-10Z"
fillRule="nonzero"
></path>
<rect x="72" y="78" width="112" height="20" rx="10"></rect>
<path d="M82 118h36c5.523 0 10 4.477 10 10s-4.477 10-10 10H82c-5.523 0-10-4.477-10-10s4.477-10 10-10ZM82 158h92c5.523 0 10 4.477 10 10s-4.477 10-10 10H82c-5.523 0-10-4.477-10-10s4.477-10 10-10Z"></path>
</g>
</svg>
}
/>
);
};

View File

@ -0,0 +1,22 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconDeleteColumn: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
>
<path d="M170.666667 85.333333 469.333333 85.333333C516.266667 85.333333 554.666667 123.733333 554.666667 170.666667L554.666667 853.333333C554.666667 900.266667 516.266667 938.666667 469.333333 938.666667L170.666667 938.666667C123.733333 938.666667 85.333333 900.266667 85.333333 853.333333L85.333333 170.666667C85.333333 123.733333 123.733333 85.333333 170.666667 85.333333M170.666667 426.666667 170.666667 597.333333 469.333333 597.333333 469.333333 426.666667 170.666667 426.666667M170.666667 682.666667 170.666667 853.333333 469.333333 853.333333 469.333333 682.666667 170.666667 682.666667M170.666667 170.666667 170.666667 341.333333 469.333333 341.333333 469.333333 170.666667 170.666667 170.666667M750.506667 512 640 401.493333 700.16 341.333333 810.666667 451.84 921.173333 341.333333 981.333333 401.493333 870.826667 512 981.333333 622.506667 921.173333 682.666667 810.666667 572.16 700.16 682.666667 640 622.506667 750.506667 512Z"></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,22 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconDeleteRow: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
>
<path d="M401.493333 554.666667 512 665.173333 622.506667 554.666667 682.666667 614.826667 572.16 725.333333 682.666667 835.84 622.506667 896 512 785.493333 401.493333 896 341.333333 835.84 451.84 725.333333 341.333333 614.826667 401.493333 554.666667M938.666667 384C938.666667 430.933333 900.266667 469.333333 853.333333 469.333333L170.666667 469.333333C123.733333 469.333333 85.333333 430.933333 85.333333 384L85.333333 256C85.333333 209.066667 123.733333 170.666667 170.666667 170.666667L853.333333 170.666667C900.266667 170.666667 938.666667 209.066667 938.666667 256L938.666667 384M170.666667 384 341.333333 384 341.333333 256 170.666667 256 170.666667 384M426.666667 384 597.333333 384 597.333333 256 426.666667 256 426.666667 384M682.666667 384 853.333333 384 853.333333 256 682.666667 256 682.666667 384Z"></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,22 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconDeleteTable: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
>
<path d="M659.797 677.504l60.374-60.373 90.496 90.581 90.496-90.496 60.373 60.33L870.955 768l90.496 90.496-60.331 60.373-90.453-90.581-90.496 90.496-60.374-60.33L750.38 768l-90.496-90.496zM170.667 128H768a85.333 85.333 0 0 1 85.333 85.333v302.251a255.275 255.275 0 0 0-184.192 39.083H512v170.666h46.25a254.72 254.72 0 0 0-0.085 85.334H170.667a85.333 85.333 0 0 1-85.334-85.334v-512A85.333 85.333 0 0 1 170.667 128z m0 170.667v170.666h256V298.667h-256z m341.333 0v170.666h256V298.667H512z m-341.333 256v170.666h256V554.667h-256z"></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,22 @@
import React from "react";
import { Icon } from "@douyinfe/semi-ui";
export const IconDocument: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg height="16" width="16" viewBox="0 0 24 24" focusable="false">
<g fill="currentColor" fillRule="evenodd" clipRule="evenodd">
<path
transform="translate(2 2)"
d="M3.5 0C1.84315 0 0.5 1.34315 0.5 3V17C0.5 18.6569 1.84315 20 3.5 20H17.5C19.1569 20 20.5 18.6569 20.5 17V3C20.5 1.34315 19.1569 0 17.5 0H3.5ZM2.5 3C2.5 2.44772 2.94772 2 3.5 2H17.5C18.0523 2 18.5 2.44772 18.5 3V17C18.5 17.5523 18.0523 18 17.5 18H3.5C2.94772 18 2.5 17.5523 2.5 17V3ZM5.5 5C4.94772 5 4.5 5.44772 4.5 6C4.5 6.55228 4.94771 7 5.5 7H15.5C16.0523 7 16.5 6.55228 16.5 6C16.5 5.44772 16.0523 5 15.5 5H5.5ZM4.5 10C4.5 9.44771 4.94772 9 5.5 9H15.5C16.0523 9 16.5 9.44771 16.5 10C16.5 10.5523 16.0523 11 15.5 11H5.5C4.94771 11 4.5 10.5523 4.5 10ZM5.5 13C4.94772 13 4.5 13.4477 4.5 14C4.5 14.5523 4.94772 15 5.5 15H11.5C12.0523 15 12.5 14.5523 12.5 14C12.5 13.4477 12.0523 13 11.5 13H5.5Z"
></path>
</g>
</svg>
}
/>
);
};

View File

@ -0,0 +1,21 @@
import React from "react";
import { Icon } from "@douyinfe/semi-ui";
export const IconDocumentFill: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 24 24" role="presentation">
<path
fill="currentColor"
fillRule="evenodd"
d="M3 0h18a3 3 0 013 3v18a3 3 0 01-3 3H3a3 3 0 01-3-3V3a3 3 0 013-3zm1 18c0 .556.446 1 .995 1h8.01c.54 0 .995-.448.995-1 0-.556-.446-1-.995-1h-8.01c-.54 0-.995.448-.995 1zm0-4c0 .556.448 1 1 1h14c.555 0 1-.448 1-1 0-.556-.448-1-1-1H5c-.555 0-1 .448-1 1zm0-4c0 .556.448 1 1 1h14c.555 0 1-.448 1-1 0-.556-.448-1-1-1H5c-.555 0-1 .448-1 1zm0-4c0 .556.448 1 1 1h14c.555 0 1-.448 1-1 0-.556-.448-1-1-1H5c-.555 0-1 .448-1 1z"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,20 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconEmoji: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="24" height="24" viewBox="0 0 24 24" role="presentation">
<path
d="M12 5a7 7 0 110 14 7 7 0 010-14zm0 12.5c3.033 0 5.5-2.467 5.5-5.5S15.033 6.5 12 6.5A5.506 5.506 0 006.5 12c0 3.033 2.467 5.5 5.5 5.5zm-1.5-6a1 1 0 110-2 1 1 0 010 2zm3 0a1 1 0 110-2 1 1 0 010 2zm.27 1.583a.626.626 0 01.932.834A3.63 3.63 0 0112 15.125a3.63 3.63 0 01-2.698-1.204.625.625 0 01.93-.835c.901 1.003 2.639 1.003 3.538-.003z"
fill="currentColor"
fillRule="evenodd"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,25 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconFont: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg
role="presentation"
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 12.5h-4l-.874 2.186A.5.5 0 0 1 8.66 15H7.273a.5.5 0 0 1-.456-.705l4.05-9A.5.5 0 0 1 11.323 5h1.354a.5.5 0 0 1 .456.295l4.05 9a.5.5 0 0 1-.456.705h-1.388a.5.5 0 0 1-.465-.314L14 12.5zm-.6-1.5L12 7.5 10.6 11h2.8z"
fill="currentColor"
fillRule="evenodd"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,19 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconFull: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="24" height="24" viewBox="0 0 24 24" role="presentation">
<path
d="M18.062 11L16.5 9.914A1 1 0 1117.914 8.5l2.616 2.616c.28.167.47.5.47.884s-.19.717-.47.884L17.914 15.5a1 1 0 01-1.414-1.414L18.062 13h-3.68c-.487 0-.882-.448-.882-1s.395-1 .882-1h3.68zM3.47 12.884c-.28-.167-.47-.5-.47-.884s.19-.717.47-.884L6.086 8.5A1 1 0 017.5 9.914L5.938 11h3.68c.487 0 .882.448.882 1s-.395 1-.882 1h-3.68L7.5 14.086A1 1 0 016.086 15.5L3.47 12.884z"
fill="currentColor"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,19 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconHalf: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="24" height="24" viewBox="0 0 24 24" role="presentation">
<path
d="M8.062 11L6.5 9.914A1 1 0 017.914 8.5l2.616 2.616c.28.167.47.5.47.884s-.19.717-.47.884L7.914 15.5A1 1 0 116.5 14.086L8.062 13h-3.68c-.487 0-.882-.448-.882-1s.395-1 .882-1h3.68zm5.408 1.884c-.28-.167-.47-.5-.47-.884s.19-.717.47-.884L16.086 8.5A1 1 0 0117.5 9.914L15.938 11h3.68c.487 0 .882.448.882 1s-.395 1-.882 1h-3.68l1.562 1.086a1 1 0 01-1.414 1.414l-2.616-2.616z"
fill="currentColor"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,33 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconImage: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 256 256" role="presentation">
<g fill="none" fillRule="evenodd">
<path
d="M197 27c17.673 0 32 14.327 32 32v138c0 17.673-14.327 32-32 32H59c-17.673 0-32-14.327-32-32V59c0-17.673 14.327-32 32-32h138Zm0 20H59c-6.525 0-11.834 5.209-11.996 11.695L47 59v138c0 6.525 5.209 11.834 11.695 11.996L59 209h138c6.525 0 11.834-5.209 11.996-11.695L209 197V59c0-6.525-5.209-11.834-11.695-11.996L197 47Z"
fill="currentColor"
fillRule="nonzero"
></path>
<g fill="currentColor">
<path
d="M64.982 134.434c5.322-7.419 15.65-9.118 23.07-3.796a16.532 16.532 0 0 1 3.504 3.401l.292.395 23.387 32.6c3.22 4.488 2.191 10.736-2.296 13.955-4.408 3.162-10.513 2.226-13.78-2.06l-.175-.236-20.569-28.672-32.29 45.01c-3.161 4.407-9.244 5.477-13.712 2.464l-.242-.168c-4.407-3.162-5.478-9.245-2.465-13.713l.169-.242 35.107-48.938Z"
fillRule="nonzero"
></path>
<path
d="M143.577 105.01c5.33-7.412 15.66-9.101 23.074-3.771a16.532 16.532 0 0 1 3.48 3.38l.291.391 56.34 78.353c3.224 4.484 2.203 10.733-2.281 13.957-4.404 3.167-10.51 2.238-13.782-2.044l-.175-.237L157 120.602l-41.771 58.1c-3.167 4.403-9.25 5.467-13.715 2.45l-.242-.17c-4.404-3.166-5.468-9.25-2.45-13.714l.168-.242 44.587-62.015Z"
fillRule="nonzero"
></path>
<path d="M99.825 104c7.732 0 14-6.268 14-14s-6.268-14-14-14-14 6.268-14 14 6.268 14 14 14Z"></path>
</g>
</g>
</svg>
}
/>
);
};

View File

@ -0,0 +1,22 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconInfo: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 256 256" role="presentation">
<g fill="none" fillRule="evenodd">
<path
d="M197 27c17.673 0 32 14.327 32 32v138c0 17.673-14.327 32-32 32H59c-17.673 0-32-14.327-32-32V59c0-17.673 14.327-32 32-32h138Zm0 20H59c-6.525 0-11.834 5.209-11.996 11.695L47 59v138c0 6.525 5.209 11.834 11.695 11.996L59 209h138c6.525 0 11.834-5.209 11.996-11.695L209 197V59c0-6.525-5.209-11.834-11.695-11.996L197 47Z"
fill="currentColor"
fillRule="nonzero"
></path>
</g>
</svg>
}
/>
);
};

View File

@ -0,0 +1,20 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconLeft: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="24" height="24" role="presentation">
<path
d="M6 17h12a1 1 0 010 2H6a1 1 0 010-2zm0-8h4a1 1 0 011 1v4a1 1 0 01-1 1H6a1 1 0 01-1-1v-4a1 1 0 011-1zm0-4h12a1 1 0 010 2H6a1 1 0 110-2z"
fill="currentColor"
fillRule="evenodd"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,20 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconLink: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 256 256" role="presentation">
<path
d="M155.639 173.9c3.839 3.84 3.904 10.023.195 13.942l-.195.2-30.783 30.783c-24.052 24.052-63.167 23.884-87.366-.315-23.957-23.957-24.362-62.533-1.03-86.64l.715-.726 30.783-30.783c3.905-3.905 10.237-3.905 14.142 0 3.839 3.84 3.904 10.023.195 13.942l-.195.2-30.783 30.783c-16.214 16.214-16.1 42.666.315 59.082 16.252 16.251 42.34 16.525 58.592.797l.49-.482 30.783-30.783c3.905-3.905 10.237-3.905 14.142 0Zm17.366-90.905c3.839 3.84 3.904 10.023.195 13.942l-.195.2-76.368 76.368c-3.905 3.905-10.237 3.905-14.142 0-3.839-3.84-3.904-10.023-.195-13.942l.195-.2 76.368-76.368c3.905-3.905 10.237-3.905 14.142 0ZM218.51 37.49c23.957 23.957 24.362 62.533 1.03 86.64l-.715.726-30.783 30.783c-3.905 3.905-10.237 3.905-14.142 0-3.839-3.84-3.904-10.023-.195-13.942l.195-.2 30.783-30.783c16.214-16.214 16.1-42.666-.315-59.082-16.252-16.251-42.34-16.525-58.592-.797l-.49.482L114.503 82.1c-3.905 3.905-10.237 3.905-14.142 0-3.839-3.84-3.904-10.023-.195-13.942l.195-.2 30.783-30.783c24.052-24.052 63.167-23.884 87.366.315Z"
fill="currentColor"
fillRule="nonzero"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,20 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconMath: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 256 256" role="presentation">
<path
d="M186 26.5c14.49 0 26.262 11.628 26.496 26.062l.004.438v23.801c0 5.8-4.701 10.5-10.5 10.5-5.704 0-10.346-4.548-10.496-10.215l-.004-.285V53a5.5 5.5 0 0 0-5.279-5.496L186 47.5H72.799a5.5 5.5 0 0 0-3.681 9.586l.197.17 61.362 50.24c11.325 9.271 12.989 25.967 3.717 37.292a26.5 26.5 0 0 1-3.212 3.293l-.505.423-61.362 50.24a5.5 5.5 0 0 0 3.224 9.75l.26.006H182a9.5 9.5 0 0 0 9.496-9.23l.004-.27v-20c0-5.799 4.701-10.5 10.5-10.5 5.704 0 10.346 4.548 10.496 10.216l.004.284v20c0 16.676-13.384 30.227-29.996 30.496l-.504.004H72.799a26.5 26.5 0 0 1-20.504-9.712c-9.18-11.211-7.64-27.687 3.38-37.012l.336-.28 61.363-50.24a5.5 5.5 0 0 0 .186-8.352l-.186-.16-61.363-50.24A26.5 26.5 0 0 1 46.298 53c0-14.49 11.63-26.262 26.063-26.496l.438-.004H186Z"
fill="currentColor"
fillRule="nonzero"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,22 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconMergeCell: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
>
<path d="M213.333333 426.666667H128V170.666667h341.333333v85.333333H213.333333v170.666667m597.333334 341.333333h-256v85.333333h341.333333v-256h-85.333333v170.666667M213.333333 768v-170.666667H128v256h341.333333v-85.333333H213.333333M896 170.666667h-341.333333v85.333333h256v170.666667h85.333333V170.666667M341.333333 554.666667v85.333333l128-128-128-128v85.333333H128v85.333334h213.333333m341.333334-85.333334V384l-128 128 128 128v-85.333333h213.333333v-85.333334h-213.333333z"></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,21 @@
import React from "react";
import { Icon } from "@douyinfe/semi-ui";
export const IconMessage: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="24" height="24" viewBox="0 0 24 24" role="presentation">
<path
d="M6.485 17.669a2 2 0 002.829 0l-2.829-2.83a2 2 0 000 2.83zm4.897-12.191l-.725.725c-.782.782-2.21 1.813-3.206 2.311l-3.017 1.509c-.495.248-.584.774-.187 1.171l8.556 8.556c.398.396.922.313 1.171-.188l1.51-3.016c.494-.988 1.526-2.42 2.311-3.206l.725-.726a5.048 5.048 0 00.64-6.356 1.01 1.01 0 10-1.354-1.494c-.023.025-.046.049-.066.075a5.043 5.043 0 00-2.788-.84 5.036 5.036 0 00-3.57 1.478z"
fill="currentColor"
fillRule="evenodd"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,20 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconMind: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 256 256" role="presentation">
<g fill="currentColor" fill-rule="nonzero">
<path d="M214 35h-46c-14.36 0-26 11.64-26 26v10c0 14.36 11.64 26 26 26h46c14.36 0 26-11.64 26-26V61c0-14.36-11.64-26-26-26Zm-46 20h46a6 6 0 0 1 6 6v10a6 6 0 0 1-6 6h-46a6 6 0 0 1-6-6V61a6 6 0 0 1 6-6ZM214 159h-46c-14.36 0-26 11.64-26 26v10c0 14.36 11.64 26 26 26h46c14.36 0 26-11.64 26-26v-10c0-14.36-11.64-26-26-26Zm-46 20h46a6 6 0 0 1 6 6v10a6 6 0 0 1-6 6h-46a6 6 0 0 1-6-6v-10a6 6 0 0 1 6-6Z"></path>
<path d="M73.55 147.305c7.858 20.517 27.486 34.415 49.774 34.69L124 182h26.207v16H124c-28.957 0-54.586-17.747-65.078-44.17l-.313-.803 14.942-5.722ZM158 58v16h-34c-22.645 0-42.63 14.068-50.511 34.857l-.235.632-15.03-5.484c9.897-27.128 35.606-45.632 64.888-46L124 58h34Z"></path>
<path d="M88 97H42c-14.36 0-26 11.64-26 26v10c0 14.36 11.64 26 26 26h46c14.36 0 26-11.64 26-26v-10c0-14.36-11.64-26-26-26Zm-46 20h46a6 6 0 0 1 6 6v10a6 6 0 0 1-6 6H42a6 6 0 0 1-6-6v-10a6 6 0 0 1 6-6Z"></path>
</g>
</svg>
}
/>
);
};

View File

@ -0,0 +1,21 @@
import React from "react";
import { Icon } from "@douyinfe/semi-ui";
export const IconOverview: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="24" height="24" viewBox="0 0 24 24" role="presentation">
<path
d="M8.01 18c.546 0 .99-.444.99-1a1 1 0 00-.99-1H3.99A.993.993 0 003 17a1 1 0 00.99 1h4.02zM3 7c0 .552.445 1 .993 1h16.014A.994.994 0 0021 7c0-.552-.445-1-.993-1H3.993A.994.994 0 003 7zm10.998 6A.999.999 0 0015 12c0-.552-.456-1-1.002-1H4.002A.999.999 0 003 12c0 .552.456 1 1.002 1h9.996z"
fill="currentColor"
fillRule="evenodd"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,20 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconRight: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="24" height="24" role="presentation">
<path
d="M6 17h12a1 1 0 010 2H6a1 1 0 010-2zm8-8h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4a1 1 0 011-1zM6 5h12a1 1 0 010 2H6a1 1 0 110-2z"
fill="currentColor"
fillRule="evenodd"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,21 @@
import React from "react";
import { Icon } from "@douyinfe/semi-ui";
export const IconSetting: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="24" height="24" viewBox="0 0 24 24">
<path
d="M11.701 16.7a5.002 5.002 0 110-10.003 5.002 5.002 0 010 10.004m8.368-3.117a1.995 1.995 0 01-1.346-1.885c0-.876.563-1.613 1.345-1.885a.48.48 0 00.315-.574 8.947 8.947 0 00-.836-1.993.477.477 0 00-.598-.195 2.04 2.04 0 01-1.29.08 1.988 1.988 0 01-1.404-1.395 2.04 2.04 0 01.076-1.297.478.478 0 00-.196-.597 8.98 8.98 0 00-1.975-.826.479.479 0 00-.574.314 1.995 1.995 0 01-1.885 1.346 1.994 1.994 0 01-1.884-1.345.482.482 0 00-.575-.315c-.708.2-1.379.485-2.004.842a.47.47 0 00-.198.582A2.002 2.002 0 014.445 7.06a.478.478 0 00-.595.196 8.946 8.946 0 00-.833 1.994.48.48 0 00.308.572 1.995 1.995 0 011.323 1.877c0 .867-.552 1.599-1.324 1.877a.479.479 0 00-.308.57 8.99 8.99 0 00.723 1.79.477.477 0 00.624.194c.595-.273 1.343-.264 2.104.238.117.077.225.185.302.3.527.8.512 1.58.198 2.188a.473.473 0 00.168.628 8.946 8.946 0 002.11.897.474.474 0 00.57-.313 1.995 1.995 0 011.886-1.353c.878 0 1.618.567 1.887 1.353a.475.475 0 00.57.313 8.964 8.964 0 002.084-.883.473.473 0 00.167-.631c-.318-.608-.337-1.393.191-2.195.077-.116.185-.225.302-.302.772-.511 1.527-.513 2.125-.23a.477.477 0 00.628-.19 8.925 8.925 0 00.728-1.793.478.478 0 00-.314-.573"
fill="currentColor"
fillRule="evenodd"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,24 @@
import React from "react";
import { Icon } from "@douyinfe/semi-ui";
export const IconShare: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 24 24" role="presentation">
<g fill="currentColor" fillRule="evenodd">
<path
d="M6 15a3 3 0 100-6 3 3 0 000 6zm0-2a1 1 0 110-2 1 1 0 010 2zm12-4a3 3 0 100-6 3 3 0 000 6zm0-2a1 1 0 110-2 1 1 0 010 2zm0 14a3 3 0 100-6 3 3 0 000 6zm0-2a1 1 0 110-2 1 1 0 010 2z"
fillRule="nonzero"
></path>
<path d="M7 13.562l8.66 5 1-1.732-8.66-5z"></path>
<path d="M7 10.83l1 1.732 8.66-5-1-1.732z"></path>
</g>
</svg>
}
/>
);
};

View File

@ -0,0 +1,22 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconSplitCell: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
>
<path d="M810.016 598.016h86.016v256h-768v-256h86.016v170.016h596v-170.016zM128 170.016v256h86.016V256h596v170.016h86.016v-256h-768z m342.016 300v84h-128v86.016l-128-128 128-128v86.016h128z m212 0V384l128 128-128 128v-86.016h-128v-84h128z"></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,26 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconStatus: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 256 256" role="presentation">
<g
transform="rotate(-45 100.071 47.645)"
fill="currentColor"
fill-rule="evenodd"
>
<path
d="m44.625 4.22-47 46.951A26 26 0 0 0-10 69.566V190c0 14.36 11.64 26 26 26h94c14.36 0 26-11.64 26-26V69.566a26 26 0 0 0-7.625-18.395l-47-46.95c-10.151-10.14-26.599-10.14-36.75 0ZM67.24 18.37l47 46.95a6 6 0 0 1 1.76 4.246V190a6 6 0 0 1-6 6H16a6 6 0 0 1-6-6V69.566a6 6 0 0 1 1.76-4.245l47-46.95a6 6 0 0 1 8.48 0Z"
fill-rule="nonzero"
></path>
<circle cx="63.172" cy="67.586" r="14"></circle>
</g>
</svg>
}
/>
);
};

View File

@ -0,0 +1,23 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconTable: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 256 256" role="presentation">
<g fill="currentColor" fill-rule="evenodd">
<path
d="M208 40c17.673 0 32 14.327 32 32v112c0 17.673-14.327 32-32 32H48c-17.673 0-32-14.327-32-32V72c0-17.673 14.327-32 32-32h160Zm0 20H48c-6.525 0-11.834 5.209-11.996 11.695L36 72v112c0 6.525 5.209 11.834 11.695 11.996L48 196h160c6.525 0 11.834-5.209 11.996-11.695L220 184V72c0-6.525-5.209-11.834-11.695-11.996L208 60Z"
fillRule="nonzero"
></path>
<path d="M28 93h204v20H28zM28 143h204v20H28z"></path>
<path d="M90 60v137H70V60z"></path>
</g>
</svg>
}
/>
);
};

View File

@ -0,0 +1,20 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconTask: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg width="24" height="24" viewBox="0 0 24 24" role="presentation">
<path
d="M7.5 6h9A1.5 1.5 0 0118 7.5v9a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 016 16.5v-9A1.5 1.5 0 017.5 6zm3.072 8.838l.143.154a.5.5 0 00.769-.042l.13-.175 3.733-5.045a.8.8 0 00-.11-1.064.665.665 0 00-.984.118l-3.243 4.387-1.315-1.422a.663.663 0 00-.99 0 .801.801 0 000 1.07l1.867 2.019z"
fill="currentColor"
fillRule="evenodd"
></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,23 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconZoomIn: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path d="M637 443H519V309c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v134H325c-4.4 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h118v134c0 4.4 3.6 8 8 8h60c4.4 0 8-3.6 8-8V519h118c4.4 0 8-3.6 8-8v-60c0-4.4-3.6-8-8-8z"></path>
<path d="M921 867L775 721c122.1-148.9 113.6-369.5-26-509-148-148.1-388.4-148.1-537 0-148.1 148.6-148.1 389 0 537 139.5 139.6 360.1 148.1 509 26l146 146c3.2 2.8 8.3 2.8 11 0l43-43c2.8-2.7 2.8-7.8 0-11zM696 696c-118.8 118.7-311.2 118.7-430 0-118.7-118.8-118.7-311.2 0-430 118.8-118.7 311.2-118.7 430 0 118.7 118.8 118.7 311.2 0 430z"></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,23 @@
import { Icon } from "@douyinfe/semi-ui";
export const IconZoomOut: React.FC<{ style?: React.CSSProperties }> = ({
style = {},
}) => {
return (
<Icon
style={style}
svg={
<svg
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path d="M637 443H325c-4.4 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h312c4.4 0 8-3.6 8-8v-60c0-4.4-3.6-8-8-8z"></path>
<path d="M921 867L775 721c122.1-148.9 113.6-369.5-26-509-148-148.1-388.4-148.1-537 0-148.1 148.6-148.1 389 0 537 139.5 139.6 360.1 148.1 509 26l146 146c3.2 2.8 8.3 2.8 11 0l43-43c2.8-2.7 2.8-7.8 0-11zM696 696c-118.8 118.7-311.2 118.7-430 0-118.7-118.8-118.7-311.2 0-430 118.8-118.7 311.2-118.7 430 0 118.7 118.8 118.7 311.2 0 430z"></path>
</svg>
}
/>
);
};

View File

@ -0,0 +1,35 @@
export * from "./IconDocument";
export * from "./IconDocumentFill";
export * from "./IconMessage";
export * from "./IconOverview";
export * from "./IconSetting";
export * from "./IconShare";
export * from "./IconLeft";
export * from "./IconRight";
export * from "./IconFull";
export * from "./IconHalf";
export * from "./IconCenter";
export * from "./IconFont";
export * from "./IconTask";
export * from "./IconLink";
export * from "./IconClear";
export * from "./IconImage";
export * from "./IconMind";
export * from "./IconZoomIn";
export * from "./IconZoomOut";
export * from "./IconTable";
export * from "./IconCodeBlock";
export * from "./IconStatus";
export * from "./IconInfo";
export * from "./IconEmoji";
export * from "./IconAddColumnBefore";
export * from "./IconAddColumnAfter";
export * from "./IconDeleteColumn";
export * from "./IconAddRowBefore";
export * from "./IconAddRowAfter";
export * from "./IconDeleteRow";
export * from "./IconDeleteTable";
export * from "./IconMergeCell";
export * from "./IconSplitCell";
export * from "./IconAttachment";
export * from "./IconMath";

View File

@ -0,0 +1,65 @@
import React, { useRef, useState, useEffect } from "react";
import distanceInWords from "date-fns/formatDistance";
import dateFormat from "date-fns/format";
import zh from "date-fns/locale/zh-CN";
let callbacks: Array<() => void> = [];
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn: () => void) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
type Props = {
date: string | number | Date;
format?: string;
timeago?: boolean;
};
const getTimeago = (date: number | string | Date) => {
let content = distanceInWords(new Date(date), new Date(), {
addSuffix: true,
locale: zh,
});
content = content
.replace("about", "")
.replace("less than a minute ago", "just now")
.replace("minute", "min");
return content;
};
export const LocaleTime: React.FC<Props> = ({
date,
timeago,
format = "yyyy-MM-dd HH:mm:ss",
}) => {
const [_, setMinutesMounted] = useState(0); // eslint-disable-line no-unused-vars
const callback = useRef<() => void>();
useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current();
}
};
}, []);
const formated = dateFormat(new Date(date), format);
return (
<time dateTime={formated}>{timeago ? getTimeago(date) : formated}</time>
);
};

View File

@ -0,0 +1,16 @@
.wrap {
display: inline-flex !important;
flex-wrap: nowrap;
align-items: center;
> svg {
width: 32px;
height: 32px;
}
> span {
font-size: 1.2rem;
font-weight: 500;
margin-left: 4px;
}
}

View File

@ -0,0 +1,48 @@
import Link from "next/link";
import { Typography } from "@douyinfe/semi-ui";
import styles from "./index.module.scss";
const { Text } = Typography;
export const LogoImage = () => {
return (
<Link href={"/"} as={"/"}>
<a style={{ width: 36, height: 36 }}>
<svg
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="36"
height="36"
>
<path
d="M512 959.948464A448.076077 448.076077 0 0 1 337.632578 99.243069a448.094728 448.094728 0 0 1 348.734844 825.553127 445.140995 445.140995 0 0 1-174.367422 35.152268z m0-876.27409c-236.180835 0-428.325626 192.144791-428.325626 428.305993s192.144791 428.335442 428.325626 428.335443 428.31581-192.144791 428.31581-428.335443S748.171019 83.674374 512 83.674374z"
fill="var(--semi-color-primary)"
></path>
<path
d="M512 910.179685a396.982093 396.982093 0 1 1 154.989992-31.294452 395.597991 395.597991 0 0 1-154.989992 31.294452z m0-786.552859c-214.152997 0-388.373174 174.220177-388.373174 388.353541s174.220177 388.38299 388.373174 388.38299 388.363357-174.220177 388.363357-388.38299-174.210361-388.353541-388.363357-388.353541z"
fill="var(--semi-color-primary)"
></path>
<path
d="M509.172898 357.196518m-70.167108 0a70.167108 70.167108 0 1 0 140.334215 0 70.167108 70.167108 0 1 0-140.334215 0Z"
fill="var(--semi-color-primary)"
></path>
<path
d="M427.559952 441.450056s34.219717 33.031941 77.411558 32.246635 83.919782-32.266268 83.919782-32.266268l99.488477 377.928602s-27.66241-20.7419-47.245983-19.200737-46.519575 41.15986-65.690863 40.001534-45.70482-54.971433-58.770351-54.156678-40.718126 57.661107-62.225699 54.186127-39.432187-48.168718-67.094597-47.118371-44.55631 22.577553-44.55631 22.577553zM509.172898 183.653668l-21.674451 68.26274h43.339085l-21.664634-68.26274zM682.715748 357.196518l-68.26274-21.664634v43.339085l68.26274-21.674451zM631.886805 234.482611l-63.590168 32.95341 30.636758 30.636758 32.95341-63.590168zM386.449174 234.482611l32.95341 63.590168 30.636758-30.636758-63.590168-32.95341zM335.620231 357.196518l68.26274 21.674451v-43.339085l-68.26274 21.664634z"
fill="var(--semi-color-primary)"
></path>
</svg>
</a>
</Link>
);
};
export const LogoText = () => {
return (
<Link href={"/"} as={"/"}>
<a className={styles.wrap}>
<Text></Text>
</a>
</Link>
);
};

View File

@ -0,0 +1,18 @@
import { Skeleton } from "@douyinfe/semi-ui";
export const Placeholder = () => {
return (
<Skeleton
placeholder={
<>
{Array.from({ length: 6 }).fill(
<Skeleton.Title
style={{ width: "100%", marginBottom: 12, marginTop: 12 }}
/>
)}
</>
}
loading={true}
></Skeleton>
);
};

View File

@ -0,0 +1,55 @@
.titleWrap {
padding: 8px 16px;
}
.itemsWrap {
max-height: 240px;
overflow: auto;
}
.itemWrap {
border-radius: var(--semi-border-radius-small);
&:hover {
background-color: var(--semi-color-fill-0);
color: var(--semi-color-text-0);
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 32px;
padding: 4px 16px;
.leftWrap {
display: flex;
align-items: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--semi-color-primary);
> span {
word-break: break-all;
}
svg {
fill: var(--semi-color-primary);
color: var(--semi-color-primary);
}
> div {
display: flex;
flex-direction: column;
}
}
}
}
.paginationWrap {
display: flex;
justify-content: center;
// padding: 16px 0 0;
}

View File

@ -0,0 +1,241 @@
import React, { useEffect } from "react";
import Link from "next/link";
import {
Typography,
Dropdown,
Badge,
Button,
Tabs,
TabPane,
Pagination,
Notification,
} from "@douyinfe/semi-ui";
import { IconMessage } from "components/icons/IconMessage";
import {
useAllMessages,
useReadMessages,
useUnreadMessages,
} from "data/message";
import { EmptyBoxIllustration } from "illustrations/empty-box";
import { DataRender } from "components/data-render";
import { Empty } from "components/empty";
import { Placeholder } from "./Placeholder";
import styles from "./index.module.scss";
const { Text } = Typography;
const PAGE_SIZE = 6;
const MessagesRender = ({
messageData,
loading,
error,
onClick = null,
page = 1,
onPageChange = null,
}) => {
const total = (messageData && messageData.total) || 0;
const messages = (messageData && messageData.data) || [];
const handleRead = (messageId) => {
onClick && onClick(messageId);
};
return (
<DataRender
loading={loading}
loadingContent={<Placeholder />}
error={error}
normalContent={() => {
return (
<div
className={styles.itemsWrap}
style={{ margin: "8px -16px" }}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{messages.length ? (
<>
{messages.map((msg) => {
return (
<div
className={styles.itemWrap}
onClick={() => handleRead(msg.id)}
>
<Link href={msg.url}>
<a className={styles.item}>
<div className={styles.leftWrap}>
<Text
ellipsis={{
showTooltip: {
opts: { content: msg.message },
},
}}
style={{ width: 240 }}
>
{msg.title}
</Text>
</div>
</a>
</Link>
</div>
);
})}
{total > PAGE_SIZE && (
<div className={styles.paginationWrap}>
<Pagination
size="small"
total={total}
currentPage={page}
pageSize={PAGE_SIZE}
style={{ textAlign: "center" }}
onPageChange={onPageChange}
/>
</div>
)}
</>
) : (
<Empty
illustration={<EmptyBoxIllustration />}
message="暂无消息"
/>
)}
</div>
);
}}
/>
);
};
export const Message = () => {
const {
data: allMsgs,
loading: allLoading,
error: allError,
page: allPage,
setPage: allSetPage,
} = useAllMessages();
const {
data: readMsgs,
loading: readLoading,
error: readError,
page: readPage,
setPage: readSetPage,
} = useReadMessages();
const {
data: unreadMsgs,
loading: unreadLoading,
error: unreadError,
readMessage,
page: unreadPage,
setPage: unreadSetPage,
} = useUnreadMessages();
const clearAll = () => {
Promise.all(
(unreadMsgs.data || []).map((msg) => {
return readMessage(msg.id);
})
);
};
useEffect(() => {
if (!unreadMsgs || !unreadMsgs.total) return;
const msg = unreadMsgs.data[0];
Notification.info({
title: "消息通知",
content: (
<Link href={msg.url}>
<a className={styles.item}>
<div className={styles.leftWrap}>
<Text
ellipsis={{
showTooltip: {
opts: { content: msg.message },
},
}}
style={{ width: 240 }}
>
{msg.title}
</Text>
</div>
</a>
</Link>
),
duration: 3,
});
}, [unreadMsgs]);
return (
<Dropdown
position="bottomRight"
trigger="click"
content={
<div style={{ width: 300, padding: "16px 16px 0" }}>
<Tabs
type="line"
size="small"
tabBarExtraContent={
unreadMsgs && unreadMsgs.total > 0 ? (
<Text
type="quaternary"
onClick={clearAll}
style={{ cursor: "pointer" }}
>
</Text>
) : null
}
>
<TabPane tab="未读" itemKey="unread">
<MessagesRender
messageData={unreadMsgs}
loading={unreadLoading}
error={unreadError}
onClick={readMessage}
page={unreadPage}
onPageChange={unreadSetPage}
/>
</TabPane>
<TabPane tab="已读" itemKey="read">
<MessagesRender
messageData={readMsgs}
loading={readLoading}
error={readError}
page={readPage}
onPageChange={readSetPage}
/>
</TabPane>
<TabPane tab="全部" itemKey="all">
<MessagesRender
messageData={allMsgs}
loading={allLoading}
error={allError}
page={allPage}
onPageChange={allSetPage}
/>
</TabPane>
</Tabs>
</div>
}
>
<Button
type="tertiary"
theme="borderless"
icon={
unreadMsgs && unreadMsgs.total > 0 ? (
<Badge count={unreadMsgs.total} overflowCount={99} type="danger">
<IconMessage style={{ transform: `translateY(2px)` }} />
</Badge>
) : (
<IconMessage />
)
}
></Button>
</Dropdown>
);
};

View File

@ -0,0 +1 @@
export * from "./resizeable";

View File

@ -0,0 +1,92 @@
import React, { useRef, useEffect } from "react";
import { useClickOutside } from "hooks/use-click-outside";
import interact from "interactjs";
import styles from "./style.module.scss";
interface IProps {
width: number;
height: number;
onChange: (arg: { width: number; height: number }) => void;
}
const MIN_WIDTH = 50;
const MIN_HEIGHT = 50;
export const Resizeable: React.FC<IProps> = ({
width,
height,
onChange,
children,
}) => {
const $container = useRef<HTMLDivElement>(null);
const $topLeft = useRef<HTMLDivElement>(null);
const $topRight = useRef<HTMLDivElement>(null);
const $bottomLeft = useRef<HTMLDivElement>(null);
const $bottomRight = useRef<HTMLDivElement>(null);
useClickOutside($container, {
in: () => $container.current.classList.add(styles.isActive),
out: () => $container.current.classList.remove(styles.isActive),
});
useEffect(() => {
interact($container.current).resizable({
edges: {
top: true,
right: true,
bottom: true,
left: true,
},
listeners: {
move: function (event) {
let { x, y } = event.target.dataset;
x = (parseFloat(x) || 0) + event.deltaRect.left;
y = (parseFloat(y) || 0) + event.deltaRect.top;
let { width, height } = event.rect;
width = width < MIN_WIDTH ? MIN_WIDTH : width;
height = height < MIN_HEIGHT ? MIN_HEIGHT : height;
Object.assign(event.target.style, {
width: `${width}px`,
height: `${height}px`,
// transform: `translate(${x}px, ${y}px)`,
});
Object.assign(event.target.dataset, { x, y });
onChange && onChange({ width, height });
},
},
});
}, []);
return (
<div
id="js-resizeable-container"
className={styles.resizable}
ref={$container}
style={{ width, height }}
>
<span
className={styles.resizer + " " + styles.topLeft}
ref={$topLeft}
data-type={"topLeft"}
></span>
<span
className={styles.resizer + " " + styles.topRight}
ref={$topRight}
data-type={"topRight"}
></span>
<span
className={styles.resizer + " " + styles.bottomLeft}
ref={$bottomLeft}
data-type={"bottomLeft"}
></span>
<span
className={styles.resizer + " " + styles.bottomRight}
ref={$bottomRight}
data-type={"bottomRight"}
></span>
{children}
</div>
);
};

View File

@ -0,0 +1,52 @@
.resizable {
box-sizing: border-box;
position: relative;
display: inline-block;
width: 100px;
height: 100px;
max-width: 100%;
.resizer {
box-sizing: border-box;
position: absolute;
z-index: 9999;
width: 10px;
height: 10px;
border-radius: 50%;
background: white;
border: 3px solid #4286f4;
opacity: 0;
}
.resizer.topLeft {
left: -5px;
top: -5px;
cursor: nwse-resize;
}
.resizer.topRight {
right: -5px;
top: -5px;
cursor: nesw-resize;
}
.resizer.bottomLeft {
left: -5px;
bottom: -5px;
cursor: nesw-resize;
}
.resizer.bottomRight {
right: -5px;
bottom: -5px;
cursor: nwse-resize;
}
&.isActive {
border: 1px solid #4286f4;
.resizer {
opacity: 1;
}
}
}

View File

@ -0,0 +1,23 @@
import React from "react";
import { Helmet } from "react-helmet";
interface IProps {
title: string;
needTitleSuffix?: boolean;
}
const buildTitle = (title) => `${title} - 云策文档`;
export const Seo: React.FC<IProps> = ({ title, needTitleSuffix = true }) => {
return (
<Helmet>
<title>{needTitleSuffix ? buildTitle(title) : title}</title>
<meta
name="viewport"
content="width=device-width,initial-scale=1.0,viewport-fit=cover,maximum-scale=1"
/>
<meta name="keyword" content={`云策文档 协作 文档 fantasticit`} />
<meta name="description" content={`云策文档 协作 文档 fantasticit`} />
</Helmet>
);
};

View File

@ -0,0 +1,67 @@
.cardWrap {
position: relative;
transform: translateZ(0);
margin: 8px 0;
display: flex;
flex-direction: column;
width: 100%;
height: 161px;
padding: 12px 16px 16px;
border-radius: var(--border-radius);
border: 1px solid var(--semi-color-border);
cursor: pointer;
overflow: hidden;
header {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--semi-color-primary);
margin-bottom: 12px;
.rightWrap {
opacity: 0;
}
}
&:hover {
box-shadow: var(--semi-color-shadow);
header .rightWrap {
opacity: 1;
}
}
footer {
margin-top: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actions {
position: absolute;
bottom: -1px;
left: 0;
z-index: 10;
transition: all ease-in-out 0.2s;
width: 100%;
display: flex;
justify-content: space-around;
padding: 8px;
border-radius: 0 0 var(--border-radius) var(--border-radius);
background-color: var(--semi-color-fill-2);
opacity: 0;
button {
width: 40%;
}
}
&:hover {
.actions {
opacity: 1;
}
}
}

View File

@ -0,0 +1,203 @@
import type { ITemplate } from "@think/share";
import { useCallback } from "react";
import cls from "classnames";
import Router from "next/router";
import {
Button,
Space,
Typography,
Tooltip,
Avatar,
Skeleton,
Modal,
} from "@douyinfe/semi-ui";
import { IconEdit, IconUser, IconPlus } from "@douyinfe/semi-icons";
import { IconDocument } from "components/icons/IconDocument";
import { TemplateReader } from "components/template/reader";
import styles from "./index.module.scss";
import { useToggle } from "hooks/useToggle";
const { Text } = Typography;
export interface IProps {
template: ITemplate;
onClick?: (id: string) => void;
getClassNames?: (id: string) => string;
onOpenPreview?: () => void;
onClosePreview?: () => void;
}
export const TemplateCard: React.FC<IProps> = ({
template,
onClick,
getClassNames = (id) => "",
onOpenPreview,
onClosePreview,
}) => {
const [visible, toggleVisible] = useToggle(false);
const gotoEdit = useCallback(() => {
Router.push(`/template/${template.id}/`);
}, [template]);
return (
<>
<Modal
title="模板预览"
width={"calc(100vh - 120px)"}
height={"calc(100vh - 120px)"}
bodyStyle={{
overflow: "auto",
}}
visible={visible}
onCancel={() => {
toggleVisible(false);
onClosePreview && onClosePreview();
}}
footer={null}
fullScreen
>
<TemplateReader key={template.id} templateId={template.id} />
</Modal>
<div className={cls(styles.cardWrap, getClassNames(template.id))}>
<header>
<IconDocument />
<div className={styles.rightWrap}>
<Space>
<Tooltip key="edit" content="编辑" position="bottom">
<Button
type="tertiary"
theme="borderless"
icon={<IconEdit />}
onClick={gotoEdit}
/>
</Tooltip>
</Space>
</div>
</header>
<main>
<div style={{ marginBottom: 12 }}>
<Text strong>{template.title}</Text>
</div>
<div>
<Text type="tertiary" size="small">
<Space>
<Avatar
size="extra-extra-small"
src={template.createUser && template.createUser.avatar}
>
<IconUser />
</Avatar>
{template.createUser && template.createUser.name}
</Space>
</Text>
</div>
</main>
<footer>
<Text type="tertiary" size="small">
<div style={{ display: "flex" }}>
使
{template.usageAmount}
</div>
</Text>
</footer>
<div className={styles.actions}>
<Button
theme="solid"
type="tertiary"
onClick={() => {
toggleVisible(true);
onOpenPreview && onOpenPreview();
}}
>
</Button>
{onClick && (
<Button
type="primary"
theme="solid"
onClick={() => onClick && onClick(template.id)}
>
使
</Button>
)}
</div>
</div>
</>
);
};
export const TemplateCardPlaceholder = () => {
return (
<div className={styles.cardWrap}>
<header>
<IconDocument />
</header>
<main>
<div style={{ marginBottom: 12 }}>
<Skeleton.Title style={{ width: 160 }} />
</div>
<div>
<Text type="tertiary" size="small">
<Space>
<Avatar size="extra-extra-small">
<IconUser />
</Avatar>
<Skeleton.Paragraph rows={1} style={{ width: 100 }} />
</Space>
</Text>
</div>
</main>
<footer>
<Text type="tertiary" size="small">
<div style={{ display: "flex" }}>
<Skeleton.Paragraph rows={1} style={{ width: 100 }} />
</div>
</Text>
</footer>
</div>
);
};
export const TemplateCardEmpty = ({
getClassNames = () => "",
onClick = () => {},
}) => {
return (
<div className={cls(styles.cardWrap, getClassNames())} onClick={onClick}>
<div
style={{
height: 131,
position: "relative",
}}
>
<div
style={{
position: "absolute",
left: "50%",
top: "50%",
transform: `translate(-50%, -50%)`,
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
<Text link style={{ textAlign: "center" }}>
<IconPlus
style={{
width: 36,
height: 36,
fontSize: 36,
margin: "0 auto 12px",
}}
/>
</Text>
<Text></Text>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,210 @@
import React, { useMemo, useCallback, useState, useEffect } from "react";
import Router from "next/router";
import cls from "classnames";
import { useEditor, EditorContent } from "@tiptap/react";
import {
Button,
Nav,
Space,
Skeleton,
Typography,
Tooltip,
Spin,
Switch,
Popover,
Popconfirm,
BackTop,
} from "@douyinfe/semi-ui";
import { IconChevronLeft, IconArticle } from "@douyinfe/semi-icons";
import { IUser, ITemplate } from "@think/share";
import { Theme } from "components/theme";
import {
DEFAULT_EXTENSION,
DocumentWithTitle,
getCollaborationExtension,
getProvider,
MenuBar,
Toc,
} from "components/tiptap";
import { DataRender } from "components/data-render";
import { User } from "components/user";
import { DocumentStyle } from "components/document/style";
import { useDocumentStyle } from "hooks/useDocumentStyle";
import { safeJSONParse } from "helpers/json";
import styles from "./index.module.scss";
const { Text } = Typography;
interface IProps {
user: IUser;
data: ITemplate;
loading: boolean;
error: Error | null;
updateTemplate: (arg) => Promise<ITemplate>;
deleteTemplate: () => Promise<void>;
}
export const Editor: React.FC<IProps> = ({
user,
data,
loading,
error,
updateTemplate,
deleteTemplate,
}) => {
if (!user) return null;
const provider = useMemo(() => {
return getProvider({
targetId: data.id,
token: user.token,
cacheType: "READER",
user,
docType: "template",
});
}, [data, user.token]);
const editor = useEditor({
editable: true,
extensions: [
...DEFAULT_EXTENSION,
DocumentWithTitle,
getCollaborationExtension(provider),
],
content: safeJSONParse(data && data.content),
});
const [isPublic, setPublic] = useState(false);
const { width, fontSize } = useDocumentStyle();
const editorWrapClassNames = useMemo(() => {
return width === "standardWidth"
? styles.isStandardWidth
: styles.isFullWidth;
}, [width]);
const goback = useCallback(() => {
Router.back();
}, []);
const handleDelte = useCallback(() => {
deleteTemplate().then(() => {
goback();
});
}, [deleteTemplate]);
useEffect(() => {
if (!data) return;
setPublic(data.isPublic);
}, [data]);
return (
<div className={styles.wrap}>
<header>
<Nav
style={{ overflow: "auto" }}
mode="horizontal"
header={
<DataRender
loading={loading}
error={error}
loadingContent={
<Skeleton
active
placeholder={
<Skeleton.Title style={{ width: 80, marginBottom: 8 }} />
}
loading={true}
/>
}
normalContent={() => (
<>
<Tooltip content="返回" position="bottom">
<Button
onClick={goback}
icon={<IconChevronLeft />}
style={{ marginRight: 16 }}
/>
</Tooltip>
<Text
strong
ellipsis={{ showTooltip: true }}
style={{ width: 120 }}
>
{data.title}
</Text>
</>
)}
/>
}
footer={
<Space>
<Popover
key="style"
zIndex={1061}
position="bottomLeft"
content={<DocumentStyle />}
>
<Button
icon={<IconArticle />}
theme="borderless"
type="tertiary"
/>
</Popover>
<Tooltip
position="bottom"
content={isPublic ? "公开模板" : "个人模板"}
>
<Switch
onChange={(v) => updateTemplate({ isPublic: v })}
></Switch>
</Tooltip>
<Popconfirm
title="删除模板"
content="模板删除后不可恢复,谨慎操作!"
onConfirm={handleDelte}
>
<Button type="danger"></Button>
</Popconfirm>
<Theme />
<User />
</Space>
}
></Nav>
</header>
<main className={styles.contentWrap}>
<DataRender
loading={false}
loadingContent={
<div style={{ margin: 24 }}>
<Spin></Spin>
</div>
}
error={error}
normalContent={() => {
return (
<div className={styles.editorWrap}>
<header className={editorWrapClassNames}>
<div>
<MenuBar editor={editor} />
</div>
</header>
<main id="js-template-editor-container">
<div
className={cls(styles.contentWrap, editorWrapClassNames)}
style={{ fontSize }}
>
<EditorContent editor={editor} />
</div>
<BackTop
target={() =>
document.querySelector("#js-template-editor-container")
}
/>
</main>
</div>
);
}}
/>
</main>
</div>
);
};

View File

@ -0,0 +1,77 @@
.wrap {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
> header {
height: 60px;
> div {
overflow: auto;
}
}
> main {
height: calc(100% - 60px);
flex: 1;
overflow: hidden;
background-color: var(--semi-color-nav-bg);
}
}
.editorWrap {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
> header {
height: 50px;
padding: 0 24px;
display: flex;
align-items: center;
overflow: hidden;
border-bottom: 1px solid var(--semi-color-border);
&.isStandardWidth {
> div {
margin: 0 auto;
}
}
&.isFullWidth {
> div {
margin: 0;
}
}
> div {
display: inline-flex;
align-items: center;
// width: 100%;
height: 100%;
overflow: auto;
}
}
> main {
flex: 1;
height: calc(100% - 50px);
overflow: auto;
.contentWrap {
padding: 24px 24px 96px;
&.isStandardWidth {
width: 96%;
max-width: 750px;
margin: 0 auto;
}
&.isFullWidth {
width: 100%;
margin: 0 auto;
}
}
}
}

View File

@ -0,0 +1,44 @@
import React from "react";
import { Spin } from "@douyinfe/semi-ui";
import { useUser } from "data/user";
import { Seo } from "components/seo";
import { DataRender } from "components/data-render";
import { useTemplate } from "data/template";
import { Editor } from "./editor";
interface IProps {
templateId: string;
}
export const TemplateEditor: React.FC<IProps> = ({ templateId }) => {
const { user } = useUser();
const { data, loading, error, updateTemplate, deleteTemplate } =
useTemplate(templateId);
return (
<DataRender
loading={loading}
loadingContent={
<div style={{ margin: 24 }}>
<Spin></Spin>
</div>
}
error={error}
normalContent={() => {
return (
<>
<Seo title={data.title} />
<Editor
user={user}
data={data}
loading={loading}
error={error}
updateTemplate={updateTemplate}
deleteTemplate={deleteTemplate}
/>
</>
);
}}
/>
);
};

View File

@ -0,0 +1,104 @@
import React, { useState, useMemo } from "react";
import { List, Pagination } from "@douyinfe/semi-ui";
import { DataRender } from "components/data-render";
import {
IProps as ITemplateCardProps,
TemplateCardPlaceholder,
TemplateCard,
} from "components/template/card";
import { Empty } from "components/empty";
const grid = {
gutter: 16,
xs: 24,
sm: 12,
md: 12,
lg: 8,
xl: 8,
};
interface IProps extends Omit<ITemplateCardProps, "template"> {
// TODO: 修复类型
hook: any;
firstListItem?: React.ReactNode;
pageSize?: number;
}
export const TemplateList: React.FC<IProps> = ({
hook,
onClick,
getClassNames,
firstListItem,
onOpenPreview,
onClosePreview,
pageSize = 5,
}) => {
const { data, loading, error } = hook();
const [page, onPageChange] = useState(1);
const arr = useMemo(() => {
const arr = (data && data.data) || [];
const start = (page - 1) * pageSize;
const end = page * pageSize;
return arr.slice(start, end);
}, [data, page]);
return (
<DataRender
loading={loading}
loadingContent={() => (
<List
grid={grid}
dataSource={[1, 2, 3]}
renderItem={() => (
<List.Item>
<TemplateCardPlaceholder />
</List.Item>
)}
/>
)}
error={error}
normalContent={() => (
<>
<List
grid={grid}
dataSource={firstListItem ? [{}, ...arr] : arr}
renderItem={(template, idx) => {
if (idx === 0 && firstListItem) {
return <List.Item>{firstListItem}</List.Item>;
}
return (
<List.Item>
<TemplateCard
template={template}
onClick={onClick}
getClassNames={getClassNames}
onOpenPreview={onOpenPreview}
onClosePreview={onClosePreview}
/>
</List.Item>
);
}}
emptyContent={<Empty message={"暂无模板"} />}
></List>
{data.data.length > pageSize ? (
<Pagination
size="small"
style={{
width: "100%",
flexBasis: "100%",
justifyContent: "center",
}}
pageSize={pageSize}
total={data.data.length}
currentPage={page}
onChange={(cPage) => onPageChange(cPage)}
/>
) : null}
</>
)}
/>
);
};

View File

@ -0,0 +1,73 @@
import React, { useMemo } from "react";
import cls from "classnames";
import { useEditor, EditorContent } from "@tiptap/react";
import { Layout, Spin, Typography } from "@douyinfe/semi-ui";
import { IUser, ITemplate } from "@think/share";
import { DEFAULT_EXTENSION, DocumentWithTitle } from "components/tiptap";
import { DataRender } from "components/data-render";
import { useDocumentStyle } from "hooks/useDocumentStyle";
import { safeJSONParse } from "helpers/json";
import styles from "./index.module.scss";
const { Content } = Layout;
const { Title } = Typography;
interface IProps {
user: IUser;
data: ITemplate;
loading: boolean;
error: Error | null;
}
export const Editor: React.FC<IProps> = ({ user, data, loading, error }) => {
if (!user) return null;
const c = safeJSONParse(data.content);
let json = c.default || c;
if (json && json.content) {
json = {
type: "doc",
content: json.content.slice(1),
};
}
const editor = useEditor({
editable: false,
extensions: [...DEFAULT_EXTENSION, DocumentWithTitle],
content: json,
});
const { width, fontSize } = useDocumentStyle();
const editorWrapClassNames = useMemo(() => {
return width === "standardWidth"
? styles.isStandardWidth
: styles.isFullWidth;
}, [width]);
return (
<div className={styles.wrap}>
<Layout className={styles.contentWrap}>
<DataRender
loading={false}
loadingContent={
<div style={{ margin: 24 }}>
<Spin></Spin>
</div>
}
error={error}
normalContent={() => {
return (
<Content className={cls(styles.editorWrap)}>
<div className={editorWrapClassNames} style={{ fontSize }}>
<Title>{data.title}</Title>
<EditorContent editor={editor} />
</div>
</Content>
);
}}
/>
</Layout>
</div>
);
};

View File

@ -0,0 +1,33 @@
.wrap {
display: flex;
flex-direction: column;
height: 100%;
.contentWrap {
flex: 1;
overflow: hidden;
> div {
display: flex;
flex-direction: column;
height: 100%;
}
.editorWrap {
flex: 1;
overflow: auto;
}
}
}
.isStandardWidth {
width: 96%;
max-width: 750px;
margin: 0 auto;
}
.isFullWidth {
width: 100%;
margin: 0 auto;
}

View File

@ -0,0 +1,36 @@
import React from "react";
import { Spin } from "@douyinfe/semi-ui";
import { useUser } from "data/user";
import { Seo } from "components/seo";
import { DataRender } from "components/data-render";
import { useTemplate } from "data/template";
import { Editor } from "./editor";
interface IProps {
templateId: string;
}
export const TemplateReader: React.FC<IProps> = ({ templateId }) => {
const { user } = useUser();
const { data, loading, error } = useTemplate(templateId);
return (
<DataRender
loading={loading}
loadingContent={
<div style={{ margin: 24 }}>
<Spin></Spin>
</div>
}
error={error}
normalContent={() => {
return (
<div style={{ fontSize: 16 }}>
<Seo title={data.title} />
<Editor user={user} data={data} loading={loading} error={error} />
</div>
);
}}
/>
);
};

View File

@ -0,0 +1,20 @@
import React, { useEffect, useState } from "react";
import { Button, Tooltip } from "@douyinfe/semi-ui";
import { IconSun, IconMoon } from "@douyinfe/semi-icons";
import { useTheme } from "hooks/useTheme";
export const Theme = () => {
const { theme, toggle } = useTheme();
const Icon = theme === "dark" ? IconSun : IconMoon;
const text = theme === "dark" ? "切换到亮色模式" : "切换到深色模式";
return (
<Tooltip content={text} position="bottom">
<Button
onClick={toggle}
icon={<Icon style={{ fontSize: 20 }} />}
theme="borderless"
></Button>
</Tooltip>
);
};

Some files were not shown because too many files have changed in this diff Show More