📝 Docs: 添加商店表单支持 (#2460)

Co-authored-by: Ju4tCode <42488585+yanyongyu@users.noreply.github.com>
This commit is contained in:
StarHeart 2023-11-22 14:04:22 +08:00 committed by GitHub
parent af9327de14
commit ef882927f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 972 additions and 18 deletions

View File

@ -37,6 +37,7 @@
"devDependencies": { "devDependencies": {
"@docusaurus/module-type-aliases": "^2.4.1", "@docusaurus/module-type-aliases": "^2.4.1",
"@tsconfig/docusaurus": "^1.0.5", "@tsconfig/docusaurus": "^1.0.5",
"@types/react-color": "^3.0.10",
"asciinema-player": "^3.5.0", "asciinema-player": "^3.5.0",
"typescript": "^4.7.4" "typescript": "^4.7.4"
}, },

View File

@ -0,0 +1,51 @@
import React from "react";
import { Form } from ".";
export default function AdapterForm(): JSX.Element {
const formItems = [
{
name: "基本信息",
items: [
{
type: "text",
name: "name",
labelText: "适配器名称",
},
{ type: "text", name: "description", labelText: "适配器描述" },
{
type: "text",
name: "homepage",
labelText: "适配器项目仓库/主页链接",
},
],
},
{
name: "包信息",
items: [
{ type: "text", name: "pypi", labelText: "PyPI 项目名" },
{ type: "text", name: "module", labelText: "适配器 import 包名" },
],
},
{
name: "其他信息",
items: [{ type: "tag", name: "tags", labelText: "标签" }],
},
];
const handleSubmit = (result: Record<string, string>) => {
window.open(
`https://github.com/nonebot/nonebot2/issues/new?${new URLSearchParams({
assignees: "",
labels: "Adapter",
projects: "",
template: "adapter_publish.yml",
title: `Adapter: ${result.name}`,
...result,
})}`
);
};
return (
<Form type="adapter" formItems={formItems} handleSubmit={handleSubmit} />
);
}

View File

@ -0,0 +1,43 @@
import React from "react";
import { Form } from ".";
export default function BotForm(): JSX.Element {
const formItems = [
{
name: "基本信息",
items: [
{
type: "text",
name: "name",
labelText: "机器人名称",
},
{ type: "text", name: "description", labelText: "机器人描述" },
{
type: "text",
name: "homepage",
labelText: "机器人项目仓库/主页链接",
},
],
},
{
name: "其他信息",
items: [{ type: "tag", name: "tags", labelText: "标签" }],
},
];
const handleSubmit = (result: Record<string, string>) => {
window.open(
`https://github.com/nonebot/nonebot2/issues/new?${new URLSearchParams({
assignees: "",
labels: "Bot",
projects: "",
template: "bot_publish.yml",
title: `Bot: ${result.name}`,
...result,
})}`
);
};
return <Form type="bot" formItems={formItems} handleSubmit={handleSubmit} />;
}

View File

@ -0,0 +1,110 @@
import React, { useState } from "react";
import clsx from "clsx";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ChromePicker, type ColorResult } from "react-color";
import "./styles.css";
import TagComponent from "@/components/Tag";
import { Tag as TagType } from "@/types/tag";
export type Props = {
allowTags: TagType[];
onTagUpdate: (tags: TagType[]) => void;
};
export default function TagFormItem({
allowTags,
onTagUpdate,
}: Props): JSX.Element {
const [tags, setTags] = useState<TagType[]>([]);
const [label, setLabel] = useState<TagType["label"]>("");
const [color, setColor] = useState<TagType["color"]>("#ea5252");
const slicedTags = Array.from(
new Set(
allowTags
.filter((tag) => tag.label.toLocaleLowerCase().includes(label))
.map((e) => e.label)
)
).slice(0, 5);
const validateTag = () => {
return label.length >= 1 && label.length <= 10;
};
const newTag = () => {
if (tags.length >= 3) {
return;
}
if (validateTag()) {
const tag: TagType = { label, color };
setTags([...tags, tag]);
onTagUpdate(tags);
}
};
const delTag = (index: number) => {
setTags(tags.filter((_, i) => i !== index));
onTagUpdate(tags);
};
const onChangeColor = (color: ColorResult) => {
setColor(color.hex as TagType["color"]);
};
return (
<>
<label className="flex flex-wrap gap-x-1 gap-y-1">
{tags.map((tag, index) => (
<TagComponent
key={index}
{...tag}
className="cursor-pointer"
onClick={() => delTag(index)}
/>
))}
{tags.length < 3 && (
<span
className={clsx("add-btn", { "add-btn-disabled": !validateTag() })}
onClick={() => newTag()}
>
<FontAwesomeIcon className="pr-1" icon={["fas", "plus"]} />
</span>
)}
</label>
<div className="form-item-container">
<span className="form-item-title"></span>
<div className="dropdown dropdown-bottom w-full">
<input
type="text"
value={label}
className="form-item form-item-input"
placeholder="请输入"
onChange={(e) => setLabel(e.target.value)}
/>
{slicedTags.length > 0 && (
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow bg-base-100 rounded-box w-52"
>
{slicedTags.map((tag) => (
<li key={tag}>
<a onClick={() => setLabel(tag)}>{tag}</a>
</li>
))}
</ul>
)}
</div>
</div>
<div className="form-item-container">
<span className="form-item-title"></span>
<ChromePicker
className="my-4 fix-input-color"
color={color}
disableAlpha={true}
onChangeComplete={onChangeColor}
/>
</div>
</>
);
}

View File

@ -0,0 +1,35 @@
.add-btn {
@apply px-2 select-none cursor-pointer min-w-[64px] rounded-full hover:bg-opacity-[.08];
@apply flex justify-center items-center border-dashed border-2;
&-disabled {
@apply pointer-events-none opacity-60;
}
}
.form-item {
@apply basis-3/4;
&-title {
@apply basis-1/4 label-text;
}
&-input {
@apply input input-sm input-bordered;
}
&-select {
@apply select select-sm select-bordered;
}
&-container {
@apply flex items-center mt-2;
}
}
.fix-input-color {
@apply !text-base-content !bg-base-100;
input {
@apply !text-base-content !bg-base-100;
}
}

View File

@ -0,0 +1,35 @@
import React from "react";
import { Form } from ".";
export default function PluginForm(): JSX.Element {
const formItems = [
{
name: "包信息",
items: [
{ type: "text", name: "pypi", labelText: "PyPI 项目名" },
{ type: "text", name: "module", labelText: "插件 import 包名" },
],
},
{
name: "其他信息",
items: [{ type: "tag", name: "tags", labelText: "标签" }],
},
];
const handleSubmit = (result: Record<string, string>) => {
window.open(
`https://github.com/nonebot/nonebot2/issues/new?${new URLSearchParams({
assignees: "",
labels: "Plugin",
projects: "",
template: "plugin_publish.yml",
title: `Plugin: ${result.pypi}`,
...result,
})}`
);
};
return (
<Form type="plugin" formItems={formItems} handleSubmit={handleSubmit} />
);
}

View File

@ -0,0 +1,159 @@
import React, { useEffect, useState } from "react";
import clsx from "clsx";
import "./styles.css";
import TagFormItem from "./Items/Tag";
import { fetchRegistryData, Resource } from "@/libs/store";
import { Tag as TagType } from "@/types/tag";
export type FormItemData = {
type: string;
name: string;
labelText: string;
};
export type FormItemGroup = {
name: string;
items: FormItemData[];
};
export type Props = {
children?: React.ReactNode;
type: Resource["resourceType"];
formItems: FormItemGroup[];
handleSubmit: (result: Record<string, string>) => void;
};
export function Form({
type,
children,
formItems,
handleSubmit,
}: Props): JSX.Element {
const [currentStep, setCurrentStep] = useState<number>(0);
const [result, setResult] = useState<Record<string, string>>({});
const [allowTags, setAllowTags] = useState<TagType[]>([]);
// load tags asynchronously
useEffect(() => {
fetchRegistryData(type)
.then((data) =>
setAllowTags(
data
.filter((item) => item.tags.length > 0)
.map((ele) => ele.tags)
.flat()
)
)
.catch((e) => {
console.error(e);
});
}, [type]);
const setFormValue = (key: string, value: string) => {
setResult({ ...result, [key]: value });
};
const handleNextStep = () => {
const currentStepNames = formItems[currentStep].items.map(
(item) => item.name
);
if (currentStepNames.every((name) => result[name]))
setCurrentStep(currentStep + 1);
else return;
};
const onPrev = () => currentStep > 0 && setCurrentStep(currentStep - 1);
const onNext = () =>
currentStep < formItems.length - 1
? handleNextStep()
: handleSubmit(result);
return (
<>
<ul className="steps">
{formItems.map((item, index) => (
<li
key={index}
className={clsx("step", currentStep === index && "step-primary")}
>
{item.name}
</li>
))}
</ul>
<div className="form-control w-full min-h-[300px]">
{children ||
formItems[currentStep].items.map((item) => (
<FormItem
key={item.name}
type={item.type}
name={item.name}
labelText={item.labelText}
allowTags={allowTags}
result={result}
setResult={setFormValue}
/>
))}
</div>
<div className="flex justify-between">
<button
className={clsx("form-btn form-btn-prev", {
"form-btn-hidden": currentStep === 0,
})}
onClick={onPrev}
>
</button>
<button className="form-btn form-btn-next" onClick={onNext}>
{currentStep === formItems.length - 1 ? "提交" : "下一步"}
</button>
</div>
</>
);
}
export function FormItem({
type,
name,
labelText,
allowTags,
result,
setResult,
}: FormItemData & {
allowTags: TagType[];
result: Record<string, string>;
setResult: (key: string, value: string) => void;
}): JSX.Element {
return (
<>
<label className="label">
<span className="label-text">{labelText}</span>
</label>
{type === "text" && (
<input
value={result[name] || ""}
type="text"
name={name}
onChange={(e) => setResult(name, e.target.value)}
placeholder="请输入"
className={clsx("form-input", {
"form-input-error": !result[name],
})}
/>
)}
{type === "text" && !result[name] && (
<label className="label">
<span className="form-label form-label-error">{labelText}</span>
</label>
)}
{type === "tag" && (
<TagFormItem
allowTags={allowTags}
onTagUpdate={(tags) => setResult(name, JSON.stringify(tags))}
/>
)}
</>
);
}

View File

@ -0,0 +1,31 @@
.form-btn {
@apply btn btn-sm btn-primary no-animation;
&-prev {
@apply mr-auto;
}
&-next {
@apply ml-auto;
}
&-hidden {
@apply hidden;
}
}
.form-input {
@apply input input-bordered w-full;
&-error {
@apply input-error;
}
}
.form-label {
@apply text-xs;
&-error {
@apply text-error;
}
}

View File

@ -0,0 +1,61 @@
import React, { useEffect, useState } from "react";
import clsx from "clsx";
import IconClose from "@theme/Icon/Close";
import "./styles.css";
export type Props = {
children?: React.ReactNode;
className?: string;
title: string;
useCustomTitle?: boolean;
backdropExit?: boolean;
setOpenModal: (isOpen: boolean) => void;
};
export default function Modal({
setOpenModal,
className,
children,
useCustomTitle,
backdropExit,
title,
}: Props): JSX.Element {
const [transitionClass, setTransitionClass] = useState<string>("");
const onFadeIn = () => setTransitionClass("fade-in");
const onFadeOut = () => setTransitionClass("fade-out");
const onTransitionEnd = () =>
transitionClass === "fade-out" && setOpenModal(false);
useEffect(onFadeIn, []);
return (
<div className={clsx("nb-modal-root", className)}>
<div
className={clsx("nb-modal-backdrop", transitionClass)}
onTransitionEnd={onTransitionEnd}
onClick={() => backdropExit && onFadeOut()}
/>
<div className={clsx("nb-modal-container", transitionClass)}>
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
{!useCustomTitle && (
<div className="nb-modal-title">
{title}
<div className="card-actions ml-auto">
<button className="btn btn-square btn-sm" onClick={onFadeOut}>
<IconClose />
</button>
</div>
</div>
)}
{children}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
.nb-modal {
&-title {
@apply flex items-center font-bold;
}
&-root {
@apply fixed z-[1300] inset-0 flex items-center justify-center;
}
&-container {
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 min-w-[400px] lg:min-w-[600px];
@apply p-8 opacity-0;
@apply transition-opacity duration-[225ms] ease-in-out delay-0;
&.fade-in {
@apply opacity-100;
}
&.fade-out {
@apply opacity-0;
}
}
&-backdrop {
@apply fixed flex right-0 bottom-0 top-0 left-0 bg-transparent opacity-0;
@apply transition-all duration-[225ms] ease-in-out delay-0 -z-[1];
&.fade-in {
@apply opacity-100 bg-black/50;
}
&.fade-out {
@apply opacity-0 bg-transparent;
}
}
}

View File

@ -0,0 +1,157 @@
import React, { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
// @ts-expect-error: we need to make package have type: module
import copy from "copy-text-to-clipboard";
import { PyPIData } from "./types";
import Tag from "@/components/Resource/Tag";
import type { Resource } from "@/libs/store";
import "./styles.css";
export type Props = {
resource: Resource;
};
export default function ResourceDetailCard({ resource }: Props) {
const [pypiData, setPypiData] = useState<PyPIData | null>(null);
const [copied, setCopied] = useState<boolean>(false);
const authorLink = `https://github.com/${resource.author}`;
const authorAvatar = `${authorLink}.png?size=100`;
const getProjectLink = (resource: Resource) => {
switch (resource.resourceType) {
case "plugin":
case "adapter":
case "driver":
return resource.project_link;
default:
return null;
}
};
const getModuleName = (resource: Resource) => {
switch (resource.resourceType) {
case "plugin":
case "adapter":
return resource.module_name;
case "driver":
return resource.module_name.replace(/~/, "nonebot.drivers.");
default:
return null;
}
};
const fetchPypiProject = (projectName: string) =>
fetch(`https://pypi.org/pypi/${projectName}/json`)
.then((response) => response.json())
.then((data) => setPypiData(data));
const copyCommand = (resource: Resource) => {
const projectLink = getProjectLink(resource);
if (projectLink) {
copy(`nb ${resource.resourceType} install ${projectLink}`);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
useEffect(() => {
const fetchingTasks: Promise<void>[] = [];
if (resource.resourceType === "bot" || resource.resourceType === "driver")
return;
if (resource.project_link)
fetchingTasks.push(fetchPypiProject(resource.project_link));
Promise.all(fetchingTasks);
}, [resource]);
const projectLink = getProjectLink(resource) || "无";
const moduleName = getModuleName(resource) || "无";
return (
<>
<div className="detail-card-header">
<img
src={authorAvatar}
className="detail-card-avatar"
decoding="async"
></img>
<div className="detail-card-title">
<span className="detail-card-title-main">{resource.name}</span>
<span className="detail-card-title-sub">{resource.author}</span>
</div>
<button
className="detail-card-copy-button detail-card-copy-button-desktop"
onClick={() => copyCommand(resource)}
>
{copied ? "复制成功" : "复制安装命令"}
</button>
</div>
<div className="detail-card-body">
<div className="detail-card-body-left">
<span className="h-full">{resource.desc}</span>
<div className="resource-card-footer-tags mb-4">
{resource.tags.map((tag, index) => (
<Tag className="align-bottom" key={index} {...tag} />
))}
</div>
</div>
<div className="detail-card-body-divider" />
<div className="detail-card-body-right">
<div className="detail-card-meta-item">
<span>
<FontAwesomeIcon fixedWidth icon={["fab", "python"]} />{" "}
{(pypiData && pypiData.info.requires_python) || "无"}
</span>
</div>
<div className="detail-card-meta-item">
<FontAwesomeIcon fixedWidth icon={["fas", "file-zipper"]} />{" "}
{(pypiData &&
pypiData.releases[pypiData.info.version] &&
`${
pypiData.releases[pypiData.info.version].reduce(
(acc, curr) => acc + curr.size,
0
) / 1000
}K`) ||
"无"}
</div>
<div className="detail-card-meta-item">
<span>
<FontAwesomeIcon
className="fa-fw"
icon={["fas", "scale-balanced"]}
/>{" "}
{(pypiData && pypiData.info.license) || "无"}
</span>
</div>
<div className="detail-card-meta-item">
<FontAwesomeIcon fixedWidth icon={["fas", "tag"]} />{" "}
{(pypiData && pypiData.info.version) || "无"}
</div>
<div className="detail-card-meta-item">
<FontAwesomeIcon fixedWidth icon={["fas", "fingerprint"]} />{" "}
<span>{moduleName}</span>
</div>
<div className="detail-card-meta-item">
<FontAwesomeIcon fixedWidth icon={["fas", "cubes"]} />{" "}
<span>{projectLink}</span>
</div>
<button
className="detail-card-copy-button detail-card-copy-button-mobile w-full"
onClick={() => copyCommand(resource)}
>
{copied ? "复制成功" : "复制安装命令"}
</button>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,53 @@
.detail-card {
&-header {
@apply flex items-center align-middle;
}
&-avatar {
@apply mr-3 w-12 h-12 rounded-full;
}
&-title {
@apply inline-flex flex-col h-12 justify-start;
&-main {
@apply font-bold;
}
&-sub {
@apply text-sm;
}
}
&-copy-button {
@apply ml-auto btn btn-sm;
&-mobile {
@apply lg:hidden;
}
&-desktop {
@apply max-lg:hidden;
}
}
&-body {
@apply flex flex-col w-full lg:flex-row;
&-left {
@apply flex flex-col min-h-[150px] lg:basis-3/4;
}
&-divider {
@apply divider lg:divider-horizontal;
}
&-right {
@apply flex flex-col justify-start gap-y-2 lg:basis-1/4;
}
}
&-meta-item {
@apply text-sm whitespace-nowrap;
}
}

View File

@ -0,0 +1,64 @@
export type Downloads = {
last_day: number;
last_month: number;
last_week: number;
};
export type Info = {
author: string;
author_email: string;
bugtrack_url: null;
classifiers: string[];
description: string;
description_content_type: string;
docs_url: null;
download_url: string;
downloads: Downloads;
home_page: string;
keywords: string;
license: string;
maintainer: string;
maintainer_email: string;
name: string;
package_url: string;
platform: null;
project_url: string;
release_url: string;
requires_dist: string[];
requires_python: string;
summary: string;
version: string;
yanked: boolean;
yanked_reason: null;
};
export interface Digests {
blake2b_256: string;
md5: string;
sha256: string;
}
export type Releases = {
comment_text: string;
digests: Digests;
downloads: number;
filename: string;
has_sig: boolean;
md5_digest: string;
packagetype: string;
python_version: string;
requires_python: string;
size: number;
upload_time: Date;
upload_time_iso_8601: Date;
url: string;
yanked: boolean;
yanked_reason: null;
};
export type PyPIData = {
info: Info;
last_serial: number;
releases: { [key: string]: Releases[] };
urls: URL[];
vulnerabilities: unknown[];
};

View File

@ -5,8 +5,11 @@ import { usePagination } from "react-use-pagination";
import Admonition from "@theme/Admonition"; import Admonition from "@theme/Admonition";
import AdapterForm from "@/components/Form/Adapter";
import Modal from "@/components/Modal";
import Paginate from "@/components/Paginate"; import Paginate from "@/components/Paginate";
import ResourceCard from "@/components/Resource/Card"; import ResourceCard from "@/components/Resource/Card";
import ResourceDetailCard from "@/components/Resource/DetailCard";
import Searcher from "@/components/Searcher"; import Searcher from "@/components/Searcher";
import StoreToolbar, { type Action } from "@/components/Store/Toolbar"; import StoreToolbar, { type Action } from "@/components/Store/Toolbar";
import { authorFilter, tagFilter } from "@/libs/filter"; import { authorFilter, tagFilter } from "@/libs/filter";
@ -21,6 +24,9 @@ export default function AdapterPage(): JSX.Element {
const loading = adapters === null; const loading = adapters === null;
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [isOpenModal, setIsOpenModal] = useState<boolean>(false);
const [isOpenCardModal, setIsOpenCardModal] = useState<boolean>(false);
const [clickedAdapter, setClickedAdapter] = useState<Adapter | null>(null);
const { const {
filteredResources: filteredAdapters, filteredResources: filteredAdapters,
@ -69,16 +75,13 @@ export default function AdapterPage(): JSX.Element {
label: "发布适配器", label: "发布适配器",
icon: ["fas", "plus"], icon: ["fas", "plus"],
onClick: () => { onClick: () => {
// TODO: open adapter release modal setIsOpenModal(true);
window.open(
"https://github.com/nonebot/nonebot2/issues/new?template=adapter_publish.yml&title=Adapter%3A+%7Bname%7D&labels=Adapter"
);
}, },
}; };
const onCardClick = useCallback((adapter: Adapter) => { const onCardClick = useCallback((adapter: Adapter) => {
// TODO: open adapter modal setClickedAdapter(adapter);
console.log(adapter, "clicked"); setIsOpenCardModal(true);
}, []); }, []);
const onCardTagClick = useCallback( const onCardTagClick = useCallback(
@ -170,6 +173,25 @@ export default function AdapterPage(): JSX.Element {
nextEnabled={nextEnabled} nextEnabled={nextEnabled}
previousEnabled={previousEnabled} previousEnabled={previousEnabled}
/> />
{isOpenModal && (
<Modal
className="not-prose"
title="发布适配器"
setOpenModal={setIsOpenModal}
>
<AdapterForm />
</Modal>
)}
{isOpenCardModal && (
<Modal
className="not-prose"
title="适配器详情"
backdropExit
setOpenModal={setIsOpenCardModal}
>
{clickedAdapter && <ResourceDetailCard resource={clickedAdapter} />}
</Modal>
)}
</> </>
); );
} }

View File

@ -5,6 +5,8 @@ import { usePagination } from "react-use-pagination";
import Admonition from "@theme/Admonition"; import Admonition from "@theme/Admonition";
import BotForm from "@/components/Form/Bot";
import Modal from "@/components/Modal";
import Paginate from "@/components/Paginate"; import Paginate from "@/components/Paginate";
import ResourceCard from "@/components/Resource/Card"; import ResourceCard from "@/components/Resource/Card";
import Searcher from "@/components/Searcher"; import Searcher from "@/components/Searcher";
@ -21,6 +23,7 @@ export default function PluginPage(): JSX.Element {
const loading = bots === null; const loading = bots === null;
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [isOpenModal, setIsOpenModal] = useState<boolean>(false);
const { const {
filteredResources: filteredBots, filteredResources: filteredBots,
@ -69,10 +72,7 @@ export default function PluginPage(): JSX.Element {
label: "发布机器人", label: "发布机器人",
icon: ["fas", "plus"], icon: ["fas", "plus"],
onClick: () => { onClick: () => {
// TODO: open bot release modal setIsOpenModal(true);
window.open(
"https://github.com/nonebot/nonebot2/issues/new?template=bot_publish.yml&title=Bot%3A+%7Bname%7D&labels=Bot"
);
}, },
}; };
@ -164,6 +164,15 @@ export default function PluginPage(): JSX.Element {
nextEnabled={nextEnabled} nextEnabled={nextEnabled}
previousEnabled={previousEnabled} previousEnabled={previousEnabled}
/> />
{isOpenModal && (
<Modal
className="not-prose"
title="发布机器人"
setOpenModal={setIsOpenModal}
>
<BotForm />
</Modal>
)}
</> </>
); );
} }

View File

@ -5,8 +5,10 @@ import { usePagination } from "react-use-pagination";
import Admonition from "@theme/Admonition"; import Admonition from "@theme/Admonition";
import Modal from "@/components/Modal";
import Paginate from "@/components/Paginate"; import Paginate from "@/components/Paginate";
import ResourceCard from "@/components/Resource/Card"; import ResourceCard from "@/components/Resource/Card";
import ResourceDetailCard from "@/components/Resource/DetailCard";
import Searcher from "@/components/Searcher"; import Searcher from "@/components/Searcher";
import { authorFilter, tagFilter } from "@/libs/filter"; import { authorFilter, tagFilter } from "@/libs/filter";
import { useSearchControl } from "@/libs/search"; import { useSearchControl } from "@/libs/search";
@ -19,6 +21,8 @@ export default function DriverPage(): JSX.Element {
const loading = drivers === null; const loading = drivers === null;
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [isOpenCardModal, setIsOpenCardModal] = useState<boolean>(false);
const [clickedDriver, setClickedDriver] = useState<Driver | null>(null);
const { const {
filteredResources: filteredDrivers, filteredResources: filteredDrivers,
@ -59,8 +63,8 @@ export default function DriverPage(): JSX.Element {
}, []); }, []);
const onCardClick = useCallback((driver: Driver) => { const onCardClick = useCallback((driver: Driver) => {
// TODO: open driver modal setClickedDriver(driver);
console.log(driver, "clicked"); setIsOpenCardModal(true);
}, []); }, []);
const onCardTagClick = useCallback( const onCardTagClick = useCallback(
@ -146,6 +150,17 @@ export default function DriverPage(): JSX.Element {
nextEnabled={nextEnabled} nextEnabled={nextEnabled}
previousEnabled={previousEnabled} previousEnabled={previousEnabled}
/> />
{isOpenCardModal && (
<Modal
className="not-prose"
useCustomTitle
backdropExit
title="驱动器详情"
setOpenModal={setIsOpenCardModal}
>
{clickedDriver && <ResourceDetailCard resource={clickedDriver} />}
</Modal>
)}
</> </>
); );
} }

View File

@ -5,8 +5,11 @@ import { usePagination } from "react-use-pagination";
import Admonition from "@theme/Admonition"; import Admonition from "@theme/Admonition";
import PluginForm from "@/components/Form/Plugin";
import Modal from "@/components/Modal";
import Paginate from "@/components/Paginate"; import Paginate from "@/components/Paginate";
import ResourceCard from "@/components/Resource/Card"; import ResourceCard from "@/components/Resource/Card";
import ResourceDetailCard from "@/components/Resource/DetailCard";
import Searcher from "@/components/Searcher"; import Searcher from "@/components/Searcher";
import StoreToolbar, { type Action } from "@/components/Store/Toolbar"; import StoreToolbar, { type Action } from "@/components/Store/Toolbar";
import { authorFilter, tagFilter } from "@/libs/filter"; import { authorFilter, tagFilter } from "@/libs/filter";
@ -21,6 +24,9 @@ export default function PluginPage(): JSX.Element {
const loading = plugins === null; const loading = plugins === null;
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [isOpenModal, setIsOpenModal] = useState<boolean>(false);
const [isOpenCardModal, setIsOpenCardModal] = useState<boolean>(false);
const [clickedPlugin, setClickedPlugin] = useState<Plugin | null>(null);
const { const {
filteredResources: filteredPlugins, filteredResources: filteredPlugins,
@ -69,16 +75,13 @@ export default function PluginPage(): JSX.Element {
label: "发布插件", label: "发布插件",
icon: ["fas", "plus"], icon: ["fas", "plus"],
onClick: () => { onClick: () => {
// TODO: open plugin release modal setIsOpenModal(true);
window.open(
"https://github.com/nonebot/nonebot2/issues/new?template=plugin_publish.yml&title=Plugin%3A+%7Bname%7D&labels=Plugin"
);
}, },
}; };
const onCardClick = useCallback((plugin: Plugin) => { const onCardClick = useCallback((plugin: Plugin) => {
// TODO: open plugin modal setClickedPlugin(plugin);
console.log(plugin, "clicked"); setIsOpenCardModal(true);
}, []); }, []);
const onCardTagClick = useCallback( const onCardTagClick = useCallback(
@ -167,6 +170,26 @@ export default function PluginPage(): JSX.Element {
nextEnabled={nextEnabled} nextEnabled={nextEnabled}
previousEnabled={previousEnabled} previousEnabled={previousEnabled}
/> />
{isOpenModal && (
<Modal
className="not-prose"
title="发布插件"
setOpenModal={setIsOpenModal}
>
<PluginForm />
</Modal>
)}
{isOpenCardModal && (
<Modal
className="not-prose"
useCustomTitle
backdropExit
title="插件详情"
setOpenModal={setIsOpenCardModal}
>
{clickedPlugin && <ResourceDetailCard resource={clickedPlugin} />}
</Modal>
)}
</> </>
); );
} }

View File

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

View File

@ -0,0 +1,3 @@
.tag {
@apply font-mono inline-flex px-3 rounded-full items-center align-middle;
}

View File

@ -2307,6 +2307,14 @@
resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.5.tgz#38bd1733ae299620771bd414837ade2e57757498" resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.5.tgz#38bd1733ae299620771bd414837ade2e57757498"
integrity sha512-xrO9OoVPqFuYyR/loIHjnbvvyRZREYKLjxV4+dY6v3FQR3stQ9ZxIGkaclF7YhI9hfjpuTbu14hZEy94qKLtOA== integrity sha512-xrO9OoVPqFuYyR/loIHjnbvvyRZREYKLjxV4+dY6v3FQR3stQ9ZxIGkaclF7YhI9hfjpuTbu14hZEy94qKLtOA==
"@types/react-color@^3.0.10":
version "3.0.10"
resolved "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.10.tgz#f869ab3a46938fb97a5c2ee568bc6469f82b14dd"
integrity sha512-6K5BAn3zyd8lW8UbckIAVeXGxR82Za9jyGD2DBEynsa7fKaguLDVtjfypzs7fgEV7bULgs7uhds8A8v1wABTvQ==
dependencies:
"@types/react" "*"
"@types/reactcss" "*"
"@types/react-router-config@*", "@types/react-router-config@^5.0.6": "@types/react-router-config@*", "@types/react-router-config@^5.0.6":
version "5.0.8" version "5.0.8"
resolved "https://registry.npmjs.org/@types/react-router-config/-/react-router-config-5.0.8.tgz#dd00654de4d79927570a4a8807c4a728feed59f3" resolved "https://registry.npmjs.org/@types/react-router-config/-/react-router-config-5.0.8.tgz#dd00654de4d79927570a4a8807c4a728feed59f3"
@ -2342,6 +2350,13 @@
"@types/scheduler" "*" "@types/scheduler" "*"
csstype "^3.0.2" csstype "^3.0.2"
"@types/reactcss@*":
version "1.2.9"
resolved "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.9.tgz#e3ddff5ee18c02e62cdef3588dd408203b275610"
integrity sha512-dN2TtynLIZaZZ4gQNK6WM0Nff8GWYCXKl1Kvsp59WgROtx03ixCwuC1UWdesgt2O1P5Qk+0+SIfsy3eiwblMEA==
dependencies:
"@types/react" "*"
"@types/retry@0.12.0": "@types/retry@0.12.0":
version "0.12.0" version "0.12.0"
resolved "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" resolved "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"