mirror of
https://github.com/nonebot/nonebot2.git
synced 2025-07-31 01:59:58 +00:00
📝 Docs: 升级新版 NonePress 主题 (#2375)
This commit is contained in:
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>;
|
||||
}
|
||||
|
@ -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} />;
|
||||
}}
|
||||
|
@ -1,3 +1,7 @@
|
||||
.asciinema-player svg {
|
||||
display: inline-block;
|
||||
.ap-player svg {
|
||||
@apply inline-block;
|
||||
}
|
||||
|
||||
.ap-container {
|
||||
@apply w-full my-4;
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
174
website/src/components/Home/Feature.tsx
Normal file
174
website/src/components/Home/Feature.tsx
Normal 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);
|
69
website/src/components/Home/Hero.tsx
Normal file
69
website/src/components/Home/Hero.tsx
Normal 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);
|
14
website/src/components/Home/index.tsx
Normal file
14
website/src/components/Home/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
41
website/src/components/Home/styles.css
Normal file
41
website/src/components/Home/styles.css
Normal 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];
|
||||
}
|
||||
}
|
@ -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, " "),
|
||||
}}
|
||||
@ -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>
|
||||
|
66
website/src/components/Messenger/styles.css
Normal file
66
website/src/components/Messenger/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>;
|
||||
}
|
@ -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>;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
30
website/src/components/Paginate/styles.css
Normal file
30
website/src/components/Paginate/styles.css
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
118
website/src/components/Resource/Card/index.tsx
Normal file
118
website/src/components/Resource/Card/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
75
website/src/components/Resource/Card/styles.css
Normal file
75
website/src/components/Resource/Card/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
32
website/src/components/Resource/Tag/index.tsx
Normal file
32
website/src/components/Resource/Tag/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
4
website/src/components/Resource/Tag/styles.css
Normal file
4
website/src/components/Resource/Tag/styles.css
Normal 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;
|
||||
}
|
118
website/src/components/Searcher/index.tsx
Normal file
118
website/src/components/Searcher/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
37
website/src/components/Searcher/styles.css
Normal file
37
website/src/components/Searcher/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
175
website/src/components/Store/Content/Adapter.tsx
Normal file
175
website/src/components/Store/Content/Adapter.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
169
website/src/components/Store/Content/Bot.tsx
Normal file
169
website/src/components/Store/Content/Bot.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
151
website/src/components/Store/Content/Driver.tsx
Normal file
151
website/src/components/Store/Content/Driver.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
172
website/src/components/Store/Content/Plugin.tsx
Normal file
172
website/src/components/Store/Content/Plugin.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
50
website/src/components/Store/Layout.tsx
Normal file
50
website/src/components/Store/Layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
128
website/src/components/Store/Toolbar.tsx
Normal file
128
website/src/components/Store/Toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
38
website/src/components/Store/styles.css
Normal file
38
website/src/components/Store/styles.css
Normal 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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user