mirror of
https://github.com/nonebot/nonebot2.git
synced 2024-11-24 00:55:07 +08:00
📝 Docs: 添加商店表单支持 (#2460)
Co-authored-by: Ju4tCode <42488585+yanyongyu@users.noreply.github.com>
This commit is contained in:
parent
af9327de14
commit
ef882927f3
@ -37,6 +37,7 @@
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^2.4.1",
|
||||
"@tsconfig/docusaurus": "^1.0.5",
|
||||
"@types/react-color": "^3.0.10",
|
||||
"asciinema-player": "^3.5.0",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
|
51
website/src/components/Form/Adapter.tsx
Normal file
51
website/src/components/Form/Adapter.tsx
Normal 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} />
|
||||
);
|
||||
}
|
43
website/src/components/Form/Bot.tsx
Normal file
43
website/src/components/Form/Bot.tsx
Normal 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} />;
|
||||
}
|
110
website/src/components/Form/Items/Tag/index.tsx
Normal file
110
website/src/components/Form/Items/Tag/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
35
website/src/components/Form/Items/Tag/styles.css
Normal file
35
website/src/components/Form/Items/Tag/styles.css
Normal 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;
|
||||
}
|
||||
}
|
35
website/src/components/Form/Plugin.tsx
Normal file
35
website/src/components/Form/Plugin.tsx
Normal 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} />
|
||||
);
|
||||
}
|
159
website/src/components/Form/index.tsx
Normal file
159
website/src/components/Form/index.tsx
Normal 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))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
31
website/src/components/Form/styles.css
Normal file
31
website/src/components/Form/styles.css
Normal 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;
|
||||
}
|
||||
}
|
61
website/src/components/Modal/index.tsx
Normal file
61
website/src/components/Modal/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
36
website/src/components/Modal/styles.css
Normal file
36
website/src/components/Modal/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
157
website/src/components/Resource/DetailCard/index.tsx
Normal file
157
website/src/components/Resource/DetailCard/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
53
website/src/components/Resource/DetailCard/styles.css
Normal file
53
website/src/components/Resource/DetailCard/styles.css
Normal 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;
|
||||
}
|
||||
}
|
64
website/src/components/Resource/DetailCard/types.ts
Normal file
64
website/src/components/Resource/DetailCard/types.ts
Normal 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[];
|
||||
};
|
@ -5,8 +5,11 @@ import { usePagination } from "react-use-pagination";
|
||||
|
||||
import Admonition from "@theme/Admonition";
|
||||
|
||||
import AdapterForm from "@/components/Form/Adapter";
|
||||
import Modal from "@/components/Modal";
|
||||
import Paginate from "@/components/Paginate";
|
||||
import ResourceCard from "@/components/Resource/Card";
|
||||
import ResourceDetailCard from "@/components/Resource/DetailCard";
|
||||
import Searcher from "@/components/Searcher";
|
||||
import StoreToolbar, { type Action } from "@/components/Store/Toolbar";
|
||||
import { authorFilter, tagFilter } from "@/libs/filter";
|
||||
@ -21,6 +24,9 @@ export default function AdapterPage(): JSX.Element {
|
||||
const loading = adapters === 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 {
|
||||
filteredResources: filteredAdapters,
|
||||
@ -69,16 +75,13 @@ export default function AdapterPage(): JSX.Element {
|
||||
label: "发布适配器",
|
||||
icon: ["fas", "plus"],
|
||||
onClick: () => {
|
||||
// TODO: open adapter release modal
|
||||
window.open(
|
||||
"https://github.com/nonebot/nonebot2/issues/new?template=adapter_publish.yml&title=Adapter%3A+%7Bname%7D&labels=Adapter"
|
||||
);
|
||||
setIsOpenModal(true);
|
||||
},
|
||||
};
|
||||
|
||||
const onCardClick = useCallback((adapter: Adapter) => {
|
||||
// TODO: open adapter modal
|
||||
console.log(adapter, "clicked");
|
||||
setClickedAdapter(adapter);
|
||||
setIsOpenCardModal(true);
|
||||
}, []);
|
||||
|
||||
const onCardTagClick = useCallback(
|
||||
@ -170,6 +173,25 @@ export default function AdapterPage(): JSX.Element {
|
||||
nextEnabled={nextEnabled}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -5,6 +5,8 @@ import { usePagination } from "react-use-pagination";
|
||||
|
||||
import Admonition from "@theme/Admonition";
|
||||
|
||||
import BotForm from "@/components/Form/Bot";
|
||||
import Modal from "@/components/Modal";
|
||||
import Paginate from "@/components/Paginate";
|
||||
import ResourceCard from "@/components/Resource/Card";
|
||||
import Searcher from "@/components/Searcher";
|
||||
@ -21,6 +23,7 @@ export default function PluginPage(): JSX.Element {
|
||||
const loading = bots === null;
|
||||
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isOpenModal, setIsOpenModal] = useState<boolean>(false);
|
||||
|
||||
const {
|
||||
filteredResources: filteredBots,
|
||||
@ -69,10 +72,7 @@ export default function PluginPage(): JSX.Element {
|
||||
label: "发布机器人",
|
||||
icon: ["fas", "plus"],
|
||||
onClick: () => {
|
||||
// TODO: open bot release modal
|
||||
window.open(
|
||||
"https://github.com/nonebot/nonebot2/issues/new?template=bot_publish.yml&title=Bot%3A+%7Bname%7D&labels=Bot"
|
||||
);
|
||||
setIsOpenModal(true);
|
||||
},
|
||||
};
|
||||
|
||||
@ -164,6 +164,15 @@ export default function PluginPage(): JSX.Element {
|
||||
nextEnabled={nextEnabled}
|
||||
previousEnabled={previousEnabled}
|
||||
/>
|
||||
{isOpenModal && (
|
||||
<Modal
|
||||
className="not-prose"
|
||||
title="发布机器人"
|
||||
setOpenModal={setIsOpenModal}
|
||||
>
|
||||
<BotForm />
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -5,8 +5,10 @@ import { usePagination } from "react-use-pagination";
|
||||
|
||||
import Admonition from "@theme/Admonition";
|
||||
|
||||
import Modal from "@/components/Modal";
|
||||
import Paginate from "@/components/Paginate";
|
||||
import ResourceCard from "@/components/Resource/Card";
|
||||
import ResourceDetailCard from "@/components/Resource/DetailCard";
|
||||
import Searcher from "@/components/Searcher";
|
||||
import { authorFilter, tagFilter } from "@/libs/filter";
|
||||
import { useSearchControl } from "@/libs/search";
|
||||
@ -19,6 +21,8 @@ export default function DriverPage(): JSX.Element {
|
||||
const loading = drivers === null;
|
||||
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isOpenCardModal, setIsOpenCardModal] = useState<boolean>(false);
|
||||
const [clickedDriver, setClickedDriver] = useState<Driver | null>(null);
|
||||
|
||||
const {
|
||||
filteredResources: filteredDrivers,
|
||||
@ -59,8 +63,8 @@ export default function DriverPage(): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const onCardClick = useCallback((driver: Driver) => {
|
||||
// TODO: open driver modal
|
||||
console.log(driver, "clicked");
|
||||
setClickedDriver(driver);
|
||||
setIsOpenCardModal(true);
|
||||
}, []);
|
||||
|
||||
const onCardTagClick = useCallback(
|
||||
@ -146,6 +150,17 @@ export default function DriverPage(): JSX.Element {
|
||||
nextEnabled={nextEnabled}
|
||||
previousEnabled={previousEnabled}
|
||||
/>
|
||||
{isOpenCardModal && (
|
||||
<Modal
|
||||
className="not-prose"
|
||||
useCustomTitle
|
||||
backdropExit
|
||||
title="驱动器详情"
|
||||
setOpenModal={setIsOpenCardModal}
|
||||
>
|
||||
{clickedDriver && <ResourceDetailCard resource={clickedDriver} />}
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -5,8 +5,11 @@ import { usePagination } from "react-use-pagination";
|
||||
|
||||
import Admonition from "@theme/Admonition";
|
||||
|
||||
import PluginForm from "@/components/Form/Plugin";
|
||||
import Modal from "@/components/Modal";
|
||||
import Paginate from "@/components/Paginate";
|
||||
import ResourceCard from "@/components/Resource/Card";
|
||||
import ResourceDetailCard from "@/components/Resource/DetailCard";
|
||||
import Searcher from "@/components/Searcher";
|
||||
import StoreToolbar, { type Action } from "@/components/Store/Toolbar";
|
||||
import { authorFilter, tagFilter } from "@/libs/filter";
|
||||
@ -21,6 +24,9 @@ export default function PluginPage(): JSX.Element {
|
||||
const loading = plugins === 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 {
|
||||
filteredResources: filteredPlugins,
|
||||
@ -69,16 +75,13 @@ export default function PluginPage(): JSX.Element {
|
||||
label: "发布插件",
|
||||
icon: ["fas", "plus"],
|
||||
onClick: () => {
|
||||
// TODO: open plugin release modal
|
||||
window.open(
|
||||
"https://github.com/nonebot/nonebot2/issues/new?template=plugin_publish.yml&title=Plugin%3A+%7Bname%7D&labels=Plugin"
|
||||
);
|
||||
setIsOpenModal(true);
|
||||
},
|
||||
};
|
||||
|
||||
const onCardClick = useCallback((plugin: Plugin) => {
|
||||
// TODO: open plugin modal
|
||||
console.log(plugin, "clicked");
|
||||
setClickedPlugin(plugin);
|
||||
setIsOpenCardModal(true);
|
||||
}, []);
|
||||
|
||||
const onCardTagClick = useCallback(
|
||||
@ -167,6 +170,26 @@ export default function PluginPage(): JSX.Element {
|
||||
nextEnabled={nextEnabled}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
31
website/src/components/Tag/index.tsx
Normal file
31
website/src/components/Tag/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
3
website/src/components/Tag/styles.css
Normal file
3
website/src/components/Tag/styles.css
Normal file
@ -0,0 +1,3 @@
|
||||
.tag {
|
||||
@apply font-mono inline-flex px-3 rounded-full items-center align-middle;
|
||||
}
|
15
yarn.lock
15
yarn.lock
@ -2307,6 +2307,14 @@
|
||||
resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.5.tgz#38bd1733ae299620771bd414837ade2e57757498"
|
||||
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":
|
||||
version "5.0.8"
|
||||
resolved "https://registry.npmjs.org/@types/react-router-config/-/react-router-config-5.0.8.tgz#dd00654de4d79927570a4a8807c4a728feed59f3"
|
||||
@ -2342,6 +2350,13 @@
|
||||
"@types/scheduler" "*"
|
||||
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":
|
||||
version "0.12.0"
|
||||
resolved "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
|
||||
|
Loading…
Reference in New Issue
Block a user