📝 Docs: 升级新版 NonePress 主题 (#2375)

This commit is contained in:
Ju4tCode 2023-09-27 16:00:26 +08:00 committed by GitHub
parent 7754f6da1d
commit 842c6ff4c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
234 changed files with 8759 additions and 5887 deletions

6
.eslintignore Normal file
View File

@ -0,0 +1,6 @@
dist
node_modules
.yarn
.history
build
lib

85
.eslintrc.js Normal file
View File

@ -0,0 +1,85 @@
module.exports = {
root: true,
env: {
browser: true,
commonjs: true,
node: true,
},
parser: "@typescript-eslint/parser",
parserOptions: {
tsconfigRootDir: __dirname,
project: ["./tsconfig.json", "./website/tsconfig.json"],
},
globals: {
JSX: true,
},
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:regexp/recommended",
"plugin:prettier/recommended",
],
settings: {
"import/resolver": {
node: {
extensions: [".js", ".jsx", ".ts", ".tsx"],
},
typescript: true,
},
react: {
version: "detect",
},
},
overrides: [
{
files: ["*.ts", "*.tsx"],
rules: {
"import/no-unresolved": "off",
},
},
{
files: ["*.js", "*.cjs"],
rules: {
"@typescript-eslint/no-var-requires": "off",
},
},
],
plugins: ["@typescript-eslint"],
rules: {
"linebreak-style": ["error", "unix"],
quotes: ["error", "double", { avoidEscape: true }],
semi: ["error", "always"],
"@typescript-eslint/no-non-null-assertion": "off",
"import/order": [
"error",
{
groups: [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
],
pathGroups: [
{ pattern: "react", group: "builtin", position: "before" },
{ pattern: "fs-extra", group: "builtin" },
{ pattern: "lodash", group: "external", position: "before" },
{ pattern: "clsx", group: "external", position: "before" },
{ pattern: "@theme/**", group: "internal" },
{ pattern: "@site/**", group: "internal" },
{ pattern: "@theme-init/**", group: "internal" },
{ pattern: "@theme-original/**", group: "internal" },
],
pathGroupsExcludedImportTypes: [],
"newlines-between": "always",
alphabetize: {
order: "asc",
},
},
],
},
};

View File

@ -7,15 +7,7 @@ runs:
- uses: actions/setup-node@v3
with:
node-version: "18"
cache: "yarn"
- id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
shell: bash
- uses: actions/cache@v3
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
- run: yarn install
- run: yarn install --frozen-lockfile
shell: bash

2
.gitignore vendored
View File

@ -139,7 +139,7 @@ fabric.properties
.LSOverride
# Icon must end with two \r
Icon
# Icon
# Thumbnails
._*

31
.stylelintrc.js Normal file
View File

@ -0,0 +1,31 @@
module.exports = {
extends: ["stylelint-config-standard", "stylelint-prettier/recommended"],
overrides: [
{
files: ["*.css"],
rules: {
"function-no-unknown": [true, { ignoreFunctions: ["theme"] }],
"selector-class-pattern": [
"^([a-z][a-z0-9]*)(-[a-z0-9]+)*$",
{
resolveNestedSelectors: true,
message: (selector) =>
`Expected class selector "${selector}" to be kebab-case`,
},
],
},
},
{
files: ["*.module.css"],
rules: {
"selector-class-pattern": [
"^[a-z][a-zA-Z0-9]+$",
{
message: (selector) =>
`Expected class selector "${selector}" to be lowerCamelCase`,
},
],
},
},
],
};

View File

@ -207,7 +207,7 @@ NoneBot2 不是 NoneBot1 的替代品。事实上,它们都在被积极的维
- [文档镜像(中国境内)](https://nb2.baka.icu)
- 其他插件请查看 [商店](https://nonebot.dev/store)
- 其他插件请查看 [商店](https://nonebot.dev/store/plugins)
## 许可证

View File

@ -1,30 +1,30 @@
from .model import URL as URL
from .driver import Mixin as Mixin
from .model import RawURL as RawURL
from .driver import Driver as Driver
from .abstract import Mixin as Mixin
from .model import Cookies as Cookies
from .model import Request as Request
from .abstract import Driver as Driver
from .model import FileType as FileType
from .model import Response as Response
from .model import DataTypes as DataTypes
from .model import FileTypes as FileTypes
from .model import WebSocket as WebSocket
from .driver import ASGIMixin as ASGIMixin
from .model import FilesTypes as FilesTypes
from .model import QueryTypes as QueryTypes
from .abstract import ASGIMixin as ASGIMixin
from .model import CookieTypes as CookieTypes
from .model import FileContent as FileContent
from .model import HTTPVersion as HTTPVersion
from .model import HeaderTypes as HeaderTypes
from .model import SimpleQuery as SimpleQuery
from .model import ContentTypes as ContentTypes
from .driver import ForwardMixin as ForwardMixin
from .driver import ReverseMixin as ReverseMixin
from .model import QueryVariable as QueryVariable
from .driver import ForwardDriver as ForwardDriver
from .driver import ReverseDriver as ReverseDriver
from .driver import combine_driver as combine_driver
from .abstract import ForwardMixin as ForwardMixin
from .abstract import ReverseMixin as ReverseMixin
from .abstract import ForwardDriver as ForwardDriver
from .abstract import ReverseDriver as ReverseDriver
from .combine import combine_driver as combine_driver
from .model import HTTPServerSetup as HTTPServerSetup
from .driver import HTTPClientMixin as HTTPClientMixin
from .abstract import HTTPClientMixin as HTTPClientMixin
from .model import WebSocketServerSetup as WebSocketServerSetup
from .driver import WebSocketClientMixin as WebSocketClientMixin
from .abstract import WebSocketClientMixin as WebSocketClientMixin

View File

@ -2,18 +2,7 @@ import abc
import asyncio
from typing_extensions import TypeAlias
from contextlib import AsyncExitStack, asynccontextmanager
from typing import (
TYPE_CHECKING,
Any,
Set,
Dict,
Type,
Union,
TypeVar,
Callable,
AsyncGenerator,
overload,
)
from typing import TYPE_CHECKING, Any, Set, Dict, Type, Callable, AsyncGenerator
from nonebot.log import logger
from nonebot.config import Env, Config
@ -33,8 +22,6 @@ if TYPE_CHECKING:
from nonebot.internal.adapter import Bot, Adapter
D = TypeVar("D", bound="Driver")
BOT_HOOK_PARAMS = [DependParam, BotParam, DefaultParam]
@ -295,44 +282,3 @@ ReverseDriver: TypeAlias = ReverseMixin
**Deprecated**请使用 {ref}`nonebot.drivers.ReverseMixin` 或其子类代替
"""
if TYPE_CHECKING:
class CombinedDriver(Driver, Mixin):
...
@overload
def combine_driver(driver: Type[D]) -> Type[D]:
...
@overload
def combine_driver(driver: Type[D], *mixins: Type[Mixin]) -> Type["CombinedDriver"]:
...
def combine_driver(
driver: Type[D], *mixins: Type[Mixin]
) -> Union[Type[D], Type["CombinedDriver"]]:
"""将一个驱动器和多个混入类合并。"""
# check first
assert issubclass(driver, Driver), "`driver` must be subclass of Driver"
assert all(
issubclass(m, Mixin) for m in mixins
), "`mixins` must be subclass of Mixin"
if not mixins:
return driver
def type_(self: "CombinedDriver") -> str:
return (
driver.type.__get__(self)
+ "+"
+ "+".join(x.type.__get__(self) for x in mixins)
)
return type(
"CombinedDriver", (*mixins, driver), {"type": property(type_)}
) # type: ignore

View File

@ -0,0 +1,45 @@
from typing import TYPE_CHECKING, Type, Union, TypeVar, overload
from .abstract import Mixin, Driver
D = TypeVar("D", bound="Driver")
if TYPE_CHECKING:
class CombinedDriver(Driver, Mixin):
...
@overload
def combine_driver(driver: Type[D]) -> Type[D]:
...
@overload
def combine_driver(driver: Type[D], *mixins: Type[Mixin]) -> Type["CombinedDriver"]:
...
def combine_driver(
driver: Type[D], *mixins: Type[Mixin]
) -> Union[Type[D], Type["CombinedDriver"]]:
"""将一个驱动器和多个混入类合并。"""
# check first
if not issubclass(driver, Driver):
raise TypeError("`driver` must be subclass of Driver")
if not all(issubclass(m, Mixin) for m in mixins):
raise TypeError("`mixins` must be subclass of Mixin")
if not mixins:
return driver
def type_(self: "CombinedDriver") -> str:
return (
driver.type.__get__(self)
+ "+"
+ "+".join(x.type.__get__(self) for x in mixins)
)
return type(
"CombinedDriver", (*mixins, driver), {"type": property(type_)}
) # type: ignore

View File

@ -29,7 +29,7 @@
- `load_builtin_plugins` =>
{ref}``load_builtin_plugins` <nonebot.plugin.load.load_builtin_plugins>`
- `require` => {ref}``require` <nonebot.plugin.load.require>`
- `PluginMetadata` => {ref}``PluginMetadata` <nonebot.plugin.plugin.PluginMetadata>`
- `PluginMetadata` => {ref}``PluginMetadata` <nonebot.plugin.model.PluginMetadata>`
FrontMatter:
sidebar_position: 0
@ -77,7 +77,7 @@ def get_plugin(name: str) -> Optional["Plugin"]:
如果为 `load_plugins` 文件夹导入的插件则为文件()
参数:
name: 插件名 {ref}`nonebot.plugin.plugin.Plugin.name`
name: 插件名 {ref}`nonebot.plugin.model.Plugin.name`
"""
return _plugins.get(name)
@ -88,7 +88,7 @@ def get_plugin_by_module_name(module_name: str) -> Optional["Plugin"]:
如果提供的模块名为某个插件的子模块同样会返回该插件
参数:
module_name: 模块名 {ref}`nonebot.plugin.plugin.Plugin.module_name`
module_name: 模块名 {ref}`nonebot.plugin.model.Plugin.module_name`
"""
loaded = {plugin.module_name: plugin for plugin in _plugins.values()}
has_parent = True
@ -111,9 +111,9 @@ def get_available_plugin_names() -> Set[str]:
from .on import on as on
from .manager import PluginManager
from .on import on_type as on_type
from .model import Plugin as Plugin
from .load import require as require
from .on import on_regex as on_regex
from .plugin import Plugin as Plugin
from .on import on_notice as on_notice
from .on import on_command as on_command
from .on import on_keyword as on_keyword
@ -129,8 +129,8 @@ from .load import load_plugins as load_plugins
from .on import on_startswith as on_startswith
from .load import load_from_json as load_from_json
from .load import load_from_toml as load_from_toml
from .model import PluginMetadata as PluginMetadata
from .on import on_shell_command as on_shell_command
from .plugin import PluginMetadata as PluginMetadata
from .load import load_all_plugins as load_all_plugins
from .load import load_builtin_plugin as load_builtin_plugin
from .load import load_builtin_plugins as load_builtin_plugins

View File

@ -12,7 +12,7 @@ from typing import Set, Union, Iterable, Optional
from nonebot.utils import path_to_module_name
from .plugin import Plugin
from .model import Plugin
from .manager import PluginManager
from . import _managers, get_plugin, _current_plugin_chain, _module_name_to_plugin_name
@ -160,7 +160,7 @@ def require(name: str) -> ModuleType:
如果为 `load_plugins` 文件夹导入的插件则为文件()
参数:
name: 插件名 {ref}`nonebot.plugin.plugin.Plugin.name`
name: 插件名 {ref}`nonebot.plugin.model.Plugin.name`
异常:
RuntimeError: 插件无法加载

View File

@ -20,7 +20,7 @@ from typing import Set, Dict, List, Iterable, Optional, Sequence
from nonebot.log import logger
from nonebot.utils import escape_tag, path_to_module_name
from .plugin import Plugin, PluginMetadata
from .model import Plugin, PluginMetadata
from . import (
_managers,
_new_plugin,

View File

@ -2,7 +2,7 @@
FrontMatter:
sidebar_position: 3
description: nonebot.plugin.plugin 模块
description: nonebot.plugin.model 模块
"""
import contextlib

View File

@ -30,7 +30,7 @@ from nonebot.rule import (
shell_command,
)
from .plugin import Plugin
from .model import Plugin
from . import get_plugin_by_module_name
from .manager import _current_plugin_chain

View File

@ -10,7 +10,7 @@ from nonebot.rule import Rule, ArgumentParser
from nonebot.matcher import Matcher, MatcherSource
from nonebot.typing import T_State, T_Handler, T_RuleChecker, T_PermissionChecker
from .plugin import Plugin
from .model import Plugin
def store_matcher(matcher: type[Matcher]) -> None: ...
def get_matcher_plugin(depth: int = ...) -> Plugin | None: ...

View File

@ -599,7 +599,7 @@ def shell_command(
通过 {ref}`nonebot.params.ShellCommandArgs` 获取解析后的参数字典
: `{"arg": "arg", "h": True}`
:::warning 警告
:::caution 警告
如果参数解析失败则通过 {ref}`nonebot.params.ShellCommandArgs`
获取的将是 {ref}`nonebot.exception.ParserExit` 异常
:::

View File

@ -12,11 +12,30 @@
"serve": "yarn workspace nonebot serve",
"clear": "yarn workspace nonebot clear",
"prettier": "prettier --config ./.prettierrc --write \"./website/\"",
"lint": "yarn lint:js && yarn lint:style",
"lint:js": "eslint --cache --report-unused-disable-directives \"**/*.{js,jsx,ts,tsx,mjs}\"",
"lint:js:fix": "eslint --cache --report-unused-disable-directives --fix \"**/*.{js,jsx,ts,tsx,mjs}\"",
"lint:style": "stylelint \"**/*.css\"",
"lint:style:fix": "stylelint --fix \"**/*.css\"",
"pyright": "pyright"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"cross-env": "^7.0.3",
"prettier": "^2.5.0",
"pyright": "^1.1.317"
"eslint": "^8.48.0",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "^3.6.0",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-regexp": "^1.15.0",
"prettier": "^3.0.3",
"pyright": "^1.1.317",
"stylelint": "^15.10.3",
"stylelint-config-standard": "^34.0.0",
"stylelint-prettier": "^4.0.2"
}
}

45
tsconfig.json Normal file
View File

@ -0,0 +1,45 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ESNext"],
"module": "NodeNext",
"declaration": true,
"declarationMap": false,
"sourceMap": false,
"jsx": "react-native",
"noEmit": true,
/* Strict Type-Checking Options */
"strict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
/* Additional Checks */
// "noUnusedLocals": false, // ensured by eslint, should not block compilation
// "noImplicitReturns": true,
// "noFallthroughCasesInSwitch": true,
/* Disabled on purpose (handled by ESLint, should not block compilation) */
"noUnusedParameters": false,
/* Module Resolution Options */
"moduleResolution": "nodenext",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"isolatedModules": true,
/* Advanced Options */
"resolveJsonModule": true,
"skipLibCheck": true, // @types/webpack and webpack/types.d.ts are not the same thing
/* Use tslib */
"importHelpers": true,
"noEmitHelpers": true
},
"include": ["./**/.eslintrc.js", "./**/.stylelintrc.js"],
"exclude": ["node_modules", "**/lib/**/*"]
}

View File

@ -1,33 +0,0 @@
# Website
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
### Installation
```
$ yarn
```
### Local Development
```
$ yarn start
```
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
### Build
```
$ yarn build
```
This command generates static content into the `build` directory and can be served using any static contents hosting service.
### Deployment
```
$ GIT_USER=<Your GitHub username> USE_SSH=true yarn deploy
```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.

View File

@ -4,8 +4,8 @@ description: 注册适配器与指定平台交互
options:
menu:
weight: 20
category: advanced
- category: advanced
weight: 20
---
# 使用适配器
@ -158,4 +158,4 @@ is_tome: bool = event.is_tome()
## 更多
官方支持的适配器和社区贡献的适配器均可在[商店](/store)中查看。如果你想要开发自己的适配器,可以参考[开发文档](../developer/adapter-writing.md)。欢迎通过商店发布你的适配器。
官方支持的适配器和社区贡献的适配器均可在[商店](/store/adapters)中查看。如果你想要开发自己的适配器,可以参考[开发文档](../developer/adapter-writing.md)。欢迎通过商店发布你的适配器。

View File

@ -4,8 +4,8 @@ description: 通过依赖注入获取上下文信息
options:
menu:
weight: 70
category: advanced
- category: advanced
weight: 70
---
# 依赖注入
@ -557,7 +557,7 @@ async def _(x: httpx.AsyncClient = Depends(get_client)):
</TabItem>
</Tabs>
:::warning 注意
:::caution 注意
生成器作为依赖时,其中只能进行一次 `yield`,否则将会触发异常。如果对此有疑问并想探究原因,可以参考 [contextmanager](https://docs.python.org/zh-cn/3/library/contextlib.html#contextlib.contextmanager) 和 [asynccontextmanager](https://docs.python.org/zh-cn/3/library/contextlib.html#contextlib.asynccontextmanager) 文档。事实上NoneBot 内部就使用了这两个装饰器。
:::

View File

@ -4,8 +4,8 @@ description: 选择合适的驱动器运行机器人
options:
menu:
weight: 10
category: advanced
- category: advanced
weight: 10
---
# 选择驱动器
@ -118,7 +118,7 @@ DRIVER=~fastapi
##### `fastapi_reload`
:::warning 警告
:::caution 警告
不推荐开启该配置项,在 Windows 平台上开启该功能有可能会造成预料之外的影响!替代方案:使用 `nb-cli` 命令行工具以及参数 `--reload` 启动 NoneBot。
```bash
@ -200,7 +200,7 @@ DRIVER=~quart
##### `quart_reload`
:::warning 警告
:::caution 警告
不推荐开启该配置项,在 Windows 平台上开启该功能有可能会造成预料之外的影响!替代方案:使用 `nb-cli` 命令行工具以及参数 `--reload` 启动 NoneBot。
```bash
@ -252,7 +252,7 @@ nonebot.run(app="bot:app")
**类型:**HTTP 客户端驱动器
:::warning 注意
:::caution 注意
本驱动器仅支持 HTTP 请求,不支持 WebSocket 连接请求。
:::
@ -266,7 +266,7 @@ DRIVER=~httpx
**类型:**WebSocket 客户端驱动器
:::warning 注意
:::caution 注意
本驱动器仅支持 WebSocket 连接请求,不支持 HTTP 请求。
:::

View File

@ -4,8 +4,8 @@ description: 自定义事件响应器存储
options:
menu:
weight: 110
category: advanced
- category: advanced
weight: 110
---
# 事件响应器存储

View File

@ -4,8 +4,8 @@ description: 事件响应器组成与内置响应规则
options:
menu:
weight: 60
category: advanced
- category: advanced
weight: 60
---
# 事件响应器进阶

View File

@ -4,8 +4,8 @@ description: 填写与获取插件相关的信息
options:
menu:
weight: 30
category: advanced
- category: advanced
weight: 30
---
# 插件信息
@ -14,7 +14,7 @@ NoneBot 是一个插件化的框架,可以通过加载插件来扩展功能。
## 插件元数据
在 NoneBot 中,插件 [`Plugin`](../api/plugin/plugin.md#Plugin) 对象中存储了插件系统所需要的一系列信息。包括插件的索引名称、插件模块、插件中的事件响应器、插件父子关系等。通常,只有插件开发者才需要关心这些信息,而插件使用者或者机器人用户想要看到的是插件使用方法等帮助信息。因此,我们可以为插件添加插件元数据 `PluginMetadata`,它允许插件开发者为插件添加一些额外的信息。这些信息编写于插件模块的顶层,可以直接通过源码查看,或者通过 NoneBot 插件系统获取收集到的信息,通过其他方式发送给机器人用户等。
在 NoneBot 中,插件 [`Plugin`](../api/plugin/model.md#Plugin) 对象中存储了插件系统所需要的一系列信息。包括插件的索引名称、插件模块、插件中的事件响应器、插件父子关系等。通常,只有插件开发者才需要关心这些信息,而插件使用者或者机器人用户想要看到的是插件使用方法等帮助信息。因此,我们可以为插件添加插件元数据 `PluginMetadata`,它允许插件开发者为插件添加一些额外的信息。这些信息编写于插件模块的顶层,可以直接通过源码查看,或者通过 NoneBot 插件系统获取收集到的信息,通过其他方式发送给机器人用户等。
现在,假设我们有一个插件 `example`, 它的模块结构如下:

View File

@ -4,8 +4,8 @@ description: 编写与加载嵌套插件
options:
menu:
weight: 40
category: advanced
- category: advanced
weight: 40
---
# 嵌套插件

View File

@ -4,8 +4,8 @@ description: 使用其他插件提供的功能
options:
menu:
weight: 50
category: advanced
- category: advanced
weight: 50
---
# 跨插件访问

View File

@ -4,8 +4,8 @@ description: 添加服务端路由规则
options:
menu:
weight: 100
category: advanced
- category: advanced
weight: 100
---
# 添加路由
@ -21,10 +21,11 @@ NoneBot 中,我们可以通过两种途径向 ASGI 驱动器添加路由规则
在向驱动器添加路由规则时,我们需要注意驱动器是否为服务端类型,我们可以通过以下方式判断:
```python {3}
```python
from nonebot import get_driver
from nonebot.drivers import ASGIMixin
# highlight-next-line
can_use = isinstance(get_driver(), ASGIMixin)
```

View File

@ -4,8 +4,8 @@ description: 在特定的生命周期中执行代码
options:
menu:
weight: 90
category: advanced
- category: advanced
weight: 90
---
# 钩子函数

View File

@ -4,8 +4,8 @@ description: 控制会话响应对象
options:
menu:
weight: 80
category: advanced
- category: advanced
weight: 80
---
# 会话更新
@ -56,4 +56,4 @@ async def _(matcher: Matcher) -> Permission:
请注意,此处为全大写字母的 `USER` 权限,它可以匹配多个会话 ID。通过这种方式我们可以实现多用户同时参与的会话。
我们已经了解了如何控制会话的更新,相信你已经能够实现更复杂的会话功能了,例如多人小游戏等等。欢迎将你的作品分享到[插件商店](/store)。
我们已经了解了如何控制会话的更新,相信你已经能够实现更复杂的会话功能了,例如多人小游戏等等。欢迎将你的作品分享到[插件商店](/store/plugins)。

View File

@ -4,14 +4,13 @@ description: 使用平台接口,完成更多功能
options:
menu:
weight: 50
category: appendices
- category: appendices
weight: 50
---
# 使用平台接口
import Messenger from "@site/src/components/Messenger";
import MarkdownText from "!!raw-loader!./assets/console-markdown.txt";
import Messenger from "@/components/Messenger";
在 NoneBot 中,除了使用事件响应器操作发送文本消息外,我们还可以直接通过使用协议适配器提供的方法来使用平台特定的接口,完成发送特殊消息、获取信息等其他平台提供的功能。同时,在部分无法使用事件响应器的情况中,例如[定时任务](../best-practice/scheduler.md),我们也可以使用平台接口来完成需要的功能。
@ -19,7 +18,7 @@ import MarkdownText from "!!raw-loader!./assets/console-markdown.txt";
在之前的章节中,我们介绍了如何向用户发送文本消息以及[如何处理平台消息](../tutorial/message.md),现在我们来向用户发送平台特殊消息。
:::warning 注意
:::caution 注意
在以下的示例中,我们将使用 `Console` 协议适配器来演示如何发送平台消息。在实际使用中,你需要确保你使用的**消息序列类型**与你所要发送的**平台类型**一致。
:::
@ -49,7 +48,11 @@ async def got_location(location: str = ArgPlainText()):
{ position: "right", msg: "/天气" },
{ position: "left", msg: "❓请输入地名" },
{ position: "right", msg: "北京" },
{ position: "left", msg: MarkdownText },
{
position: "left",
monospace: true,
msg: "┏━━━━━━━━━━━━━━━━┓\n┃ 北京 ┃\n┗━━━━━━━━━━━━━━━━┛\n• 今天\n⛅ 多云 20℃~24℃",
},
]}
/>
@ -100,7 +103,7 @@ result = await bot.get_user_info(user_id=12345678)
result = await bot.call_api("get_user_info", user_id=12345678)
```
:::warning 注意
:::caution 注意
实际可以使用的 API 以及参数取决于平台提供的接口以及协议适配器的实现,请参考协议适配器以及平台文档。
:::

View File

@ -1,6 +0,0 @@
┏━━━━━━━━━━━━━━━━┓
┃ 北京 ┃
┗━━━━━━━━━━━━━━━━┛
• 今天
⛅ 多云 20℃~24℃

View File

@ -4,8 +4,8 @@ description: 读取用户配置来控制插件行为
options:
menu:
weight: 10
category: appendices
- category: appendices
weight: 10
---
# 配置
@ -62,7 +62,7 @@ export CUSTOM_CONFIG="config in environment variables"
那最终 NoneBot 所读取的内容为环境变量中的内容,即 `config in environment variables`。
:::warning 注意
:::caution 注意
NoneBot 不会自发读取未被定义的配置项的环境变量,如果需要读取某一环境变量需要在 dotenv 配置文件中进行声明。
:::

View File

@ -4,8 +4,8 @@ description: 记录与控制日志
options:
menu:
weight: 70
category: appendices
- category: appendices
weight: 70
---
# 日志

View File

@ -4,8 +4,8 @@ description: 根据事件类型进行不同的处理
options:
menu:
weight: 80
category: appendices
- category: appendices
weight: 80
---
# 事件类型与重载
@ -28,7 +28,7 @@ async def got_location(event: MessageEvent, location: str = ArgPlainText()):
在上面的代码中,我们获取了 `Console` 协议适配器的消息事件提供的发送时间 `time` 属性。
:::warning 注意
:::caution 注意
如果**基类**就能满足你的需求,那么就**不要修改**事件参数类型注解,这样可以使你的代码更加**通用**,可以在更多平台上运行。如何根据不同平台事件类型进行不同的处理,我们将在[重载](#重载)一节中介绍。
:::
@ -63,7 +63,7 @@ async def handle_onebot(bot: OneBot):
await bot.send_group_message(group_id=123123, message="OneBot")
```
:::warning 注意
:::caution 注意
重载机制对所有的参数类型注解都有效,因此,依赖注入也可以使用这个特性来对不同的返回值进行处理。
但 Bot、Event 和 Matcher 三者的参数类型注解具有最高检查优先级,如果三者任一类型注解不匹配,那么其他依赖注入将不会执行(如:`Depends`)。

View File

@ -4,8 +4,8 @@ description: 控制事件响应器的权限
options:
menu:
weight: 60
category: appendices
- category: appendices
weight: 60
---
# 权限控制

View File

@ -4,8 +4,8 @@ description: 自定义响应规则
options:
menu:
weight: 20
category: appendices
- category: appendices
weight: 20
---
# 响应规则

View File

@ -4,8 +4,8 @@ description: 更灵活的会话控制
options:
menu:
weight: 30
category: appendices
- category: appendices
weight: 30
---
# 会话控制
@ -322,7 +322,7 @@ async def _(matcher: Matcher):
matcher.stop_propagation()
```
:::warning 注意
:::caution 注意
`stop_propagation` 操作是实例方法,需要先通过依赖注入获取事件响应器实例再进行调用。
:::

View File

@ -4,8 +4,8 @@ description: 会话状态信息
options:
menu:
weight: 40
category: appendices
- category: appendices
weight: 40
---
# 会话状态

View File

@ -180,7 +180,7 @@ docker compose build
将以下文件添加至**项目目录**下的 `.github/workflows/` 目录下,并将文件中高亮行中的仓库名称替换为你的仓库名称:
```yaml title=.github/workflows/build.yml {34}
```yaml title=.github/workflows/build.yml
name: Docker Hub Release
on:
@ -213,6 +213,7 @@ jobs:
id: metadata
with:
images: |
# highlight-next-line
{organization}/{repository}
tags: |
type=semver,pattern={{version}}

View File

@ -27,7 +27,7 @@ nb plugin install nonebot-plugin-sentry
### 配置插件
:::warning 注意
:::caution 注意
错误跟踪通常在生产环境中使用,因此开发环境中 `sentry_dsn` 留空即会停用插件。
:::

View File

@ -58,7 +58,7 @@ scheduler.add_job(
)
```
:::warning 注意
:::caution 注意
由于 APScheduler 的定时任务并不是**由事件响应器所触发的事件**,因此其任务函数无法同[事件处理函数](../tutorial/handler.mdx#事件处理函数)一样通过[依赖注入](../tutorial/event-data.mdx#认识依赖注入)获取上下文信息,也无法通过事件响应器对象的方法进行任何操作,因此我们需要使用[调用平台 API](../appendices/api-calling.mdx#调用平台-api)的方式来获取信息或收发消息。
相对于事件处理依赖而言,编写定时任务更像是编写普通的函数,需要我们自行获取信息以及发送信息,请**不要**将事件处理依赖的特殊语法用于定时任务!

View File

@ -189,7 +189,7 @@ async def _(bot: Bot):
然后我们对该插件进行测试:
```python {19,20,23,24} title=tests/test_example.py
```python title=tests/test_example.py
from datetime import datetime
import pytest
@ -210,12 +210,16 @@ async def test_example(app: App):
from awesome_bot.plugins.example import foo
async with app.test_matcher(foo) as ctx:
# highlight-start
adapter = nonebot.get_adapter(Adapter)
bot = ctx.create_bot(base=Bot, adapter=adapter)
# highlight-end
event = make_event("/foo")
ctx.receive_event(bot, event)
# highlight-start
ctx.should_call_send(event, "message", result=None, bot=bot)
ctx.should_call_api("bell", {}, result=None, adapter=adapter)
# highlight-end
```
请注意,对于在依赖注入中使用了非基类对象的情况,我们需要在 `create_bot` 方法中指定 `base` 和 `adapter` 参数,确保不会因为重载功能而出现非预期情况。

View File

@ -576,6 +576,6 @@ class Message(BaseMessage[MessageSegment]):
## 后续工作
在完成适配器代码的编写后,如果想要将适配器发布到 NoneBot 商店,我们需要将适配器发布到 PyPI 中,然后前往[商店](/store)页面,切换到适配器页签,点击**发布适配器**按钮,填写适配器相关信息并提交。
在完成适配器代码的编写后,如果想要将适配器发布到 NoneBot 商店,我们需要将适配器发布到 PyPI 中,然后前往[商店](/store/adapters)页面,切换到适配器页签,点击**发布适配器**按钮,填写适配器相关信息并提交。
另外建议编写适配器文档或者一些插件开发示例,以便其他开发者使用我们的适配器。

View File

@ -62,7 +62,7 @@ NoneBot 插件使用下述命名规范:
依赖填写的基本原则:程序直接导入了什么第三方库,就添加什么第三方包依赖;能用哪些第三方库的特性,就根据使用的特性锁定第三方包版本。
:::warning 注意
:::caution 注意
1. 插件需要添加 `nonebot2` 为依赖以避免“幽灵依赖”;
2. 插件需要将使用的适配器加入依赖列表,如:使用 OneBot 适配器的插件应添加 `nonebot-adapter-onebot` 依赖;
@ -102,7 +102,7 @@ __plugin_meta__ = PluginMetadata(
)
```
:::warning 注意
:::caution 注意
`__plugin_meta__` 变量**必须**处于插件最外层(如 `__init__.py` 中),否则无法正常识别。
一般做法是在 `__init__.py` 中定义 `__plugin_meta__`。
@ -183,7 +183,7 @@ twine upload dist/* # 只发布先前的构建
### 提交申请
完成在 PyPI 的插件发布流程后,前往[商店](/store)页面,切换到插件页签,点击 **发布插件** 按钮。
完成在 PyPI 的插件发布流程后,前往[商店](/store/plugins)页面,切换到插件页签,点击 **发布插件** 按钮。
在弹出的插件信息提交表单内,填入您所要发布的相应插件信息。请注意,如果插件需要必要配置项才能正常导入,请在“插件配置项”中填写必要的内容(请勿填写密钥等敏感信息)。
@ -199,4 +199,4 @@ twine upload dist/* # 只发布先前的构建
之后NoneBot 的维护者和一些插件开发者会初步检查插件代码,帮助减少该插件的问题。
完成这些步骤后,您的插件将会被自动合并到[商店](/store),而您也将成为 [**NoneBot 贡献者**](https://github.com/nonebot/nonebot2/graphs/contributors)的一员。
完成这些步骤后,您的插件将会被自动合并到[商店](/store/plugins),而您也将成为 [**NoneBot 贡献者**](https://github.com/nonebot/nonebot2/graphs/contributors)的一员。

View File

@ -4,8 +4,8 @@ description: 尝试使用 NoneBot
options:
menu:
weight: 10
category: tutorial
- category: tutorial
weight: 10
---
import Asciinema from "@site/src/components/Asciinema";
@ -13,7 +13,7 @@ import Messenger from "@site/src/components/Messenger";
# 快速上手
:::warning 前提条件
:::caution 前提条件
- 请确保你的 Python 版本 >= 3.8
- **我们强烈建议使用虚拟环境进行开发**,如果没有使用虚拟环境,请确保已经卸载可能存在的 NoneBot v1

View File

@ -4,15 +4,15 @@ description: 创建一个 NoneBot 项目
options:
menu:
weight: 20
category: tutorial
- category: tutorial
weight: 20
---
# 手动创建项目
在[快速上手](./quick-start.mdx)中,我们已经介绍了如何安装和使用 `nb-cli` 创建一个项目。在本章节中,我们将简要介绍如何在不使用 `nb-cli` 的方式创建一个机器人项目的**最小实例**并启动。如果你想要了解 NoneBot 的启动流程,也可以阅读本章节。
在[快速上手](../quick-start.mdx)中,我们已经介绍了如何安装和使用 `nb-cli` 创建一个项目。在本章节中,我们将简要介绍如何在不使用 `nb-cli` 的方式创建一个机器人项目的**最小实例**并启动。如果你想要了解 NoneBot 的启动流程,也可以阅读本章节。
:::warning
:::caution 警告
我们十分不推荐直接创建机器人项目,请优先考虑使用 nb-cli 进行项目创建。
:::
@ -44,7 +44,7 @@ options:
pip install 'nonebot2[fastapi]'
```
驱动器包名可以在 [驱动器商店](/store) 中找到。
驱动器包名可以在 [驱动器商店](/store/drivers) 中找到。
3. 安装适配器
@ -52,7 +52,7 @@ options:
pip install nonebot-adapter-console
```
适配器包名可以在 [适配器商店](/store) 中找到。
适配器包名可以在 [适配器商店](/store/adapters) 中找到。
## 创建配置文件

View File

@ -4,8 +4,8 @@ description: 创建并加载自定义插件
options:
menu:
weight: 50
category: tutorial
- category: tutorial
weight: 50
---
# 插件编写准备
@ -41,7 +41,7 @@ options:
## 创建插件
:::warning 注意
:::caution 注意
如果在之前的[快速上手](../quick-start.mdx)章节中已经使用 `bootstrap` 模板创建了项目,那么你需要做出如下修改:
1. 在项目目录中创建一个两层文件夹 `awesome_bot/plugins`
@ -63,7 +63,7 @@ options:
:::
:::warning 注意
:::caution 注意
如果在之前的[创建项目](./application.md)章节中手动创建了相关文件,那么你需要做出如下修改:
1. 在项目目录中创建一个两层文件夹 `awesome_bot/plugins`
@ -144,7 +144,7 @@ nonebot.load_plugin("path.to.your.plugin") # 加载第三方插件
nonebot.load_plugin(Path("./path/to/your/plugin.py")) # 加载项目插件
```
:::warning 注意
:::caution 注意
请注意,本地插件的路径应该为相对机器人 **入口文件(通常为 bot.py** 可导入的,例如在项目 `plugins` 目录下。
:::
@ -156,7 +156,7 @@ nonebot.load_plugin(Path("./path/to/your/plugin.py")) # 加载项目插件
nonebot.load_plugins("src/plugins", "path/to/your/plugins")
```
:::warning 注意
:::caution 注意
请注意,插件目录应该为相对机器人 **入口文件(通常为 bot.py** 可导入的,例如在项目 `plugins` 目录下。
:::

View File

@ -4,8 +4,8 @@ description: 通过依赖注入获取所需事件信息
options:
menu:
weight: 80
category: tutorial
- category: tutorial
weight: 80
---
# 获取事件信息

View File

@ -4,8 +4,8 @@ description: NoneBot 机器人构成及基本使用
options:
menu:
weight: 30
category: tutorial
- category: tutorial
weight: 30
---
# 机器人的构成

View File

@ -4,8 +4,8 @@ description: 处理接收到的特定事件
options:
menu:
weight: 70
category: tutorial
- category: tutorial
weight: 70
---
# 事件处理

View File

@ -4,8 +4,8 @@ description: 响应接收到的特定事件
options:
menu:
weight: 60
category: tutorial
- category: tutorial
weight: 60
---
# 事件响应器

View File

@ -4,8 +4,8 @@ description: 处理消息序列与消息段
options:
menu:
weight: 90
category: tutorial
- category: tutorial
weight: 90
---
# 处理消息
@ -26,7 +26,7 @@ options:
顾名思义,消息段 `MessageSegment` 是一段消息。由于消息序列的本质是由若干消息段所组成的序列,消息段可以被认为是构成消息序列的最小单位。简单来说,消息序列类似于一个自然段,而消息段则是组成自然段的一句话。同时,作为特殊消息载体的存在,绝大多数的平台都有着**独特的消息类型**,这些独特的内容均需要由对应的**协议适配器**所提供,以适应不同平台中的消息模式。**这也意味着,你需要导入对应的协议适配器中的消息序列和消息段后才能使用其特殊的工厂方法。**
:::warning 注意
:::caution 注意
消息段的类型是由协议适配器提供的,因此你需要参考协议适配器的文档并导入对应的消息段后才能使用其特殊的消息类型。
在上一节的[使用依赖注入](./event-data.mdx#使用依赖注入)中,我们导入的为 `nonebot.adapters.Message` 抽象基类,因此我们无法使用平台特有的消息类型。仅能使用 `str` 作为纯文本消息回复。
@ -34,7 +34,7 @@ options:
## 使用消息序列
:::warning 注意
:::caution 注意
在以下的示例中,为了更好的理解多种类型的消息组成方式,我们将使用 `Console` 协议适配器来演示消息序列的使用方法。在实际使用中,你需要确保你使用的**消息序列类型**与你所要发送的**平台类型**一致。
:::
@ -297,7 +297,7 @@ msg == Message(
如果 `Message.template` 构建消息模板,那么消息模板将采用消息序列形式的格式化,此时的消息将会是平台特定的:
:::warning 注意
:::caution 注意
使用 `Message.template` 构建消息模板时,应注意消息序列为平台适配器提供的类型,不能使用 `nonebot.adapters.Message` 基类作为模板构建。使用基类构建模板与使用 `str` 构建模板的效果是一样的,因此请使用上述的 `MessageTemplate` 类直接构建模板。:
:::
@ -337,7 +337,7 @@ Message(
)
```
:::warning 注意
:::caution 注意
只有消息序列中的文本类型消息段才能被格式化,其他类型的消息段将会原样添加。
:::

View File

@ -4,8 +4,8 @@ description: 从商店安装适配器和插件
options:
menu:
weight: 40
category: tutorial
- category: tutorial
weight: 40
---
# 获取商店内容
@ -15,10 +15,12 @@ import TabItem from "@theme/TabItem";
import Asciinema from "@site/src/components/Asciinema";
:::tip 提示
如果你暂时没有获取商店内容的需求,可以跳过本章节。
:::
NoneBot 提供了一个[商店](/store),商店内容均由社区开发者贡献。你可以在商店中查找你需要的适配器和插件等,进行安装或者参考其文档等。
NoneBot 提供了一个[商店](/store/plugins),商店内容均由社区开发者贡献。你可以在商店中查找你需要的适配器和插件等,进行安装或者参考其文档等。
商店中每个内容的卡片都包含了其名称和简介等信息,点击**卡片右上角**链接图标即可跳转到其主页。

View File

@ -1,26 +1,226 @@
// @ts-check
// Note: type annotations allow type checking and IDEs autocompletion
// color mode config
/** @type {import('@nullbot/docusaurus-preset-nonepress').ThemeConfig["colorMode"]} */
const colorMode = {
defaultMode: "light",
respectPrefersColorScheme: true,
};
// navbar config
/** @type {import('@nullbot/docusaurus-preset-nonepress').ThemeConfig["navbar"]} */
const navbar = {
title: "NoneBot",
logo: {
alt: "NoneBot",
src: "logo.png",
href: "/",
target: "_self",
height: 32,
width: 32,
},
hideOnScroll: false,
items: [
{
label: "指南",
type: "docsMenu",
category: "tutorial",
},
{
label: "深入",
type: "docsMenu",
category: "appendices",
},
{
label: "进阶",
type: "docsMenu",
category: "advanced",
},
{
label: "API",
type: "doc",
docId: "api/index",
},
{
label: "更多",
type: "dropdown",
to: "/store/plugins",
items: [
{
label: "最佳实践",
type: "doc",
docId: "best-practice/scheduler",
},
{
label: "开发者",
type: "doc",
docId: "developer/plugin-publishing",
},
{ label: "社区", type: "doc", docId: "community/contact" },
{ label: "开源之夏", type: "doc", docId: "ospp/2023" },
{ label: "商店", to: "/store/plugins" },
{ label: "更新日志", to: "/changelog" },
{ label: "论坛", href: "https://discussions.nonebot.dev" },
],
},
],
};
// footer config
/** @type {import('@nullbot/docusaurus-preset-nonepress').ThemeConfig["footer"]} */
const footer = {
style: "light",
logo: {
alt: "NoneBot",
src: "logo.png",
href: "/",
target: "_self",
height: 32,
width: 32,
},
copyright: `Copyright © ${new Date().getFullYear()} NoneBot. All rights reserved.`,
links: [
{
title: "Learn",
items: [
{ label: "Introduction", to: "/docs/" },
{ label: "QuickStart", to: "/docs/quick-start" },
{ label: "Changelog", to: "/changelog" },
],
},
{
title: "NoneBot Team",
items: [
{
label: "Homepage",
href: "https://nonebot.dev",
},
{
label: "NoneBot V1",
href: "https://v1.nonebot.dev",
},
{ label: "NoneBot CLI", href: "https://cli.nonebot.dev" },
],
},
{
title: "Related",
items: [
{ label: "OneBot", href: "https://onebot.dev/" },
{ label: "go-cqhttp", href: "https://docs.go-cqhttp.org/" },
{ label: "Mirai", href: "https://mirai.mamoe.net/" },
],
},
],
};
// prism config
/** @type {import('prism-react-renderer').PrismTheme} */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line import/order
const lightCodeTheme = require("prism-react-renderer/themes/github");
/** @type {import('prism-react-renderer').PrismTheme} */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line import/order
const darkCodeTheme = require("prism-react-renderer/themes/dracula");
/** @type {import('@nullbot/docusaurus-preset-nonepress').ThemeConfig["prism"]} */
const prism = {
theme: lightCodeTheme,
darkTheme: darkCodeTheme,
additionalLanguages: ["docker", "ini"],
};
// algolia config
/** @type {import('@nullbot/docusaurus-preset-nonepress').ThemeConfig["algolia"]} */
const algolia = {
appId: "X0X5UACHZQ",
apiKey: "ac03e1ac2bd0812e2ea38c0cc1ea38c5",
indexName: "nonebot",
contextualSearch: true,
};
// nonepress config
/** @type {import('@nullbot/docusaurus-preset-nonepress').ThemeConfig["nonepress"]} */
const nonepress = {
tailwindConfig: require("./tailwind.config"),
navbar: {
docsVersionDropdown: {
dropdownItemsAfter: [
{
label: "1.x",
href: "https://v1.nonebot.dev/",
},
],
},
socialLinks: [
{
icon: ["fab", "github"],
href: "https://github.com/nonebot/nonebot2",
},
],
},
footer: {
socialLinks: [
{
icon: ["fab", "github"],
href: "https://github.com/nonebot/nonebot2",
},
{
icon: ["fab", "qq"],
href: "https://jq.qq.com/?_wv=1027&k=5OFifDh",
},
{
icon: ["fab", "telegram"],
href: "https://t.me/botuniverse",
},
{
icon: ["fab", "discord"],
href: "https://discord.gg/VKtE6Gdc4h",
},
],
},
};
// theme config
/** @type {import('@nullbot/docusaurus-preset-nonepress').ThemeConfig} */
const themeConfig = {
colorMode,
navbar,
footer,
prism,
algolia,
nonepress,
};
/** @type {import('@docusaurus/types').Config} */
const config = {
const siteConfig = {
title: "NoneBot",
tagline: "跨平台 Python 异步机器人框架",
url: "https://nonebot.dev",
baseUrl: process.env.BASE_URL || "/",
onBrokenLinks: "throw",
onBrokenMarkdownLinks: "warn",
favicon: "icons/favicon.ico",
// Set the production url of your site here
url: "https://nonebot.dev",
// Set the /<baseUrl>/ pathname under which your site is served
// For GitHub pages deployment, it is often '/<projectName>/'
baseUrl: process.env.BASE_URL || "/",
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
organizationName: "nonebot", // Usually your GitHub org/user name.
projectName: "nonebot2", // Usually your repo name.
onBrokenLinks: "throw",
onBrokenMarkdownLinks: "warn",
// Even if you don't use internalization, you can use this field to set useful
// metadata like html lang. For example, if your site is Chinese, you may want
// to replace "en" with "zh-Hans".
i18n: {
defaultLocale: "zh-Hans",
locales: ["zh-Hans"],
localeConfigs: {
"zh-Hans": { label: "简体中文" },
},
},
scripts: [
@ -34,8 +234,8 @@ const config = {
presets: [
[
"docusaurus-preset-nonepress",
/** @type {import('docusaurus-preset-nonepress').Options} */
"@nullbot/docusaurus-preset-nonepress",
/** @type {import('@nullbot/docusaurus-preset-nonepress').Options} */
({
docs: {
sidebarPath: require.resolve("./sidebars.js"),
@ -49,160 +249,42 @@ const config = {
// "**/*.test.{js,jsx,ts,tsx}",
// "**/__tests__/**",
// ],
// async sidebarItemsGenerator({
// isCategoryIndex: defaultCategoryIndexMatcher,
// defaultSidebarItemsGenerator,
// ...args
// }) {
// return defaultSidebarItemsGenerator({
// ...args,
// isCategoryIndex(doc) {
// // disable category index convention for generated API docs
// if (
// doc.directories.length > 0 &&
// doc.directories.at(-1) === "api"
// ) {
// return false;
// }
// return defaultCategoryIndexMatcher(doc);
// },
// });
// },
},
// theme: {
// customCss: require.resolve("./src/css/custom.css"),
// },
sitemap: {
changefreq: "daily",
priority: 0.5,
},
gtag: {
trackingID: "G-MRS1GMZG0F",
},
}),
],
],
plugins: [require("./src/plugins/webpack-plugin.cjs")],
themeConfig:
/** @type {import('docusaurus-preset-nonepress').ThemeConfig} */
({
colorMode: {
defaultMode: "light",
},
logo: {
alt: "",
src: "logo.png",
href: "/",
target: "_self",
},
navbar: {
hideOnScroll: true,
items: [
{
label: "指南",
type: "docsMenu",
category: "tutorial",
},
{
label: "深入",
type: "docsMenu",
category: "appendices",
},
{
label: "进阶",
type: "docsMenu",
category: "advanced",
},
{
label: "API",
type: "docLink",
docId: "api/index",
},
{
label: "更多",
type: "dropdown",
to: "/store",
items: [
{
label: "最佳实践",
type: "docLink",
docId: "best-practice/scheduler",
},
{
label: "开发者",
type: "docLink",
docId: "developer/plugin-publishing",
},
{ label: "社区", type: "docLink", docId: "community/contact" },
{ label: "开源之夏", type: "docLink", docId: "ospp/2023" },
{ label: "商店", to: "/store" },
{ label: "更新日志", to: "/changelog" },
{ label: "论坛", href: "https://discussions.nonebot.dev" },
],
},
{
icon: ["fab", "github"],
href: "https://github.com/nonebot/nonebot2",
},
],
docsVersionItemAfter: [
{
label: "2.0.0a16",
href: "https://61d3d9dbcadf413fd3238e89--nonebot2.netlify.app/",
},
{
label: "1.x",
href: "https://v1.nonebot.dev/",
},
],
},
hideableSidebar: true,
footer: {
copyright: `Copyright © ${new Date().getFullYear()} NoneBot. All rights reserved.`,
iconLinks: [
{
icon: ["fab", "github"],
href: "https://github.com/nonebot/nonebot2",
description: "GitHub",
},
{
icon: ["fab", "qq"],
href: "https://jq.qq.com/?_wv=1027&k=5OFifDh",
},
{
icon: ["fab", "telegram"],
href: "https://t.me/botuniverse",
},
{
icon: ["fab", "discord"],
href: "https://discord.gg/VKtE6Gdc4h",
},
],
links: [
{
title: "Learn",
icon: ["fas", "book"],
items: [
{ label: "Introduction", to: "/docs/" },
// { label: "QuickStart", to: "/docs/quick-start" },
{ label: "Changelog", to: "/changelog" },
],
},
{
title: "NoneBot Team",
icon: ["fas", "user-friends"],
items: [
{
label: "Homepage",
href: "https://nonebot.dev",
},
{
label: "NoneBot V1",
href: "https://docs.nonebot.dev",
},
{ label: "NoneBot CLI", href: "https://cli.nonebot.dev" },
],
},
{
title: "Related",
icon: ["fas", "external-link-alt"],
items: [
{ label: "OneBot", href: "https://onebot.dev/" },
{ label: "go-cqhttp", href: "https://docs.go-cqhttp.org/" },
{ label: "Mirai", href: "https://mirai.mamoe.net/" },
],
},
],
},
prism: {
theme: lightCodeTheme,
darkTheme: darkCodeTheme,
additionalLanguages: ["docker", "ini"],
},
algolia: {
appId: "X0X5UACHZQ",
apiKey: "ac03e1ac2bd0812e2ea38c0cc1ea38c5",
indexName: "nonebot",
contextualSearch: true,
},
tailwindConfig: require("./tailwind.config"),
customCss: [require.resolve("./src/css/custom.css")],
}),
themeConfig,
};
module.exports = config;
module.exports = siteConfig;

View File

@ -11,7 +11,7 @@
"license": "MIT",
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"start": "docusaurus start --host 0.0.0.0 --port 3000",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
@ -22,27 +22,23 @@
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "2.0.0-beta.9",
"@mdx-js/react": "^1.6.21",
"@svgr/webpack": "^5.5.0",
"clsx": "^1.1.1",
"copy-to-clipboard": "^3.3.1",
"docusaurus-preset-nonepress": "canary",
"file-loader": "^6.2.0",
"prism-react-renderer": "^1.2.1",
"@docusaurus/core": "^2.4.1",
"@mdx-js/react": "^1.6.22",
"@nullbot/docusaurus-preset-nonepress": "^2.1.2",
"clsx": "^1.2.1",
"copy-text-to-clipboard": "^3.0.1",
"prism-react-renderer": "^1.3.5",
"raw-loader": "^4.0.2",
"react": "^17.0.1",
"react-color": "^2.19.3",
"react-dom": "^17.0.1",
"react-use-pagination": "^2.0.1",
"resize-observer-polyfill": "^1.5.1",
"url-loader": "^4.1.1"
"react-use-pagination": "^2.0.1"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "2.0.0-beta.9",
"@tsconfig/docusaurus": "^1.0.4",
"asciinema-player": "^3.0.0-rc.1",
"typescript": "^4.3.5"
"@docusaurus/module-type-aliases": "^2.4.1",
"@tsconfig/docusaurus": "^1.0.5",
"asciinema-player": "^3.5.0",
"typescript": "^4.7.4"
},
"browserslist": {
"production": [
@ -55,5 +51,8 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"engines": {
"node": ">=16.14"
}
}

View File

@ -87,7 +87,7 @@ const sidebars = {
{
type: "category",
label: "开源之夏",
collapsible: false,
collapsible: true,
items: [
{
type: "autogenerated",
@ -102,8 +102,28 @@ const sidebars = {
items: [
{
type: "link",
label: "商店",
href: "/store",
label: "插件商店",
href: "/store/plugins",
},
{
type: "link",
label: "适配器商店",
href: "/store/adapters",
},
{
type: "link",
label: "驱动器商店",
href: "/store/drivers",
},
{
type: "link",
label: "机器人商店",
href: "/store/bots",
},
{
type: "link",
label: "Awesome NoneBot",
href: "https://awesome.nonebot.dev",
},
{
type: "link",

View File

@ -1,260 +0,0 @@
import clsx from "clsx";
import React, { useRef, useState } from "react";
import { ChromePicker } from "react-color";
import { usePagination } from "react-use-pagination";
import adapters from "../../static/adapters.json";
import { Tag, useFilteredObjs } from "../libs/store";
import Card from "./Card";
import Modal from "./Modal";
import ModalAction from "./ModalAction";
import ModalContent from "./ModalContent";
import ModalTitle from "./ModalTitle";
import Paginate from "./Paginate";
import TagComponent from "./Tag";
export default function Adapter(): JSX.Element {
const [modalOpen, setModalOpen] = useState<boolean>(false);
const {
filter,
setFilter,
filteredObjs: filteredAdapters,
} = useFilteredObjs(adapters);
const props = usePagination({
totalItems: filteredAdapters.length,
initialPageSize: 10,
});
const { startIndex, endIndex } = props;
const currentAdapters = filteredAdapters.slice(startIndex, endIndex + 1);
const [form, setForm] = useState<{
name: string;
desc: string;
projectLink: string;
moduleName: string;
homepage: string;
}>({ name: "", desc: "", projectLink: "", moduleName: "", homepage: "" });
const ref = useRef<HTMLInputElement>(null);
const [tags, setTags] = useState<Tag[]>([]);
const [label, setLabel] = useState<string>("");
const [color, setColor] = useState<string>("#ea5252");
const urlEncode = (str: string) =>
encodeURIComponent(str).replace(/%2B/gi, "+");
const onSubmit = () => {
setModalOpen(false);
const queries: { key: string; value: string }[] = [
{ key: "template", value: "adapter_publish.yml" },
{ key: "title", value: form.name && `Adapter: ${form.name}` },
{ key: "labels", value: "Adapter" },
{ key: "name", value: form.name },
{ key: "description", value: form.desc },
{ key: "pypi", value: form.projectLink },
{ key: "module", value: form.moduleName },
{ key: "homepage", value: form.homepage },
{ key: "tags", value: JSON.stringify(tags) },
];
const urlQueries = queries
.filter((query) => !!query.value)
.map((query) => `${query.key}=${urlEncode(query.value)}`)
.join("&");
window.open(`https://github.com/nonebot/nonebot2/issues/new?${urlQueries}`);
};
const onChange = (event) => {
const target = event.target;
const value = target.type === "checkbox" ? target.checked : target.value;
const name = target.name;
setForm({
...form,
[name]: value,
});
event.preventDefault();
};
const onChangeLabel = (event) => {
setLabel(event.target.value);
};
const onChangeColor = (color) => {
setColor(color.hex);
};
const validateTag = () => {
return label.length >= 1 && label.length <= 10;
};
const newTag = () => {
if (tags.length >= 3) {
return;
}
if (validateTag()) {
const tag = { label, color };
setTags([...tags, tag]);
}
};
const delTag = (index: number) => {
setTags(tags.filter((_, i) => i !== index));
};
const insertTagType = (text: string) => {
setLabel(text + label);
ref.current.value = text + label;
};
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4 px-4">
<input
className="w-full px-4 py-2 border rounded-full bg-light-nonepress-100 dark:bg-dark-nonepress-100"
value={filter}
placeholder="搜索适配器"
onChange={(event) => setFilter(event.target.value)}
/>
<button
className="w-full rounded-lg bg-hero text-white"
onClick={() => setModalOpen(true)}
>
</button>
</div>
<div className="grid grid-cols-1 p-4">
<Paginate {...props} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 px-4">
{currentAdapters.map((adapter, index) => (
<Card
key={index}
{...adapter}
action={`nb adapter install ${adapter.project_link}`}
actionDisabled={!adapter.project_link}
/>
))}
</div>
<div className="grid grid-cols-1 p-4">
<Paginate {...props} />
</div>
<Modal active={modalOpen} setActive={setModalOpen}>
<ModalTitle title={"适配器信息"} />
<ModalContent>
<form onSubmit={onSubmit}>
<div className="grid grid-cols-1 gap-4 p-4">
<label className="flex flex-wrap">
<span className="mr-2">:</span>
<input
type="text"
name="name"
maxLength={20}
className="px-2 flex-grow rounded bg-light-nonepress-200 dark:bg-dark-nonepress-200"
onChange={onChange}
/>
</label>
<label className="flex flex-wrap">
<span className="mr-2">:</span>
<input
type="text"
name="desc"
className="px-2 flex-grow rounded bg-light-nonepress-200 dark:bg-dark-nonepress-200"
onChange={onChange}
/>
</label>
<label className="flex flex-wrap">
<span className="mr-2">PyPI :</span>
<input
type="text"
name="projectLink"
className="px-2 flex-grow rounded bg-light-nonepress-200 dark:bg-dark-nonepress-200"
onChange={onChange}
/>
</label>
<label className="flex flex-wrap">
<span className="mr-2">import :</span>
<input
type="text"
name="moduleName"
className="px-2 flex-grow rounded bg-light-nonepress-200 dark:bg-dark-nonepress-200"
onChange={onChange}
/>
</label>
<label className="flex flex-wrap">
<span className="mr-2">/:</span>
<input
type="text"
name="homepage"
className="px-2 flex-grow rounded bg-light-nonepress-200 dark:bg-dark-nonepress-200"
onChange={onChange}
/>
</label>
</div>
</form>
<div className="px-4">
<label className="flex flex-wrap">
<span className="mr-2">:</span>
{tags.map((tag, index) => (
<TagComponent
key={index}
{...tag}
className="cursor-pointer"
onClick={() => delTag(index)}
/>
))}
</label>
</div>
<div className="px-4 pt-4">
<input
ref={ref}
type="text"
className="px-2 flex-grow rounded bg-light-nonepress-200 dark:bg-dark-nonepress-200"
onChange={onChangeLabel}
/>
<ChromePicker
className="mt-2"
color={color}
disableAlpha={true}
onChangeComplete={onChangeColor}
/>
<div className="flex flex-wrap mt-2 items-center">
<span className="mr-2">Type:</span>
<button
className="px-2 h-9 min-w-[64px] rounded text-hero hover:bg-hero hover:bg-opacity-[.08]"
onClick={() => insertTagType("a:")}
>
Adapter
</button>
<button
className="px-2 h-9 min-w-[64px] rounded text-hero hover:bg-hero hover:bg-opacity-[.08]"
onClick={() => insertTagType("t:")}
>
Topic
</button>
</div>
<div className="flex mt-2">
<TagComponent label={label} color={color} />
<button
className={clsx(
"px-2 h-9 min-w-[64px] rounded text-hero hover:bg-hero hover:bg-opacity-[.08]",
{ "pointer-events-none opacity-60": !validateTag() }
)}
onClick={newTag}
>
</button>
</div>
</div>
</ModalContent>
<ModalAction>
<button
className="px-2 h-9 min-w-[64px] rounded text-hero hover:bg-hero hover:bg-opacity-[.08]"
onClick={() => setModalOpen(false)}
>
</button>
<button
className="ml-2 px-2 h-9 min-w-[64px] rounded text-hero hover:bg-hero hover:bg-opacity-[.08]"
onClick={onSubmit}
>
</button>
</ModalAction>
</Modal>
</>
);
}

View File

@ -1,6 +1,7 @@
import * as AsciinemaPlayer from "asciinema-player";
import React, { useEffect, useRef } from "react";
import * as AsciinemaPlayer from "asciinema-player";
export type AsciinemaOptions = {
cols: number;
rows: number;
@ -16,7 +17,7 @@ export type AsciinemaOptions = {
fontSize: string;
};
export type AsciinemaProps = {
export type Props = {
url: string;
options?: Partial<AsciinemaOptions>;
};
@ -24,12 +25,12 @@ export type AsciinemaProps = {
export default function AsciinemaContainer({
url,
options = {},
}: AsciinemaProps): JSX.Element {
}: Props): JSX.Element {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
AsciinemaPlayer.create(url, ref.current, options);
}, []);
}, [url, options]);
return <div ref={ref} className="not-prose w-full max-w-full my-4"></div>;
return <div ref={ref} className="not-prose ap-container"></div>;
}

View File

@ -1,15 +1,24 @@
import "asciinema-player/dist/bundle/asciinema-player.css";
import "./styles.css";
import React from "react";
import "asciinema-player/dist/bundle/asciinema-player.css";
import BrowserOnly from "@docusaurus/BrowserOnly";
export default function Asciinema(props): JSX.Element {
import "./styles.css";
import type { Props } from "./container";
export type { Props } from "./container";
export default function Asciinema(props: Props): JSX.Element {
return (
<BrowserOnly fallback={<div></div>}>
<BrowserOnly
fallback={
<a href={props.url} title="Asciinema video player">
Asciinema cast
</a>
}
>
{() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const AsciinemaContainer = require("./container.tsx").default;
return <AsciinemaContainer {...props} />;
}}

View File

@ -1,3 +1,7 @@
.asciinema-player svg {
display: inline-block;
.ap-player svg {
@apply inline-block;
}
.ap-container {
@apply w-full my-4;
}

View File

@ -1,233 +0,0 @@
import clsx from "clsx";
import React, { useRef, useState } from "react";
import { ChromePicker } from "react-color";
import { usePagination } from "react-use-pagination";
import bots from "../../static/bots.json";
import { Tag, useFilteredObjs } from "../libs/store";
import Card from "./Card";
import Modal from "./Modal";
import ModalAction from "./ModalAction";
import ModalContent from "./ModalContent";
import ModalTitle from "./ModalTitle";
import Paginate from "./Paginate";
import TagComponent from "./Tag";
export default function Bot(): JSX.Element {
const [modalOpen, setModalOpen] = useState<boolean>(false);
const {
filter,
setFilter,
filteredObjs: filteredBots,
} = useFilteredObjs(bots);
const props = usePagination({
totalItems: filteredBots.length,
initialPageSize: 10,
});
const { startIndex, endIndex } = props;
const currentBots = filteredBots.slice(startIndex, endIndex + 1);
const [form, setForm] = useState<{
name: string;
desc: string;
homepage: string;
}>({ name: "", desc: "", homepage: "" });
const ref = useRef<HTMLInputElement>(null);
const [tags, setTags] = useState<Tag[]>([]);
const [label, setLabel] = useState<string>("");
const [color, setColor] = useState<string>("#ea5252");
const urlEncode = (str: string) =>
encodeURIComponent(str).replace(/%2B/gi, "+");
const onSubmit = () => {
setModalOpen(false);
const queries: { key: string; value: string }[] = [
{ key: "template", value: "bot_publish.yml" },
{ key: "title", value: form.name && `Bot: ${form.name}` },
{ key: "labels", value: "Bot" },
{ key: "name", value: form.name },
{ key: "description", value: form.desc },
{ key: "homepage", value: form.homepage },
{ key: "tags", value: JSON.stringify(tags) },
];
const urlQueries = queries
.filter((query) => !!query.value)
.map((query) => `${query.key}=${urlEncode(query.value)}`)
.join("&");
window.open(`https://github.com/nonebot/nonebot2/issues/new?${urlQueries}`);
};
const onChange = (event) => {
const target = event.target;
const value = target.type === "checkbox" ? target.checked : target.value;
const name = target.name;
setForm({
...form,
[name]: value,
});
event.preventDefault();
};
const onChangeLabel = (event) => {
setLabel(event.target.value);
};
const onChangeColor = (color) => {
setColor(color.hex);
};
const validateTag = () => {
return label.length >= 1 && label.length <= 10;
};
const newTag = () => {
if (tags.length >= 3) {
return;
}
if (validateTag()) {
const tag = { label, color };
setTags([...tags, tag]);
}
};
const delTag = (index: number) => {
setTags(tags.filter((_, i) => i !== index));
};
const insertTagType = (text: string) => {
setLabel(text + label);
ref.current.value = text + label;
};
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4 px-4">
<input
className="w-full px-4 py-2 border rounded-full bg-light-nonepress-100 dark:bg-dark-nonepress-100"
value={filter}
placeholder="搜索机器人"
onChange={(event) => setFilter(event.target.value)}
/>
<button
className="w-full rounded-lg bg-hero text-white"
onClick={() => setModalOpen(true)}
>
</button>
</div>
<div className="grid grid-cols-1 p-4">
<Paginate {...props} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 px-4">
{currentBots.map((bot, index) => (
<Card key={index} {...bot} />
))}
</div>
<div className="grid grid-cols-1 p-4">
<Paginate {...props} />
</div>
<Modal active={modalOpen} setActive={setModalOpen}>
<ModalTitle title={"机器人信息"} />
<ModalContent>
<form onSubmit={onSubmit}>
<div className="grid grid-cols-1 gap-4 p-4">
<label className="flex flex-wrap">
<span className="mr-2">:</span>
<input
type="text"
name="name"
maxLength={20}
className="px-2 flex-grow rounded bg-light-nonepress-200 dark:bg-dark-nonepress-200"
onChange={onChange}
/>
</label>
<label className="flex flex-wrap">
<span className="mr-2">:</span>
<input
type="text"
name="desc"
className="px-2 flex-grow rounded bg-light-nonepress-200 dark:bg-dark-nonepress-200"
onChange={onChange}
/>
</label>
<label className="flex flex-wrap">
<span className="mr-2">/:</span>
<input
type="text"
name="homepage"
className="px-2 flex-grow rounded bg-light-nonepress-200 dark:bg-dark-nonepress-200"
onChange={onChange}
/>
</label>
</div>
</form>
<div className="px-4">
<label className="flex flex-wrap">
<span className="mr-2">:</span>
{tags.map((tag, index) => (
<TagComponent
key={index}
{...tag}
className="cursor-pointer"
onClick={() => delTag(index)}
/>
))}
</label>
</div>
<div className="px-4 pt-4">
<input
ref={ref}
type="text"
className="px-2 flex-grow rounded bg-light-nonepress-200 dark:bg-dark-nonepress-200"
onChange={onChangeLabel}
/>
<ChromePicker
className="mt-2"
color={color}
disableAlpha={true}
onChangeComplete={onChangeColor}
/>
<div className="flex flex-wrap mt-2 items-center">
<span className="mr-2">Type:</span>
<button
className="px-2 h-9 min-w-[64px] rounded text-hero hover:bg-hero hover:bg-opacity-[.08]"
onClick={() => insertTagType("a:")}
>
Adapter
</button>
<button
className="px-2 h-9 min-w-[64px] rounded text-hero hover:bg-hero hover:bg-opacity-[.08]"
onClick={() => insertTagType("t:")}
>
Topic
</button>
</div>
<div className="flex mt-2">
<TagComponent label={label} color={color} />
<button
className={clsx(
"px-2 h-9 min-w-[64px] rounded text-hero hover:bg-hero hover:bg-opacity-[.08]",
{ "pointer-events-none opacity-60": !validateTag() }
)}
onClick={newTag}
>
</button>
</div>
</div>
</ModalContent>
<ModalAction>
<button
className="px-2 h-9 min-w-[64px] rounded text-hero hover:bg-hero hover:bg-opacity-[.08]"
onClick={() => setModalOpen(false)}
>
</button>
<button
className="ml-2 px-2 h-9 min-w-[64px] rounded text-hero hover:bg-hero hover:bg-opacity-[.08]"
onClick={onSubmit}
>
</button>
</ModalAction>
</Modal>
</>
);
}

View File

@ -1,112 +0,0 @@
import clsx from "clsx";
import copy from "copy-to-clipboard";
import React, { useState } from "react";
import Link from "@docusaurus/Link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { Obj } from "../../libs/store";
import Tag from "../Tag";
export default function Card({
module_name,
project_link,
name,
desc,
author,
homepage,
tags,
is_official,
action,
actionDisabled = false,
actionLabel = "点击复制安装命令",
}: Obj & {
action?: string;
actionLabel?: string;
actionDisabled?: boolean;
}): JSX.Element {
const isGithub = /^https:\/\/github.com\/[^/]+\/[^/]+/.test(homepage);
const [copied, setCopied] = useState<boolean>(false);
const copyAction = () => {
copy(action);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="block max-w-full px-4 border-2 rounded-lg outline-none no-underline bg-light-nonepress-100 dark:bg-dark-nonepress-100 border-light-nonepress-200 dark:border-dark-nonepress-200 shadow-md shadow-light-nonepress-300 dark:shadow-dark-nonepress-300">
<div className="flex justify-between pt-4 text-lg font-medium">
<span>
{name}
{is_official && (
<FontAwesomeIcon
icon={["fas", "check-circle"]}
className="text-green-600 ml-2"
/>
)}
</span>
{homepage && (
<Link
href={homepage}
className="text-black dark:text-white opacity-60 hover:text-hero dark:hover:text-white hover:opacity-100"
>
{isGithub ? (
<FontAwesomeIcon icon={["fab", "github"]} />
) : (
<FontAwesomeIcon icon={["fas", "link"]} />
)}
</Link>
)}
</div>
{tags && (
<div className="pt-2 pb-4">
{tags.map((tag, index) => (
<Tag key={index} {...tag} />
))}
</div>
)}
{/* FIXME: full height */}
{desc && (
<div className="pb-4 text-sm font-normal opacity-60">{desc}</div>
)}
{project_link && (
<div className="my-2 text-sm font-normal opacity-60 font-mono">
<FontAwesomeIcon icon={["fas", "cube"]} className="mr-2" />
{project_link}
</div>
)}
{module_name && (
<div className="my-2 text-sm font-normal opacity-60 font-mono">
<FontAwesomeIcon icon={["fas", "fingerprint"]} className="mr-2" />
{module_name}
</div>
)}
{/* TODO: add user avatar */}
{/* link: https://github.com/<username>.png */}
{author && (
<div className="my-2 text-sm font-normal opacity-60 font-mono">
<FontAwesomeIcon icon={["fas", "user"]} className="mr-2" />
{author}
</div>
)}
{action && actionLabel && (
<button
className={clsx(
"my-2 text-sm py-2 w-full rounded select-none bg-light-nonepress-200 dark:bg-dark-nonepress-200 active:bg-light-nonepress-300 active:dark:bg-dark-nonepress-300",
{ "opacity-60 pointer-events-none": actionDisabled }
)}
onClick={copyAction}
>
<span className="flex grow items-center justify-center">
{copied ? "复制成功" : actionLabel}
<FontAwesomeIcon
icon={["fas", copied ? "check-circle" : "copy"]}
className="ml-2"
/>
</span>
</button>
)}
</div>
);
}

View File

@ -1,54 +0,0 @@
import React from "react";
import { usePagination } from "react-use-pagination";
import drivers from "../../static/drivers.json";
import { useFilteredObjs } from "../libs/store";
import Card from "./Card";
import Paginate from "./Paginate";
export default function Driver(): JSX.Element {
const {
filter,
setFilter,
filteredObjs: filteredDrivers,
} = useFilteredObjs(drivers);
const props = usePagination({
totalItems: filteredDrivers.length,
initialPageSize: 10,
});
const { startIndex, endIndex } = props;
const currentDrivers = filteredDrivers.slice(startIndex, endIndex + 1);
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4 px-4">
<input
className="w-full px-4 py-2 border rounded-full bg-light-nonepress-100 dark:bg-dark-nonepress-100"
value={filter}
placeholder="搜索驱动器"
onChange={(event) => setFilter(event.target.value)}
/>
<button className="w-full rounded-lg bg-hero text-white opacity-60">
</button>
</div>
<div className="grid grid-cols-1 p-4">
<Paginate {...props} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 px-4">
{currentDrivers.map((driver, index) => (
<Card
key={index}
{...driver}
action={`nb driver install ${driver.project_link}`}
actionDisabled={!driver.project_link}
/>
))}
</div>
<div className="grid grid-cols-1 p-4">
<Paginate {...props} />
</div>
</>
);
}

View File

@ -1,101 +0,0 @@
import React, { PropsWithChildren } from "react";
import Link from "@docusaurus/Link";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Logo from "@theme/Logo";
export function Hero(): JSX.Element {
const { siteConfig } = useDocusaurusContext();
return (
<div className="flex flex-wrap p-16 mx-auto max-w-7xl h-screen relative px-4 sm:p-24">
<div className="flex-grow self-center text-center">
<Logo imageClassName="max-h-48" />
<h1 className="text-5xl tracking-tight font-light sm:text-5xl md:text-5xl">
<span className="text-hero">N</span>one
<span className="text-hero">B</span>ot
</h1>
<p className="my-3 max-w-md mx-auto text-sm font-medium tracking-wide uppercase opacity-70 md:mt-5 md:max-w-3xl">
{siteConfig.tagline}
</p>
<div className="mt-8">
<Link
to="/docs/"
className="inline-block bg-hero text-white font-bold rounded-lg px-6 py-3"
>
使 <FontAwesomeIcon icon={["fas", "chevron-right"]} />
</Link>
</div>
</div>
<div className="absolute flex-grow flex items-center justify-between bottom-0 right-0 w-full">
<div className="mx-auto self-start animate-bounce">
<FontAwesomeIcon
className="text-4xl text-hero"
icon={["fas", "angle-down"]}
/>
</div>
</div>
</div>
);
}
export type Feature = {
readonly title: string;
readonly tagline?: string;
readonly description?: string;
readonly annotaion?: string;
};
export function HeroFeature(props: PropsWithChildren<Feature>): JSX.Element {
const { title, tagline, description, annotaion, children } = props;
return (
<>
<p className="mt-3 mb-3 max-w-md mx-auto text-sm font-medium tracking-wide uppercase opacity-70 md:mt-5 md:max-w-3xl">
{tagline}
</p>
<h1 className="font-mono font-light text-4xl tracking-tight sm:text-5xl md:text-5xl text-hero">
{title}
</h1>
<p className="mt-10 mb-6">{description}</p>
{children}
<p className="text-sm italic opacity-70">{annotaion}</p>
</>
);
}
export function HeroFeatureSingle(
props: PropsWithChildren<Feature>
): JSX.Element {
return (
<div className="max-w-7xl mx-auto py-16 px-4 text-center md:px-16">
<HeroFeature {...props} />
</div>
);
}
export function HeroFeatureDouble(
props: PropsWithChildren<{ features: [Feature, Feature] }>
): JSX.Element {
const {
features: [feature1, feature2],
children,
} = props;
let children1, children2;
if (Array.isArray(children) && children.length === 2) {
[children1, children2] = children;
}
return (
<div className="max-w-7xl mx-auto py-16 px-4 md:grid md:grid-cols-2 md:gap-6 md:px-16">
<div className="pb-16 text-center md:pb-0">
<HeroFeature {...feature1} children={children1} />
</div>
<div className="text-center">
<HeroFeature {...feature2} children={children2} />
</div>
</div>
);
}

View File

@ -0,0 +1,174 @@
import React from "react";
import CodeBlock from "@theme/CodeBlock";
export type Feature = {
title: string;
tagline?: string;
description?: string;
annotaion?: string;
children?: React.ReactNode;
};
export function HomeFeature({
title,
tagline,
description,
annotaion,
children,
}: Feature): JSX.Element {
return (
<div className="flex flex-col items-center justify-center p-4">
<p className="text-sm text-base-content/70 font-medium tracking-wide uppercase">
{tagline}
</p>
<h1 className="mt-3 font-mono font-light text-4xl tracking-tight sm:text-5xl md:text-5xl text-primary">
{title}
</h1>
<p className="mt-10 mb-6">{description}</p>
{children}
<p className="text-sm italic text-base-content/70">{annotaion}</p>
</div>
);
}
function HomeFeatureSingleColumn(props: Feature): JSX.Element {
return (
<div className="grid mx-auto px-4 py-8 md:px-16">
<HomeFeature {...props} />
</div>
);
}
function HomeFeatureDoubleColumn({
features: [feature1, feature2],
children,
}: {
features: [Feature, Feature];
children?: [React.ReactNode, React.ReactNode];
}): JSX.Element {
const [children1, children2] = children ?? [];
return (
<div className="grid gap-x-6 gap-y-8 grid-cols-1 lg:grid-cols-2 max-w-7xl mx-auto px-4 py-8 md:px-16">
<HomeFeature {...feature1}>{children1}</HomeFeature>
<HomeFeature {...feature2}>{children2}</HomeFeature>
</div>
);
}
function HomeFeatures(): JSX.Element {
return (
<>
<HomeFeatureSingleColumn
title="开箱即用"
tagline="out of box"
description="使用 NB-CLI 快速构建属于你的机器人"
>
<CodeBlock
title="Installation"
language="bash"
className="home-codeblock"
>
{[
"$ pipx install nb-cli",
"$ nb",
// "d8b db .d88b. d8b db d88888b d8888b. .d88b. d888888b",
// "888o 88 .8P Y8. 888o 88 88' 88 `8D .8P Y8. `~~88~~'",
// "88V8o 88 88 88 88V8o 88 88ooooo 88oooY' 88 88 88",
// "88 V8o88 88 88 88 V8o88 88~~~~~ 88~~~b. 88 88 88",
// "88 V888 `8b d8' 88 V888 88. 88 8D `8b d8' 88",
// "VP V8P `Y88P' VP V8P Y88888P Y8888P' `Y88P' YP",
"[?] What do you want to do?",
" Create a NoneBot project.",
" Run the bot in current folder.",
" Manage bot driver.",
" Manage bot adapters.",
" Manage bot plugins.",
" ...",
].join("\n")}
</CodeBlock>
</HomeFeatureSingleColumn>
<HomeFeatureDoubleColumn
features={[
{
title: "插件系统",
tagline: "plugin system",
description: "插件化开发,模块化管理",
},
{
title: "跨平台支持",
tagline: "cross-platform support",
description: "支持多种平台,以及多样的事件响应方式",
},
]}
>
<CodeBlock title="" language="python" className="home-codeblock">
{[
"import nonebot",
"# 加载一个插件",
'nonebot.load_plugin("path.to.your.plugin")',
"# 从文件夹加载插件",
'nonebot.load_plugins("plugins")',
"# 从配置文件加载多个插件",
'nonebot.load_from_json("plugins.json")',
'nonebot.load_from_toml("pyproject.toml")',
].join("\n")}
</CodeBlock>
<CodeBlock title="" language="python" className="home-codeblock">
{[
"import nonebot",
"# OneBot",
"from nonebot.adapters.onebot.v11 import Adapter as OneBotAdapter",
"# QQ 频道",
"from nonebot.adapters.qqguild import Adapter as QQGuildAdapter",
"driver = nonebot.get_driver()",
"driver.register_adapter(OneBotAdapter)",
"driver.register_adapter(QQGuildAdapter)",
].join("\n")}
</CodeBlock>
</HomeFeatureDoubleColumn>
<HomeFeatureDoubleColumn
features={[
{
title: "异步开发",
tagline: "asynchronous first",
description: "异步优先式开发,提高运行效率",
},
{
title: "依赖注入",
tagline: "builtin dependency injection system",
description: "简单清晰的依赖注入系统,内置依赖函数减少用户代码",
},
]}
>
<CodeBlock title="" language="python" className="home-codeblock">
{[
"from nonebot import on_message",
"# 注册一个消息响应器",
"matcher = on_message()",
"# 注册一个消息处理器",
"# 并重复收到的消息",
"@matcher.handle()",
"async def handler(event: Event) -> None:",
" await matcher.send(event.get_message())",
].join("\n")}
</CodeBlock>
<CodeBlock title="" language="python" className="home-codeblock">
{[
"from nonebot import on_command",
"# 注册一个命令响应器",
'matcher = on_command("help", alias={"帮助"})',
"# 注册一个命令处理器",
"# 通过依赖注入获得命令名以及参数",
"@matcher.handle()",
"async def handler(cmd = Command(), arg = CommandArg()) -> None:",
" await matcher.finish()",
].join("\n")}
</CodeBlock>
</HomeFeatureDoubleColumn>
</>
);
}
export default React.memo(HomeFeatures);

View File

@ -0,0 +1,69 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import Link from "@docusaurus/Link";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNonepressThemeConfig } from "@nullbot/docusaurus-theme-nonepress/client";
// @ts-expect-error: we need to make package have type: module
import copy from "copy-text-to-clipboard";
import IconCopy from "@theme/Icon/Copy";
import IconSuccess from "@theme/Icon/Success";
function HomeHeroInstallButton(): JSX.Element {
const code = "pipx run nb-cli create";
const [isCopied, setIsCopied] = useState(false);
const copyTimeout = useRef<number | undefined>(undefined);
const handleCopyCode = useCallback(() => {
copy(code);
setIsCopied(true);
copyTimeout.current = window.setTimeout(() => {
setIsCopied(false);
}, 1500);
}, [code]);
useEffect(() => () => window.clearTimeout(copyTimeout.current), []);
return (
<button
className="btn no-animation home-hero-copy"
onClick={handleCopyCode}
>
<code>$ {code}</code>
{isCopied ? <IconSuccess className="text-success" /> : <IconCopy />}
</button>
);
}
function HomeHero(): JSX.Element {
const {
siteConfig: { tagline },
} = useDocusaurusContext();
const {
navbar: { logo },
} = useNonepressThemeConfig();
return (
<div className="home-hero">
<img src={logo!.src} alt={logo!.alt} className="home-hero-logo" />
<h1 className="home-hero-title">
<span className="text-primary">None</span>
Bot
</h1>
<p className="home-hero-tagline">{tagline}</p>
<div className="home-hero-actions">
<Link to="/docs/" className="btn btn-primary">
使 <FontAwesomeIcon icon={["fas", "chevron-right"]} />
</Link>
<HomeHeroInstallButton />
</div>
<div className="home-hero-next">
<FontAwesomeIcon icon={["fas", "angle-down"]} />
</div>
</div>
);
}
export default React.memo(HomeHero);

View File

@ -0,0 +1,14 @@
import React from "react";
import "./styles.css";
import HomeFeatures from "./Feature";
import HomeHero from "./Hero";
export default function HomeContent(): JSX.Element {
return (
<div className="home-container">
<HomeHero />
<HomeFeatures />
</div>
);
}

View File

@ -0,0 +1,41 @@
.home {
&-container {
@apply -mt-16;
}
&-hero {
@apply relative flex flex-col items-center justify-center gap-4 h-screen;
&-logo {
@apply h-48 w-auto;
}
&-title {
@apply text-5xl font-normal tracking-tight;
}
&-tagline {
@apply text-sm font-medium uppercase tracking-wide text-base-content/70;
}
&-actions {
@apply flex flex-col sm:flex-row gap-4;
}
&-copy {
@apply font-normal normal-case text-base-content/70;
}
&-next {
@apply absolute bottom-4;
& svg {
@apply animate-bounce text-primary text-4xl;
}
}
}
&-codeblock {
@apply inline-block !max-w-[600px];
}
}

View File

@ -1,40 +1,56 @@
import clsx from "clsx";
import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Logo from "@theme/Logo";
import clsx from "clsx";
import styles from "./styles.module.css";
import useBaseUrl from "@docusaurus/useBaseUrl";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNonepressThemeConfig } from "@nullbot/docusaurus-theme-nonepress/client";
import "./styles.css";
import ThemedImage from "@theme/ThemedImage";
export type Message = {
position?: "left" | "right";
msg: string;
position?: "left" | "right";
monospace?: boolean;
};
function MessageBox({
msg,
isRight,
}: {
msg: string;
isRight: boolean;
}): JSX.Element {
position = "left",
monospace = false,
}: Message): JSX.Element {
const {
navbar: { logo },
} = useNonepressThemeConfig();
const sources = {
light: useBaseUrl(logo!.src),
dark: useBaseUrl(logo!.srcDark || logo!.src),
};
const isRight = position === "right";
return (
<div
className={clsx(styles.message, {
[styles.messageRight]: isRight,
})}
>
{isRight ? (
<div className={clsx("bg-cyan-600 text-base", styles.messageAvatar)}>
<FontAwesomeIcon icon={["fas", "user"]} />
<div className={clsx("chat", isRight ? "chat-end" : "chat-start")}>
<div className="chat-image avatar">
<div
className={clsx(
"messenger-chat-avatar",
isRight && "messenger-chat-avatar-user"
)}
>
{isRight ? (
<FontAwesomeIcon icon={["fas", "user"]} />
) : (
<ThemedImage sources={sources} />
)}
</div>
) : (
<div className={clsx("transparent", styles.messageAvatar)}>
<Logo imageClassName="h-full w-full" disabled />
</div>
)}
</div>
<div
className={clsx(styles.messageBox, { "order-first": isRight })}
className={clsx(
"chat-bubble messenger-chat-bubble",
monospace && "font-mono"
)}
dangerouslySetInnerHTML={{
__html: msg.replace(/\n/g, "<br/>").replace(/ /g, "&nbsp;"),
}}
@ -48,62 +64,55 @@ export default function Messenger({
}: {
msgs?: Message[];
}): JSX.Element {
const isRight = (msg: Message): boolean => msg.position === "right";
return (
<div className="block w-full max-w-full my-4 rounded shadow-md outline-none no-underline bg-light-nonepress-100 dark:bg-dark-nonepress-100">
<header className="flex items-center h-12 px-4 bg-blue-500 text-white rounded-t-[inherit]">
<div className="text-left text-base grow">
<div className="messenger-container">
<header className="messenger-title">
<div className="messenger-title-back">
<FontAwesomeIcon icon={["fas", "chevron-left"]} />
</div>
<div className="flex-initial grow-0">
<span className="text-xl font-bold">NoneBot</span>
<div className="messenger-title-name">
<span>NoneBot</span>
</div>
<div className="text-right text-base grow">
<FontAwesomeIcon icon={["fas", "user"]} />
<div className="messenger-title-more">
<FontAwesomeIcon icon={["fas", "bars"]} />
</div>
</header>
<div className="p-3 min-h-[150px]">
<div className="messenger-chat">
{msgs.map((msg, i) => (
<MessageBox msg={msg.msg} isRight={isRight(msg)} key={i} />
<MessageBox {...msg} key={i} />
))}
</div>
<div className="px-3">
<div className="flex flex-row items-center">
<div className="flex-1 p-1 max-w-full">
<div className="messenger-footer">
<div className="messenger-footer-action">
<div className="messenger-footer-action-input">
<input
className="w-full rounded bg-light dark:bg-dark focus:outline-none focus:ring focus:border-blue-500"
className="input input-xs input-bordered input-info w-full"
readOnly
/>
</div>
<div className="flex-initial grow-0 w-fit">
<button
className={clsx(
"h-7 px-3 rounded-full bg-blue-500 text-white",
styles.messageSendButton
)}
>
<span></span>
<div className="messenger-footer-action-send">
<button className="btn btn-xs btn-info no-animation text-white">
</button>
</div>
</div>
<div className="flex flex-row items-center text-center text-base text-gray-600">
<div className="p-1 shrink-0 grow-0 basis-1/6">
<div className="messenger-footer-tools">
<div>
<FontAwesomeIcon icon={["fas", "microphone"]} />
</div>
<div className="p-1 shrink-0 grow-0 basis-1/6">
<div>
<FontAwesomeIcon icon={["fas", "image"]} />
</div>
<div className="p-1 shrink-0 grow-0 basis-1/6">
<div>
<FontAwesomeIcon icon={["fas", "camera"]} />
</div>
<div className="p-1 shrink-0 grow-0 basis-1/6">
<div>
<FontAwesomeIcon icon={["fas", "wallet"]} />
</div>
<div className="p-1 shrink-0 grow-0 basis-1/6">
<div>
<FontAwesomeIcon icon={["fas", "smile-wink"]} />
</div>
<div className="p-1 shrink-0 grow-0 basis-1/6">
<div>
<FontAwesomeIcon icon={["fas", "plus-circle"]} />
</div>
</div>

View File

@ -0,0 +1,66 @@
.messenger {
&-container {
@apply block w-full my-4 overflow-hidden;
@apply rounded-lg outline-none bg-base-200;
@apply transition-[background-color] duration-500;
}
&-title {
@apply flex items-center h-12 px-4 bg-info text-white;
&-back {
@apply text-left text-base grow;
}
&-name {
@apply flex-initial grow-0 text-xl font-bold;
}
&-more {
@apply text-right text-base grow;
}
}
&-chat {
@apply p-4 min-h-[150px];
&-avatar {
@apply !flex items-center justify-center;
@apply w-10 rounded-full;
&-user {
@apply bg-info text-white;
}
}
&-bubble {
@apply bg-base-100 text-base-content [word-break:break-word];
@apply transition-[color,background-color] duration-500;
}
}
&-footer {
@apply px-4;
&-action {
@apply flex items-center gap-2;
&-input {
@apply flex-1;
& > input {
@apply transition-[color,background-color] duration-500;
}
}
&-send {
@apply flex-initial w-fit;
}
}
&-tools {
@apply grid grid-cols-6 items-center py-1;
@apply text-center text-base text-base-content/60;
}
}
}

View File

@ -1,40 +0,0 @@
.message {
@apply flex flex-row flex-wrap justify-start;
}
.messageRight {
@apply !justify-end;
}
.message .messageAvatar {
@apply relative inline-flex items-center justify-center text-center align-middle h-9 w-9 rounded-full;
}
.message .messageBox {
@apply relative w-fit max-w-[55%] px-2 py-[0.375rem] mx-3 my-2 rounded-lg bg-light;
}
:global(.dark) .message .messageBox {
@apply !bg-dark;
}
.message .messageBox::after {
content: "";
border-bottom: 7px solid;
@apply absolute top-0 right-full w-2 h-3 text-light rounded-bl-lg;
}
:global(.dark) .message .messageBox::after {
@apply !text-dark;
}
.message.messageRight .messageBox::after {
@apply !left-full !right-auto !rounded-bl-[0] !rounded-br-lg;
}
.messageSendButton {
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}

View File

@ -1,41 +0,0 @@
import clsx from "clsx";
import React from "react";
export default function Modal({
active,
setActive,
children,
}: {
active: boolean;
setActive: (active: boolean) => void;
children: React.ReactNode;
}): JSX.Element {
return (
<>
{/* overlay */}
<div
className={clsx(
"fixed top-0 bottom-0 left-0 right-0 flex items-center justify-center transition z-[200] pointer-events-auto",
{ "!hidden": !active }
)}
onClick={() => setActive(false)}
>
<div className="absolute top-0 bottom-0 left-0 right-0 h-full w-full bg-gray-800 opacity-[.46]"></div>
</div>
{/* modal */}
<div
className={clsx(
"fixed top-0 left-0 flex items-center justify-center h-full w-full z-[201] pointer-events-none",
{ "!hidden": !active }
)}
tabIndex={0}
>
<div className="w-full max-w-[600px] max-h-[90%] overflow-y-auto rounded shadow-lg m-6 z-[inherit] pointer-events-auto thin-scrollbar">
<div className="bg-light-nonepress-100 dark:bg-dark-nonepress-100">
{children}
</div>
</div>
</div>
</>
);
}

View File

@ -1,9 +0,0 @@
import React from "react";
export default function ModalAction({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return <div className="px-4 py-2 flex justify-end">{children}</div>;
}

View File

@ -1,9 +0,0 @@
import React from "react";
export default function ModalContent({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return <div className="px-6 pb-5 w-full">{children}</div>;
}

View File

@ -1,9 +0,0 @@
import React from "react";
export default function ModalTitle({ title }: { title: string }): JSX.Element {
return (
<div className="px-6 pt-4 pb-2 font-medium text-xl">
<span>{title}</span>
</div>
);
}

View File

@ -1,39 +1,55 @@
import React, { useCallback } from "react";
import clsx from "clsx";
import React, { useCallback, useState } from "react";
import { usePagination } from "react-use-pagination";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { usePagination } from "react-use-pagination";
import { useContentWidth } from "../../libs/width";
import styles from "./styles.module.css";
import "./styles.css";
const MAX_LENGTH = 7;
export type Props = Pick<
ReturnType<typeof usePagination>,
| "totalPages"
| "currentPage"
| "setNextPage"
| "setPreviousPage"
| "setPage"
| "previousEnabled"
| "nextEnabled"
> & {
className?: string;
};
export default function Paginate({
className,
totalPages,
currentPage,
setPreviousPage,
setNextPage,
setPage,
currentPage,
previousEnabled,
nextEnabled,
}: ReturnType<typeof usePagination>): JSX.Element {
const [containerElement, setContainerElement] = useState<HTMLElement | null>(
null
);
}: Props): JSX.Element {
// const [containerElement, setContainerElement] = useState<HTMLElement | null>(
// null
// );
const ref = useCallback(
(element: HTMLElement | null) => {
setContainerElement(element);
},
[setContainerElement]
);
// const ref = useCallback(
// (element: HTMLElement | null) => {
// setContainerElement(element);
// },
// [setContainerElement]
// );
const maxWidth = useContentWidth(
containerElement?.parentElement ?? undefined
);
const maxLength = Math.min(
(maxWidth && Math.floor(maxWidth / 50) - 2) || totalPages,
totalPages
);
// const maxWidth = useContentWidth(
// containerElement?.parentElement ?? undefined
// );
// const maxLength = Math.min(
// (maxWidth && Math.floor(maxWidth / 50) - 2) || totalPages,
// totalPages
// );
const range = useCallback((start: number, end: number) => {
const result = [];
@ -47,12 +63,12 @@ export default function Paginate({
const pages: (React.ReactNode | number)[] = [];
const ellipsis = <FontAwesomeIcon icon="ellipsis-h" />;
const even = maxLength % 2 === 0 ? 1 : 0;
const left = Math.floor(maxLength / 2);
const even = MAX_LENGTH % 2 === 0 ? 1 : 0;
const left = Math.floor(MAX_LENGTH / 2);
const right = totalPages - left + even + 1;
currentPage = currentPage + 1;
if (totalPages <= maxLength) {
if (totalPages <= MAX_LENGTH) {
pages.push(...range(1, totalPages));
} else if (currentPage > left && currentPage < right) {
const firstItem = 1;
@ -74,34 +90,44 @@ export default function Paginate({
}
return (
<nav role="navigation" aria-label="Pagination Navigation" ref={ref}>
<ul className={styles.container}>
<li
className={clsx(styles.li, { [styles.disabled]: !previousEnabled })}
>
<button className={styles.button} onClick={setPreviousPage}>
<FontAwesomeIcon icon="chevron-left" />
</button>
</li>
<nav
className={clsx("paginate-container", className)}
role="navigation"
aria-label="Pagination Navigation"
>
<button
className="paginate-button"
onClick={setPreviousPage}
disabled={!previousEnabled}
>
<FontAwesomeIcon icon={["fas", "chevron-left"]} />
</button>
<ul className="paginate-pager">
{pages.map((page, index) => (
<li className={styles.li} key={index}>
<button
className={clsx(styles.button, {
[styles.active]: page === currentPage,
"pointer-events-none": typeof page !== "number",
})}
onClick={() => typeof page === "number" && setPage(page - 1)}
>
{page}
</button>
<li
key={index}
className={clsx(
"paginate-button",
typeof page !== "number" && "ellipsis",
currentPage === page && "active"
)}
onClick={() =>
typeof page === "number" &&
currentPage !== page &&
setPage(page - 1)
}
>
{page}
</li>
))}
<li className={clsx(styles.li, { [styles.disabled]: !nextEnabled })}>
<button className={styles.button} onClick={setNextPage}>
<FontAwesomeIcon icon="chevron-right" />
</button>
</li>
</ul>
<button
className="paginate-button"
onClick={setNextPage}
disabled={!nextEnabled}
>
<FontAwesomeIcon icon={["fas", "chevron-right"]} />
</button>
</nav>
);
}

View File

@ -0,0 +1,30 @@
.paginate {
&-container {
@apply flex items-center justify-center gap-2;
}
&-button {
@apply flex items-center justify-center cursor-pointer select-none;
@apply w-8 h-8 text-sm leading-8 text-center bg-base-200 text-base-content;
&.ellipsis {
@apply !text-base-content cursor-default;
}
&.active {
@apply bg-primary !text-primary-content cursor-default;
}
&:hover {
@apply text-primary;
}
&:disabled {
@apply !text-base-content/80 cursor-not-allowed;
}
}
&-pager {
@apply flex items-center justify-center gap-2;
}
}

View File

@ -1,29 +0,0 @@
.container {
@apply w-full max-w-full inline-flex justify-center items-center m-0 pl-0 list-none select-none;
}
.li {
@apply flex items-center;
}
.button {
height: 34px;
width: auto;
min-width: 34px;
@apply m-1 px-1 border-2 rounded shadow-lg text-center;
@apply border-light-nonepress-200 shadow-light-nonepress-300;
@apply text-black bg-light-nonepress-100;
}
:global(.dark) .button {
@apply border-dark-nonepress-200 shadow-dark-nonepress-300;
@apply text-white bg-dark-nonepress-100;
}
.button.active {
@apply bg-hero text-white border-hero;
}
.disabled {
@apply opacity-60 pointer-events-none;
}

View File

@ -1,211 +0,0 @@
import clsx from "clsx";
import React, { useRef, useState } from "react";
import { ChromePicker } from "react-color";
import { usePagination } from "react-use-pagination";
import plugins from "../../static/plugins.json";
import { Tag, useFilteredObjs } from "../libs/store";
import Card from "./Card";
import Modal from "./Modal";
import ModalAction from "./ModalAction";
import ModalContent from "./ModalContent";
import ModalTitle from "./ModalTitle";
import Paginate from "./Paginate";
import TagComponent from "./Tag";
export default function Plugin(): JSX.Element {
const [modalOpen, setModalOpen] = useState<boolean>(false);
const {
filter,
setFilter,
filteredObjs: filteredPlugins,
} = useFilteredObjs(plugins);
const props = usePagination({
totalItems: filteredPlugins.length,
initialPageSize: 10,
});
const { startIndex, endIndex } = props;
const currentPlugins = filteredPlugins.slice(startIndex, endIndex + 1);
const [form, setForm] = useState<{
projectLink: string;
moduleName: string;
}>({ projectLink: "", moduleName: "" });
const ref = useRef<HTMLInputElement>(null);
const [tags, setTags] = useState<Tag[]>([]);
const [label, setLabel] = useState<string>("");
const [color, setColor] = useState<string>("#ea5252");
const urlEncode = (str: string) =>
encodeURIComponent(str).replace(/%2B/gi, "+");
const onSubmit = () => {
setModalOpen(false);
const queries: { key: string; value: string }[] = [
{ key: "template", value: "plugin_publish.yml" },
{
key: "title",
value: form.projectLink && `Plugin: ${form.projectLink}`,
},
{ key: "labels", value: "Plugin" },
{ key: "pypi", value: form.projectLink },
{ key: "module", value: form.moduleName },
{ key: "tags", value: JSON.stringify(tags) },
];
const urlQueries = queries
.filter((query) => !!query.value)
.map((query) => `${query.key}=${urlEncode(query.value)}`)
.join("&");
window.open(`https://github.com/nonebot/nonebot2/issues/new?${urlQueries}`);
};
const onChange = (event) => {
const target = event.target;
const value = target.type === "checkbox" ? target.checked : target.value;
const name = target.name;
setForm({
...form,
[name]: value,
});
event.preventDefault();
};
const onChangeLabel = (event) => {
setLabel(event.target.value);
};
const onChangeColor = (color) => {
setColor(color.hex);
};
const validateTag = () => {
return label.length >= 1 && label.length <= 10;
};
const newTag = () => {
if (tags.length >= 3) {
return;
}
if (validateTag()) {
const tag = { label, color };
setTags([...tags, tag]);
}
};
const delTag = (index: number) => {
setTags(tags.filter((_, i) => i !== index));
};
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4 px-4">
<input
className="w-full px-4 py-2 border rounded-full bg-light-nonepress-100 dark:bg-dark-nonepress-100"
value={filter}
placeholder="搜索插件"
onChange={(event) => setFilter(event.target.value)}
/>
<button
className="w-full rounded-lg bg-hero text-white"
onClick={() => setModalOpen(true)}
>
</button>
</div>
<div className="grid grid-cols-1 p-4">
<Paginate {...props} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 px-4">
{currentPlugins.map((plugin, index) => (
<Card
key={index}
{...plugin}
action={`nb plugin install ${plugin.project_link}`}
actionDisabled={!plugin.project_link}
/>
))}
</div>
<div className="grid grid-cols-1 p-4">
<Paginate {...props} />
</div>
<Modal active={modalOpen} setActive={setModalOpen}>
<ModalTitle title={"插件信息"} />
<ModalContent>
<form onSubmit={onSubmit}>
<div className="grid grid-cols-1 gap-4 p-4">
<label className="flex flex-wrap">
<span className="mr-2">PyPI :</span>
<input
type="text"
name="projectLink"
className="px-2 flex-grow rounded bg-light-nonepress-200 dark:bg-dark-nonepress-200"
onChange={onChange}
/>
</label>
<label className="flex flex-wrap">
<span className="mr-2">import :</span>
<input
type="text"
name="moduleName"
className="px-2 flex-grow rounded bg-light-nonepress-200 dark:bg-dark-nonepress-200"
onChange={onChange}
/>
</label>
</div>
</form>
<div className="px-4">
<label className="flex flex-wrap">
<span className="mr-2">:</span>
{tags.map((tag, index) => (
<TagComponent
key={index}
{...tag}
className="cursor-pointer"
onClick={() => delTag(index)}
/>
))}
</label>
</div>
<div className="px-4 pt-4">
<input
ref={ref}
type="text"
className="px-2 flex-grow rounded bg-light-nonepress-200 dark:bg-dark-nonepress-200"
onChange={onChangeLabel}
/>
<ChromePicker
className="mt-2"
color={color}
disableAlpha={true}
onChangeComplete={onChangeColor}
/>
<div className="flex mt-2">
<TagComponent label={label} color={color} />
<button
className={clsx(
"px-2 h-9 min-w-[64px] rounded text-hero hover:bg-hero hover:bg-opacity-[.08]",
{ "pointer-events-none opacity-60": !validateTag() }
)}
onClick={newTag}
>
</button>
</div>
</div>
</ModalContent>
<ModalAction>
<button
className="px-2 h-9 min-w-[64px] rounded text-hero hover:bg-hero hover:bg-opacity-[.08]"
onClick={() => setModalOpen(false)}
>
</button>
<button
className="ml-2 px-2 h-9 min-w-[64px] rounded text-hero hover:bg-hero hover:bg-opacity-[.08]"
onClick={onSubmit}
>
</button>
</ModalAction>
</Modal>
</>
);
}

View File

@ -0,0 +1,118 @@
import React from "react";
import clsx from "clsx";
import Link from "@docusaurus/Link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import "./styles.css";
import Tag from "@/components/Resource/Tag";
import type { Resource } from "@/libs/store";
export type Props = {
resource: Resource;
onClick?: () => void;
onTagClick: (tag: string) => void;
onAuthorClick: () => void;
className?: string;
};
export default function ResourceCard({
resource,
onClick,
onTagClick,
onAuthorClick,
className,
}: Props): JSX.Element {
const isGithub = /^https:\/\/github.com\/[^/]+\/[^/]+/.test(
resource.homepage
);
const isPlugin = resource.resourceType === "plugin";
const registryLink =
isPlugin &&
`https://registry.nonebot.dev/plugin/${resource.project_link}:${resource.module_name}`;
const authorLink = `https://github.com/${resource.author}`;
const authorAvatar = `${authorLink}.png?size=80`;
return (
<div
className={clsx(
"resource-card-container",
onClick && "resource-card-container-clickable",
className
)}
onClick={onClick}
>
<div className="resource-card-header">
<div className="resource-card-header-title">
{resource.name}
{resource.is_official && (
<FontAwesomeIcon
className="resource-card-header-check"
icon={["fas", "circle-check"]}
/>
)}
</div>
<div className="resource-card-header-expand">
<FontAwesomeIcon icon={["fas", "expand"]} />
</div>
</div>
<div className="resource-card-desc">{resource.desc}</div>
<div className="resource-card-footer">
<div className="resource-card-footer-tags">
{resource.tags.map((tag, index) => (
<Tag
className="resource-card-footer-tag"
key={index}
{...tag}
onClick={() => onTagClick(tag.label)}
/>
))}
</div>
<div className="divider resource-card-footer-divider"></div>
<div className="resource-card-footer-info">
<div className="resource-card-footer-group">
<Link href={resource.homepage}>
{isGithub ? (
<FontAwesomeIcon
className="resource-card-footer-icon"
icon={["fab", "github"]}
/>
) : (
<FontAwesomeIcon
className="resource-card-footer-icon"
icon={["fas", "link"]}
/>
)}
</Link>
{isPlugin && (
<Link href={registryLink as string}>
<FontAwesomeIcon
className="resource-card-footer-icon"
icon={["fas", "cube"]}
/>
</Link>
)}
</div>
<div className="resource-card-footer-group">
<div className="avatar">
<div className="resource-card-footer-avatar">
<Link href={authorLink}>
<img src={authorAvatar} key={resource.author} />
</Link>
</div>
</div>
<span
className="resource-card-footer-author"
onClick={onAuthorClick}
>
{resource.author}
</span>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,75 @@
.resource-card {
&-container {
@apply flex flex-col gap-y-2 w-full min-h-[12rem] p-4;
@apply transition-colors duration-500 bg-base-200;
@apply border-2 border-base-200 rounded-lg;
&-clickable {
@apply cursor-pointer hover:border-primary;
}
}
&-header {
@apply flex items-center w-full text-lg font-medium;
&-title {
@apply grow text-left truncate;
}
&-check {
@apply ml-2 text-success w-5 h-5 fill-current;
}
&-expand {
@apply flex-none fill-current;
}
}
&-desc {
@apply flex-1 w-full text-sm text-ellipsis break-words;
}
&-footer {
@apply flex flex-col w-full cursor-default;
&-tags {
@apply flex flex-wrap gap-1;
}
&-tag {
@apply cursor-pointer;
}
&-divider {
@apply m-0;
}
&-info {
@apply flex items-center justify-between w-full;
}
&-group {
@apply flex items-center justify-center gap-x-1 leading-none;
}
&-icon {
@apply w-5 h-5 fill-current opacity-80;
&:hover {
@apply opacity-100;
}
}
&-avatar {
@apply w-5 h-5 rounded-full transition-shadow;
&:hover {
@apply ring-1 ring-primary ring-offset-base-100 ring-offset-1;
}
}
&-author {
@apply text-sm cursor-pointer;
}
}
}

View File

@ -0,0 +1,32 @@
import React from "react";
import clsx from "clsx";
import "./styles.css";
import { pickTextColor } from "@/libs/color";
import type { Tag } from "@/types/tag";
export type Props = Tag & {
className?: string;
onClick?: React.MouseEventHandler<HTMLSpanElement>;
};
export default function ResourceTag({
label,
color,
className,
onClick,
}: Props): JSX.Element {
return (
<span
className={clsx("resource-tag", className)}
style={{
backgroundColor: color,
color: pickTextColor(color, "#fff", "#000"),
}}
onClick={onClick}
>
{label}
</span>
);
}

View File

@ -0,0 +1,4 @@
.resource-tag {
@apply inline-flex items-center justify-center;
@apply text-xs font-mono w-fit h-5 px-2 rounded;
}

View File

@ -0,0 +1,118 @@
import React, { useRef } from "react";
import clsx from "clsx";
import "./styles.css";
import { translate } from "@docusaurus/Translate";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export type Props = {
onChange: (value: string) => void;
onSubmit: (value: string) => void;
onBackspace: () => void;
onClear: () => void;
onTagClick: (index: number) => void;
tags?: string[];
className?: string;
placeholder?: string;
disabled?: boolean;
};
export default function Searcher({
onChange,
onSubmit,
onBackspace,
onClear,
onTagClick,
tags = [],
className,
placeholder,
disabled = false,
}: Props): JSX.Element {
const ref = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent<HTMLInputElement>) => {
onSubmit(e.currentTarget.value);
e.currentTarget.value = "";
e.preventDefault();
};
const handleEscape = (e: React.KeyboardEvent<HTMLInputElement>) => {
e.currentTarget.value = "";
};
const handleBackspace = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.currentTarget.value === "") {
onBackspace();
e.preventDefault();
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case "Enter": {
handleSubmit(e);
break;
}
case "Escape": {
handleEscape(e);
break;
}
case "Backspace": {
handleBackspace(e);
break;
}
default:
break;
}
};
const handleClear = () => {
if (ref.current) {
ref.current.value = "";
ref.current.focus();
}
onClear();
};
return (
<div className={clsx("searcher-box", className)}>
<div className="searcher-container">
{tags.map((tag, index) => (
<div
key={index}
className="badge badge-primary searcher-tag"
onClick={() => onTagClick(index)}
>
{tag}
</div>
))}
<input
ref={ref}
className="searcher-input"
placeholder={
placeholder ??
translate({
id: "theme.searcher.input.placeholder",
description: "Search input placeholder",
message: "搜索",
})
}
onChange={(e) => onChange(e.currentTarget.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
/>
</div>
<div className="searcher-action" onClick={handleClear}>
<FontAwesomeIcon
className="searcher-action-icon search"
icon={["fas", "magnifying-glass"]}
/>
<FontAwesomeIcon
className="searcher-action-icon close"
icon={["fas", "xmark"]}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,37 @@
.searcher {
&-box {
@apply flex items-center w-full rounded-3xl bg-base-200;
@apply transition-[color,background-color] duration-500;
}
&-container {
@apply flex-1 flex items-center flex-wrap gap-x-1 gap-y-2;
@apply pl-5 py-3;
}
&-tag {
@apply flex-initial shrink-0;
@apply font-medium cursor-pointer select-none;
}
&-input {
@apply flex-1 text-sm min-w-[10rem];
@apply bg-transparent border-none outline-none;
}
&-action {
@apply flex-initial shrink-0 flex items-center justify-center cursor-pointer w-12 h-10;
&-icon {
@apply h-4 opacity-50;
}
&:hover &-icon.search {
@apply hidden;
}
&:not(:hover) &-icon.close {
@apply hidden;
}
}
}

View File

@ -0,0 +1,175 @@
import React, { useCallback, useEffect, useState } from "react";
import Translate from "@docusaurus/Translate";
import { usePagination } from "react-use-pagination";
import Admonition from "@theme/Admonition";
import Paginate from "@/components/Paginate";
import ResourceCard from "@/components/Resource/Card";
import Searcher from "@/components/Searcher";
import StoreToolbar, { type Action } from "@/components/Store/Toolbar";
import { authorFilter, tagFilter } from "@/libs/filter";
import { useSearchControl } from "@/libs/search";
import { fetchRegistryData, loadFailedTitle } from "@/libs/store";
import { useToolbar } from "@/libs/toolbar";
import type { Adapter } from "@/types/adapter";
export default function AdapterPage(): JSX.Element {
const [adapters, setAdapters] = useState<Adapter[] | null>(null);
const adapterCount = adapters?.length ?? 0;
const loading = adapters === null;
const [error, setError] = useState<Error | null>(null);
const {
filteredResources: filteredAdapters,
searcherTags,
addFilter,
onSearchQueryChange,
onSearchQuerySubmit,
onSearchBackspace,
onSearchClear,
onSearchTagClick,
} = useSearchControl<Adapter>(adapters ?? []);
const filteredAdapterCount = filteredAdapters.length;
const {
startIndex,
endIndex,
totalPages,
currentPage,
setNextPage,
setPreviousPage,
setPage,
previousEnabled,
nextEnabled,
} = usePagination({
totalItems: filteredAdapters.length,
initialPageSize: 12,
});
const currentAdapters = filteredAdapters.slice(startIndex, endIndex + 1);
// load adapters asynchronously
useEffect(() => {
fetchRegistryData("adapter")
.then(setAdapters)
.catch((e) => {
setError(e);
console.error(e);
});
}, []);
const { filters: filterTools } = useToolbar({
resources: adapters ?? [],
addFilter,
});
const actionTool: Action = {
label: "发布适配器",
icon: ["fas", "plus"],
onClick: () => {
// TODO: open adapter release modal
window.open(
"https://github.com/nonebot/nonebot2/issues/new?template=adapter_publish.yml"
);
},
};
const onCardClick = useCallback((adapter: Adapter) => {
// TODO: open adapter modal
console.log(adapter, "clicked");
}, []);
const onCardTagClick = useCallback(
(tag: string) => {
addFilter(tagFilter(tag));
},
[addFilter]
);
const onCardAuthorClick = useCallback(
(author: string) => {
addFilter(authorFilter(author));
},
[addFilter]
);
return (
<>
<p className="store-description">
{adapterCount === filteredAdapterCount ? (
<Translate
id="pages.store.adapter.info"
description="Adapters info of the adapter store page"
values={{ adapterCount }}
>
{"当前共有 {adapterCount} 个适配器"}
</Translate>
) : (
<Translate
id="pages.store.adapter.searchInfo"
description="Adapters search info of the adapter store page"
values={{
adapterCount,
filteredAdapterCount,
}}
>
{"当前共有 {filteredAdapterCount} / {adapterCount} 个插件"}
</Translate>
)}
</p>
<Searcher
className="store-searcher not-prose"
onChange={onSearchQueryChange}
onSubmit={onSearchQuerySubmit}
onBackspace={onSearchBackspace}
onClear={onSearchClear}
onTagClick={onSearchTagClick}
tags={searcherTags}
disabled={loading}
/>
<StoreToolbar
className="not-prose"
filters={filterTools}
action={actionTool}
/>
{error ? (
<Admonition type="caution" title={loadFailedTitle}>
{error.message}
</Admonition>
) : loading ? (
<p className="store-loading-container">
<span className="loading loading-dots loading-lg store-loading"></span>
</p>
) : (
<div className="store-container">
{currentAdapters.map((adapter, index) => (
<ResourceCard
key={index}
className="not-prose"
resource={adapter}
onClick={() => onCardClick(adapter)}
onTagClick={onCardTagClick}
onAuthorClick={() => onCardAuthorClick(adapter.author)}
/>
))}
</div>
)}
<Paginate
className="not-prose"
totalPages={totalPages}
currentPage={currentPage}
setNextPage={setNextPage}
setPreviousPage={setPreviousPage}
setPage={setPage}
nextEnabled={nextEnabled}
previousEnabled={previousEnabled}
/>
</>
);
}

View File

@ -0,0 +1,169 @@
import React, { useCallback, useEffect, useState } from "react";
import Translate from "@docusaurus/Translate";
import { usePagination } from "react-use-pagination";
import Admonition from "@theme/Admonition";
import Paginate from "@/components/Paginate";
import ResourceCard from "@/components/Resource/Card";
import Searcher from "@/components/Searcher";
import StoreToolbar, { type Action } from "@/components/Store/Toolbar";
import { authorFilter, tagFilter } from "@/libs/filter";
import { useSearchControl } from "@/libs/search";
import { fetchRegistryData, loadFailedTitle } from "@/libs/store";
import { useToolbar } from "@/libs/toolbar";
import type { Bot } from "@/types/bot";
export default function PluginPage(): JSX.Element {
const [bots, setBots] = useState<Bot[] | null>(null);
const botCount = bots?.length ?? 0;
const loading = bots === null;
const [error, setError] = useState<Error | null>(null);
const {
filteredResources: filteredBots,
searcherTags,
addFilter,
onSearchQueryChange,
onSearchQuerySubmit,
onSearchBackspace,
onSearchClear,
onSearchTagClick,
} = useSearchControl<Bot>(bots ?? []);
const filteredBotCount = filteredBots.length;
const {
startIndex,
endIndex,
totalPages,
currentPage,
setNextPage,
setPreviousPage,
setPage,
previousEnabled,
nextEnabled,
} = usePagination({
totalItems: filteredBots.length,
initialPageSize: 12,
});
const currentBots = filteredBots.slice(startIndex, endIndex + 1);
// load bots asynchronously
useEffect(() => {
fetchRegistryData("bot")
.then(setBots)
.catch((e) => {
setError(e);
console.error(e);
});
}, []);
const { filters: filterTools } = useToolbar({
resources: bots ?? [],
addFilter,
});
const actionTool: Action = {
label: "发布机器人",
icon: ["fas", "plus"],
onClick: () => {
// TODO: open bot release modal
window.open(
"https://github.com/nonebot/nonebot2/issues/new?template=bot_publish.yml"
);
},
};
const onCardTagClick = useCallback(
(tag: string) => {
addFilter(tagFilter(tag));
},
[addFilter]
);
const onAuthorClick = useCallback(
(author: string) => {
addFilter(authorFilter(author));
},
[addFilter]
);
return (
<>
<p className="store-description">
{botCount === filteredBotCount ? (
<Translate
id="pages.store.bot.info"
description="Bots info of the bot store page"
values={{ botCount }}
>
{"当前共有 {botCount} 个机器人"}
</Translate>
) : (
<Translate
id="pages.store.bot.searchInfo"
description="Bots search info of the bot store page"
values={{
botCount,
filteredBotCount,
}}
>
{"当前共有 {filteredBotCount} / {botCount} 个机器人"}
</Translate>
)}
</p>
<Searcher
className="store-searcher not-prose"
onChange={onSearchQueryChange}
onSubmit={onSearchQuerySubmit}
onBackspace={onSearchBackspace}
onClear={onSearchClear}
onTagClick={onSearchTagClick}
tags={searcherTags}
disabled={loading}
/>
<StoreToolbar
className="not-prose"
filters={filterTools}
action={actionTool}
/>
{error ? (
<Admonition type="caution" title={loadFailedTitle}>
{error.message}
</Admonition>
) : loading ? (
<p className="store-loading-container">
<span className="loading loading-dots loading-lg store-loading"></span>
</p>
) : (
<div className="store-container">
{currentBots.map((bot, index) => (
<ResourceCard
key={index}
className="not-prose"
resource={bot}
onTagClick={onCardTagClick}
onAuthorClick={() => onAuthorClick(bot.author)}
/>
))}
</div>
)}
<Paginate
className="not-prose"
totalPages={totalPages}
currentPage={currentPage}
setNextPage={setNextPage}
setPreviousPage={setPreviousPage}
setPage={setPage}
nextEnabled={nextEnabled}
previousEnabled={previousEnabled}
/>
</>
);
}

View File

@ -0,0 +1,151 @@
import React, { useCallback, useEffect, useState } from "react";
import Translate from "@docusaurus/Translate";
import { usePagination } from "react-use-pagination";
import Admonition from "@theme/Admonition";
import Paginate from "@/components/Paginate";
import ResourceCard from "@/components/Resource/Card";
import Searcher from "@/components/Searcher";
import { authorFilter, tagFilter } from "@/libs/filter";
import { useSearchControl } from "@/libs/search";
import { fetchRegistryData, loadFailedTitle } from "@/libs/store";
import type { Driver } from "@/types/driver";
export default function DriverPage(): JSX.Element {
const [drivers, setDrivers] = useState<Driver[] | null>(null);
const driverCount = drivers?.length ?? 0;
const loading = drivers === null;
const [error, setError] = useState<Error | null>(null);
const {
filteredResources: filteredDrivers,
searcherTags,
addFilter,
onSearchQueryChange,
onSearchQuerySubmit,
onSearchBackspace,
onSearchClear,
onSearchTagClick,
} = useSearchControl<Driver>(drivers ?? []);
const filteredDriverCount = filteredDrivers.length;
const {
startIndex,
endIndex,
totalPages,
currentPage,
setNextPage,
setPreviousPage,
setPage,
previousEnabled,
nextEnabled,
} = usePagination({
totalItems: filteredDrivers.length,
initialPageSize: 12,
});
const currentDrivers = filteredDrivers.slice(startIndex, endIndex + 1);
// load drivers asynchronously
useEffect(() => {
fetchRegistryData("driver")
.then(setDrivers)
.catch((e) => {
setError(e);
console.error(e);
});
}, []);
const onCardClick = useCallback((driver: Driver) => {
// TODO: open driver modal
console.log(driver, "clicked");
}, []);
const onCardTagClick = useCallback(
(tag: string) => {
addFilter(tagFilter(tag));
},
[addFilter]
);
const onAuthorClick = useCallback(
(author: string) => {
addFilter(authorFilter(author));
},
[addFilter]
);
return (
<>
<p className="store-description">
{driverCount === filteredDriverCount ? (
<Translate
id="pages.store.driver.info"
description="Drivers info of the driver store page"
values={{ driverCount }}
>
{"当前共有 {driverCount} 个插件"}
</Translate>
) : (
<Translate
id="pages.store.driver.searchInfo"
description="Drivers search info of the driver store page"
values={{
driverCount,
filteredDriverCount,
}}
>
{"当前共有 {filteredDriverCount} / {driverCount} 个插件"}
</Translate>
)}
</p>
<Searcher
className="store-searcher not-prose"
onChange={onSearchQueryChange}
onSubmit={onSearchQuerySubmit}
onBackspace={onSearchBackspace}
onClear={onSearchClear}
onTagClick={onSearchTagClick}
tags={searcherTags}
disabled={loading}
/>
{error ? (
<Admonition type="caution" title={loadFailedTitle}>
{error.message}
</Admonition>
) : loading ? (
<p className="store-loading-container">
<span className="loading loading-dots loading-lg store-loading"></span>
</p>
) : (
<div className="store-container">
{currentDrivers.map((driver, index) => (
<ResourceCard
key={index}
className="not-prose"
resource={driver}
onClick={() => onCardClick(driver)}
onTagClick={onCardTagClick}
onAuthorClick={() => onAuthorClick(driver.author)}
/>
))}
</div>
)}
<Paginate
className="not-prose"
totalPages={totalPages}
currentPage={currentPage}
setNextPage={setNextPage}
setPreviousPage={setPreviousPage}
setPage={setPage}
nextEnabled={nextEnabled}
previousEnabled={previousEnabled}
/>
</>
);
}

View File

@ -0,0 +1,172 @@
import React, { useCallback, useEffect, useState } from "react";
import Translate from "@docusaurus/Translate";
import { usePagination } from "react-use-pagination";
import Admonition from "@theme/Admonition";
import Paginate from "@/components/Paginate";
import ResourceCard from "@/components/Resource/Card";
import Searcher from "@/components/Searcher";
import StoreToolbar, { type Action } from "@/components/Store/Toolbar";
import { authorFilter, tagFilter } from "@/libs/filter";
import { useSearchControl } from "@/libs/search";
import { fetchRegistryData, loadFailedTitle } from "@/libs/store";
import { useToolbar } from "@/libs/toolbar";
import type { Plugin } from "@/types/plugin";
export default function PluginPage(): JSX.Element {
const [plugins, setPlugins] = useState<Plugin[] | null>(null);
const pluginCount = plugins?.length ?? 0;
const loading = plugins === null;
const [error, setError] = useState<Error | null>(null);
const {
filteredResources: filteredPlugins,
searcherTags,
addFilter,
onSearchQueryChange,
onSearchQuerySubmit,
onSearchBackspace,
onSearchClear,
onSearchTagClick,
} = useSearchControl<Plugin>(plugins ?? []);
const filteredPluginCount = filteredPlugins.length;
const {
startIndex,
endIndex,
totalPages,
currentPage,
setNextPage,
setPreviousPage,
setPage,
previousEnabled,
nextEnabled,
} = usePagination({
totalItems: filteredPlugins.length,
initialPageSize: 12,
});
const currentPlugins = filteredPlugins.slice(startIndex, endIndex + 1);
// load plugins asynchronously
useEffect(() => {
fetchRegistryData("plugin")
.then(setPlugins)
.catch((e) => {
setError(e);
console.error(e);
});
}, []);
const { filters: filterTools } = useToolbar({
resources: plugins ?? [],
addFilter,
});
const actionTool: Action = {
label: "发布插件",
icon: ["fas", "plus"],
onClick: () => {
// TODO: open plugin release modal
window.open(
"https://github.com/nonebot/nonebot2/issues/new?template=plugin_publish.yml"
);
},
};
const onCardClick = useCallback((plugin: Plugin) => {
// TODO: open plugin modal
console.log(plugin, "clicked");
}, []);
const onCardTagClick = useCallback(
(tag: string) => {
addFilter(tagFilter(tag));
},
[addFilter]
);
const onCardAuthorClick = useCallback(
(author: string) => {
addFilter(authorFilter(author));
},
[addFilter]
);
return (
<>
<p className="store-description">
{pluginCount === filteredPluginCount ? (
<Translate
id="pages.store.plugin.info"
description="Plugins info of the plugin store page"
values={{ pluginCount }}
>
{"当前共有 {pluginCount} 个插件"}
</Translate>
) : (
<Translate
id="pages.store.plugin.searchInfo"
description="Plugins search info of the plugin store page"
values={{ pluginCount, filteredPluginCount: filteredPluginCount }}
>
{"当前共有 {filteredPluginCount} / {pluginCount} 个插件"}
</Translate>
)}
</p>
<Searcher
className="store-searcher not-prose"
onChange={onSearchQueryChange}
onSubmit={onSearchQuerySubmit}
onBackspace={onSearchBackspace}
onClear={onSearchClear}
onTagClick={onSearchTagClick}
tags={searcherTags}
disabled={loading}
/>
<StoreToolbar
className="not-prose"
filters={filterTools}
action={actionTool}
/>
{error ? (
<Admonition type="caution" title={loadFailedTitle}>
{error.message}
</Admonition>
) : loading ? (
<p className="store-loading-container">
<span className="loading loading-dots loading-lg store-loading"></span>
</p>
) : (
<div className="store-container">
{currentPlugins.map((plugin, index) => (
<ResourceCard
key={index}
className="not-prose"
resource={plugin}
onClick={() => onCardClick(plugin)}
onTagClick={onCardTagClick}
onAuthorClick={() => onCardAuthorClick(plugin.author)}
/>
))}
</div>
)}
<Paginate
className="not-prose"
totalPages={totalPages}
currentPage={currentPage}
setNextPage={setNextPage}
setPreviousPage={setPreviousPage}
setPage={setPage}
nextEnabled={nextEnabled}
previousEnabled={previousEnabled}
/>
</>
);
}

View File

@ -0,0 +1,50 @@
import React from "react";
import { PageMetadata } from "@docusaurus/theme-common";
import { useDocsVersionCandidates } from "@docusaurus/theme-common/internal";
import { useVersionedSidebar } from "@nullbot/docusaurus-plugin-getsidebar/client";
import { SidebarContentFiller } from "@nullbot/docusaurus-theme-nonepress/contexts";
import BackToTopButton from "@theme/BackToTopButton";
import Layout from "@theme/Layout";
import Page from "@theme/Page";
import "./styles.css";
const SIDEBAR_ID = "ecosystem";
type Props = {
title: string;
children: React.ReactNode;
};
function StorePage({ title, children }: Props): JSX.Element {
const sidebarItems = useVersionedSidebar(
useDocsVersionCandidates()[0].name,
SIDEBAR_ID
)!;
return (
<Page hideTableOfContents reduceContentWidth={false}>
<SidebarContentFiller items={sidebarItems} />
<article className="prose max-w-full">
<h1 className="store-title">{title}</h1>
{children}
</article>
</Page>
);
}
export default function StoreLayout({ title, ...props }: Props): JSX.Element {
return (
<>
<PageMetadata title={title} />
<Layout>
<BackToTopButton />
<StorePage title={title} {...props} />
</Layout>
</>
);
}

View File

@ -0,0 +1,128 @@
import React, { useState } from "react";
import clsx from "clsx";
import type { IconProp } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export type Filter = {
label: string;
icon: IconProp;
choices?: string[];
onSubmit: (query: string) => void;
};
export type Action = {
label: string;
icon: IconProp;
onClick: () => void;
};
export type Props = {
filters?: Filter[];
action?: Action;
className?: string;
};
function ToolbarFilter({
label,
icon,
choices,
onSubmit,
}: Filter): JSX.Element {
const [query, setQuery] = useState<string>("");
const filteredChoices = choices
?.filter((choice) => choice.toLowerCase().includes(query.toLowerCase()))
?.slice(0, 5);
const handleQuerySubmit = () => {
if (filteredChoices && filteredChoices.length > 0) {
onSubmit(filteredChoices[0]);
} else if (choices === null) {
onSubmit(query);
}
};
const onQueryKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleQuerySubmit();
e.preventDefault();
}
};
const onChoiceKeyDown = (e: React.KeyboardEvent<HTMLLIElement>) => {
if (e.key === "Enter") {
onSubmit(e.currentTarget.innerText);
e.preventDefault();
}
};
return (
<div className="dropdown">
<label
className="btn btn-sm btn-outline btn-primary no-animation"
tabIndex={0}
>
<FontAwesomeIcon icon={icon} />
{label}
</label>
<div className="dropdown-content store-toolbar-dropdown">
<input
type="text"
placeholder="搜索"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onQueryKeyDown}
className="input input-sm input-bordered w-full"
/>
{filteredChoices && (
<ul className="menu menu-sm">
{filteredChoices.map((choice, index) => (
<li
key={index}
onClick={() => onSubmit(choice)}
onKeyDown={onChoiceKeyDown}
>
<a tabIndex={0}>{choice}</a>
</li>
))}
</ul>
)}
</div>
</div>
);
}
export default function StoreToolbar({
filters,
action,
className,
}: Props): JSX.Element | null {
if (!(filters && filters.length > 0) && !action) {
return null;
}
return (
<div className={clsx("store-toolbar", className)}>
{filters && filters.length > 0 && (
<div className="store-toolbar-filters">
{filters.map((filter, index) => (
<ToolbarFilter key={index} {...filter} />
))}
</div>
)}
{action && (
<div className="store-toolbar-action">
<button
className="btn btn-sm btn-primary no-animation"
onClick={action.onClick}
>
<FontAwesomeIcon icon={action.icon} />
{action.label}
</button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,38 @@
.store {
&-title {
@apply text-center;
}
&-description {
@apply text-center;
}
&-searcher {
@apply max-w-2xl mx-auto my-4;
}
&-toolbar {
@apply flex items-center justify-center my-4;
&-filters {
@apply flex grow gap-2;
}
&-dropdown {
@apply w-36 z-10 m-0 p-2;
@apply rounded-md bg-base-100 shadow-lg border border-base-200;
}
}
&-loading {
@apply text-primary;
&-container {
@apply text-center;
}
}
&-container {
@apply grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-6 mt-4 mb-8;
}
}

View File

@ -1,38 +0,0 @@
import clsx from "clsx";
import React from "react";
import { Tag as TagType } from "../../libs/store";
function pickTextColor(bgColor, lightColor, darkColor) {
var color = bgColor.charAt(0) === "#" ? bgColor.substring(1, 7) : bgColor;
var r = parseInt(color.substring(0, 2), 16); // hexToR
var g = parseInt(color.substring(2, 4), 16); // hexToG
var b = parseInt(color.substring(4, 6), 16); // hexToB
return r * 0.299 + g * 0.587 + b * 0.114 > 186 ? darkColor : lightColor;
}
export default function Tag({
label,
color,
className,
onClick,
}: TagType & {
className?: string;
onClick?: React.MouseEventHandler<HTMLSpanElement>;
}): JSX.Element {
return (
<span
className={clsx(
"font-mono inline-flex px-3 rounded-full items-center align-middle mr-2",
className
)}
style={{
backgroundColor: color,
color: pickTextColor(color, "#fff", "#000"),
}}
onClick={onClick}
>
{label}
</span>
);
}

View File

@ -1,3 +0,0 @@
.homeCodeBlock {
width: 602px;
}

16
website/src/libs/color.ts Normal file
View File

@ -0,0 +1,16 @@
/**
* Choose fg color by bg color
* @see https://www.npmjs.com/package/colord
* @see https://www.w3.org/TR/AERT/#color-contrast
*/
export function pickTextColor(
bgColor: string,
lightColor: string,
darkColor: string
) {
const color = bgColor.charAt(0) === "#" ? bgColor.substring(1, 7) : bgColor;
const r = parseInt(color.substring(0, 2), 16); // hexToR
const g = parseInt(color.substring(2, 4), 16); // hexToG
const b = parseInt(color.substring(4, 6), 16); // hexToB
return r * 0.299 + g * 0.587 + b * 0.114 > 186 ? darkColor : lightColor;
}

130
website/src/libs/filter.ts Normal file
View File

@ -0,0 +1,130 @@
import { useCallback, useState } from "react";
import { translate } from "@docusaurus/Translate";
import type { Resource } from "./store";
export type Filter<T extends Resource = Resource> = {
type: string;
id: string;
displayName: string;
filter: (resource: T) => boolean;
};
export const tagFilter = <T extends Resource = Resource>(
tag: string
): Filter<T> => ({
type: "tag",
id: `tag-${tag}`,
displayName: translate(
{
id: "pages.store.filter.tagDisplayName",
description: "The display name of tag filter",
message: "标签: {tag}",
},
{ tag }
),
filter: (resource: Resource): boolean =>
resource.tags.map((tag) => tag.label).includes(tag),
});
export const officialFilter = <T extends Resource = Resource>(
official: boolean = true
): Filter<T> => ({
type: "official",
id: `official-${official}`,
displayName: translate({
id: "pages.store.filter.officialDisplayName",
description: "The display name of official filter",
message: "非官方|官方",
}).split("|")[Number(official)],
filter: (resource: Resource): boolean => resource.is_official === official,
});
export const authorFilter = <T extends Resource = Resource>(
author: string
): Filter<T> => ({
type: "author",
id: `author-${author}`,
displayName: translate(
{
id: "pages.store.filter.authorDisplayName",
description: "The display name of author filter",
message: "作者: {author}",
},
{ author }
),
filter: (resource: Resource): boolean => resource.author === author,
});
export const queryFilter = <T extends Resource = Resource>(
query: string
): Filter<T> => ({
type: "query",
id: `query-${query}`,
displayName: query,
filter: (resource: Resource): boolean => {
if (!query) return true;
const queryLower = query.toLowerCase();
const pluginMatch =
resource.resourceType === "plugin" &&
(resource.module_name?.toLowerCase().includes(queryLower) ||
resource.project_link?.toLowerCase().includes(queryLower));
const commonMatch =
resource.name.toLowerCase().includes(queryLower) ||
resource.desc.toLowerCase().includes(queryLower) ||
resource.author.toLowerCase().includes(queryLower) ||
resource.tags.filter((t) => t.label.toLowerCase().includes(queryLower))
.length > 0;
return pluginMatch || commonMatch;
},
});
export function filterResources<T extends Resource>(
resources: T[],
filters: Filter<T>[]
): T[] {
return resources.filter((resource) =>
filters.every((filter) => filter.filter(resource))
);
}
type useFilteredResourcesReturn<T extends Resource> = {
filters: Filter<T>[];
addFilter: (filter: Filter<T>) => void;
removeFilter: (filter: Filter<T> | string) => void;
filteredResources: T[];
};
export function useFilteredResources<T extends Resource>(
resources: T[]
): useFilteredResourcesReturn<T> {
const [filters, setFilters] = useState<Filter<T>[]>([]);
const addFilter = useCallback(
(filter: Filter<T>) => {
if (filters.some((f) => f.id === filter.id)) return;
setFilters((filters) => [...filters, filter]);
},
[filters, setFilters]
);
const removeFilter = useCallback(
(filter: Filter<T> | string) => {
setFilters((filters) =>
filters.filter((f) =>
typeof filter === "string" ? f.id !== filter : f !== filter
)
);
},
[setFilters]
);
const filteredResources = useCallback(
() => filterResources(resources, filters),
[resources, filters]
);
return {
filters,
addFilter,
removeFilter,
filteredResources: filteredResources(),
};
}

View File

@ -1,67 +0,0 @@
import { useLayoutEffect, useRef } from "react";
import ResizeObserver from "resize-observer-polyfill";
export function useResizeNotifier(
element: HTMLElement | undefined,
callback: () => void
) {
const callBackRef = useRef(callback);
useLayoutEffect(() => {
callBackRef.current = callback;
}, [callback]);
useLayoutEffect(() => {
if (!element) return;
const resizeObserver = new ResizeObserver(
withResizeLoopDetection(() => {
callBackRef.current!();
})
);
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
};
}, [element]);
}
function withResizeLoopDetection(callback: () => void) {
return (entries: ResizeObserverEntry[], resizeObserver: ResizeObserver) => {
const elements = entries.map((entry) => entry.target);
const rectsBefore = elements.map((element) =>
element.getBoundingClientRect()
);
callback();
const rectsAfter = elements.map((element) =>
element.getBoundingClientRect()
);
const changedElements = elements.filter(
(_, i) => !areRectSizesEqual(rectsBefore[i], rectsAfter[i])
);
changedElements.forEach((element) =>
unobserveUntilNextFrame(element, resizeObserver)
);
};
}
function unobserveUntilNextFrame(
element: Element,
resizeObserver: ResizeObserver
) {
resizeObserver.unobserve(element);
requestAnimationFrame(() => {
resizeObserver.observe(element);
});
}
function areRectSizesEqual(rect1: DOMRect, rect2: DOMRect) {
return rect1.width === rect2.width && rect1.height === rect2.height;
}

View File

@ -0,0 +1,88 @@
import { useCallback, useEffect, useState } from "react";
import { type Filter, useFilteredResources, queryFilter } from "./filter";
import type { Resource } from "./store";
type useSearchControlReturn<T extends Resource> = {
filteredResources: T[];
searcherTags: string[];
addFilter: (filter: Filter<T>) => void;
onSearchQueryChange: (query: string) => void;
onSearchQuerySubmit: () => void;
onSearchBackspace: () => void;
onSearchClear: () => void;
onSearchTagClick: (index: number) => void;
};
export function useSearchControl<T extends Resource>(
resources: T[]
): useSearchControlReturn<T> {
const [currentFilter, setCurrentFilter] = useState<Filter<T> | null>(null);
const { filters, addFilter, removeFilter, filteredResources } =
useFilteredResources(resources);
// display tags in searcher (except current filter)
const [searcherFilters, setSearcherFilters] = useState<Filter<T>[]>([]);
useEffect(() => {
setSearcherFilters(
filters.filter((f) => !(currentFilter && f === currentFilter))
);
}, [filters, currentFilter]);
const onSearchQueryChange = useCallback(
(newQuery: string) => {
// remove current filter if query is empty
if (newQuery === "") {
currentFilter && removeFilter(currentFilter);
setCurrentFilter(null);
return;
}
const newFilter = queryFilter<T>(newQuery);
// do nothing if filter is not changed
if (currentFilter?.id === newFilter.id) return;
// remove old currentFilter
currentFilter && removeFilter(currentFilter);
// add new filter
setCurrentFilter(newFilter);
addFilter(newFilter);
},
[currentFilter, setCurrentFilter, addFilter, removeFilter]
);
const onSearchQuerySubmit = useCallback(() => {
// set current filter to null to make filter permanent
setCurrentFilter(null);
}, [setCurrentFilter]);
const onSearchBackspace = useCallback(() => {
// remove last filter
removeFilter(searcherFilters[searcherFilters.length - 1]);
}, [removeFilter, searcherFilters]);
const onSearchClear = useCallback(() => {
// remove all filters
searcherFilters.forEach((filter) => removeFilter(filter));
}, [removeFilter, searcherFilters]);
const onSearchTagClick = useCallback(
(index: number) => {
removeFilter(searcherFilters[index]);
},
[removeFilter, searcherFilters]
);
return {
filteredResources,
searcherTags: searcherFilters.map((filter) => filter.displayName),
addFilter,
onSearchQueryChange,
onSearchQuerySubmit,
onSearchBackspace,
onSearchClear,
onSearchTagClick,
};
}

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