mirror of
				https://github.com/nonebot/nonebot2.git
				synced 2025-10-31 15:06:42 +00:00 
			
		
		
		
	📝 Docs: 添加商店表单支持 (#2460)
Co-authored-by: Ju4tCode <42488585+yanyongyu@users.noreply.github.com>
This commit is contained in:
		
							
								
								
									
										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; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user