Skip to main content
Glama
page.tsx19.4 kB
"use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/ui/components/ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@repo/ui/components/ui/card"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@repo/ui/components/ui/form"; import { Input } from "@repo/ui/components/ui/input"; import { Label } from "@repo/ui/components/ui/label"; import { Textarea } from "@repo/ui/components/ui/textarea"; import { cn } from "@repo/ui/lib/utils"; import { ArrowLeft, BrainCircuit, Laptop, Server, Upload, X } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import type React from "react"; import { useCallback, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { trpc } from "@/lib/trpc/client"; import { uploadToOSS, saveFormData, getFormData, clearFormData } from "@/lib/utils"; import { useSession } from "@/hooks/auth-hooks"; // 表单验证模式 const formSchema = z.object({ nickname: z.string().min(1, "昵称不能为空"), email: z.string().email("请输入有效的邮箱地址"), name: z.string().min(1, "应用名称不能为空"), description: z.string().min(10, "描述至少需要10个字符"), type: z.enum(["client", "server", "application"]), website: z.string().url("请输入有效的网址").optional().or(z.literal("")), github: z.string().url("请输入有效的GitHub仓库地址").optional().or(z.literal("")), tags: z.string().optional(), serverConfig: z.string().optional(), logoUrl: z.string().optional(), }); type FormValues = z.infer<typeof formSchema>; export default function SubmitPage() { const router = useRouter(); const searchParams = useSearchParams(); const { data: session } = useSession(); const [logoFile, setLogoFile] = useState<File | null>(null); const [logoPreview, setLogoPreview] = useState<string | null>(null); const [isUploading, setIsUploading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [pendingLogoFile, setPendingLogoFile] = useState<File | null>(null); // 使用tRPC mutation const submitMutation = trpc.mcpSubmit.create.useMutation({ onSuccess: () => { setIsSubmitting(false); // 提交成功后清除保存的表单数据 clearFormData(); setPendingLogoFile(null); toast.success("提交成功", { description: "您的应用信息已成功提交,我们将尽快审核", }); router.push("/"); }, onError: (error) => { setIsSubmitting(false); toast.error("提交失败", { description: error.message || "提交应用时出错,请稍后再试", }); }, }); // 初始化表单 const form = useForm<FormValues>({ resolver: zodResolver(formSchema), defaultValues: { nickname: "", email: "", name: "", description: "", type: "client", website: "", github: "", tags: "", serverConfig: "", logoUrl: "", }, }); // 监听类型变化,以便显示/隐藏服务器配置字段 const appType = form.watch("type"); // 上传logo的函数 const uploadLogo = useCallback(async (file: File) => { try { setIsUploading(true); // 使用OSS上传 const result = await uploadToOSS(file, "app-logos"); if (!result.success) { throw new Error(result.error || "上传失败"); } form.setValue("logoUrl", result.url || ""); setPendingLogoFile(null); toast.success("上传成功", { description: "图片已成功上传", }); } catch (error) { toast.error("上传失败", { description: error instanceof Error ? error.message : "图片上传失败,请重试", }); setLogoFile(null); setLogoPreview(null); setPendingLogoFile(null); } finally { setIsUploading(false); } }, [form]); // 页面加载时恢复表单数据 useEffect(() => { const savedData = getFormData(); if (savedData) { // 恢复表单数据 Object.keys(savedData).forEach((key) => { if (key in form.getValues()) { form.setValue(key as keyof FormValues, savedData[key]); } }); // 如果有待处理的logo文件,提示用户重新上传 if (savedData.pendingLogoFile) { toast.info("检测到之前未完成的logo上传", { description: "请重新选择logo文件以继续上传", }); } } }, [form]); // 监听登录状态变化,处理待上传的logo文件 useEffect(() => { if (session && pendingLogoFile) { // 用户已登录且有待上传的文件,自动上传 toast.success("登录成功", { description: "正在继续上传您的logo文件...", }); uploadLogo(pendingLogoFile); } else if (session) { // 用户刚登录成功,显示欢迎信息 const savedData = getFormData(); if (savedData?.pendingLogoFile) { toast.success("欢迎回来", { description: "您的表单数据已恢复,请继续完成logo上传", }); } else if (savedData) { toast.success("欢迎回来", { description: "您的表单数据已自动恢复", }); } } }, [session, pendingLogoFile, uploadLogo]); // 监听表单变化,自动保存数据 useEffect(() => { const subscription = form.watch((value) => { // 保存表单数据到localStorage saveFormData({ ...value, pendingLogoFile: pendingLogoFile ? true : false, }); }); return () => subscription.unsubscribe(); }, [form, pendingLogoFile]); // 页面卸载时清理数据 useEffect(() => { const handleBeforeUnload = () => { // 如果用户直接关闭页面,保留数据以便恢复 // 这里不做特殊处理,让用户下次访问时可以选择是否恢复 }; window.addEventListener('beforeunload', handleBeforeUnload); return () => { window.removeEventListener('beforeunload', handleBeforeUnload); }; }, []); // 处理图片上传 const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (!file) return; // 验证文件类型和大小 const validTypes = ["image/jpeg", "image/png", "image/svg+xml"]; const maxSize = 2 * 1024 * 1024; // 2MB if (!validTypes.includes(file.type)) { toast.error("不支持的文件类型", { description: "请上传 JPG, PNG 或 SVG 格式的图片", }); return; } if (file.size > maxSize) { toast.error("文件过大", { description: "图片大小不能超过 2MB", }); return; } // 设置预览 const reader = new FileReader(); reader.onload = (e) => { setLogoPreview(e.target?.result as string); }; reader.readAsDataURL(file); setLogoFile(file); setPendingLogoFile(file); // 检查用户是否已登录 if (!session) { // 保存当前表单数据 const currentFormData = form.getValues(); saveFormData({ ...currentFormData, pendingLogoFile: true, }); toast.info("需要登录", { description: "上传logo需要先登录,即将跳转到登录页面", }); // 跳转到登录页面,并设置重定向回当前页面 const currentUrl = window.location.pathname + window.location.search; router.push(`/auth/sign-in?redirectTo=${encodeURIComponent(currentUrl)}`); return; } // 用户已登录,直接上传图片 await uploadLogo(file); }; // 删除图片 const handleDeleteLogo = () => { setLogoFile(null); setLogoPreview(null); setPendingLogoFile(null); form.setValue("logoUrl", ""); }; // 提交表单 const onSubmit = async (values: FormValues) => { try { setIsSubmitting(true); // 处理标签,将逗号分隔的字符串转换为数组 const tags = values.tags ? values.tags.split(",").map(tag => tag.trim()) : []; await submitMutation.mutateAsync({ ...values, tags, title: values.name, }); } catch (error) { setIsSubmitting(false); // 错误已在mutation的onError中处理 } }; // 处理取消操作 const handleCancel = () => { // 清除保存的数据 clearFormData(); setPendingLogoFile(null); router.back(); }; return ( <div className="flex min-h-screen items-center justify-center py-10 px-4 sm:px-6 lg:px-8"> <div className="w-full max-w-3xl"> <div className="mb-8 text-center"> <Button variant="ghost" onClick={handleCancel} className="absolute left-4 top-4 md:left-8 md:top-8" > <ArrowLeft className="mr-2 h-4 w-4" /> 返回 </Button> <h1 className="text-3xl font-bold mb-2">提交应用</h1> <p className="text-muted-foreground">分享您的 MCP 客户端、服务器或应用,让更多的人了解和使用您的产品</p> </div> <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <Card className="w-full"> <CardHeader> <CardTitle>应用信息</CardTitle> <CardDescription>请填写您的应用信息,带 * 的字段为必填项</CardDescription> </CardHeader> <CardContent className="space-y-6"> <div className="space-y-2"> <Label>应用类型 *</Label> <div className="grid grid-cols-3 gap-4"> <div className={`flex flex-col items-center justify-center p-4 rounded-lg border cursor-pointer transition-colors ${appType === "client" ? "border-primary bg-primary/5" : "border-muted hover:border-primary/50" }`} onClick={() => form.setValue("type", "client")} > <Laptop className="h-8 w-8 mb-2" /> <span className="cursor-pointer">客户端</span> <p className="text-xs text-muted-foreground text-center mt-1">MCP 客户端应用</p> </div> <div className={`flex flex-col items-center justify-center p-4 rounded-lg border cursor-pointer transition-colors ${appType === "server" ? "border-primary bg-primary/5" : "border-muted hover:border-primary/50" }`} onClick={() => form.setValue("type", "server")} > <Server className="h-8 w-8 mb-2" /> <span className="cursor-pointer">服务器</span> <p className="text-xs text-muted-foreground text-center mt-1">MCP 服务器应用</p> </div> <div className={`flex flex-col items-center justify-center p-4 rounded-lg border cursor-pointer transition-colors ${appType === "application" ? "border-primary bg-primary/5" : "border-muted hover:border-primary/50" }`} onClick={() => form.setValue("type", "application")} > <BrainCircuit className="h-8 w-8 mb-2" /> <span className="cursor-pointer">AI 应用</span> <p className="text-xs text-muted-foreground text-center mt-1">基于 MCP 的应用</p> </div> </div> <input type="hidden" {...form.register("type")} /> </div> <div className="grid gap-6 md:grid-cols-2"> <FormField control={form.control} name="nickname" render={({ field }) => ( <FormItem> <FormLabel>个人昵称 *</FormLabel> <FormControl> <Input placeholder="您的昵称" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>邮箱 *</FormLabel> <FormControl> <Input type="email" placeholder="your@email.com" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </div> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>应用名称 *</FormLabel> <FormControl> <Input placeholder="例如:My MCP Client" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="description" render={({ field }) => ( <FormItem> <FormLabel>应用描述 *</FormLabel> <FormControl> <Textarea placeholder="简要描述您的应用功能和特点" {...field} rows={4} /> </FormControl> <FormMessage /> </FormItem> )} /> <div className="grid gap-6 md:grid-cols-2"> <FormField control={form.control} name="website" render={({ field }) => ( <FormItem> <FormLabel>官方网站</FormLabel> <FormControl> <Input placeholder="https://example.com" {...field} type="url" /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="github" render={({ field }) => ( <FormItem> <FormLabel>GitHub 仓库</FormLabel> <FormControl> <Input placeholder="https://github.com/username/repo" {...field} type="url" /> </FormControl> <FormMessage /> </FormItem> )} /> </div> <FormField control={form.control} name="tags" render={({ field }) => ( <FormItem> <FormLabel>标签</FormLabel> <FormControl> <Input placeholder="用逗号分隔,例如:AI 助手, MCP 客户端, 自然语言" {...field} /> </FormControl> <FormDescription>用逗号分隔多个标签</FormDescription> <FormMessage /> </FormItem> )} /> <div className="space-y-2"> <Label htmlFor="logo">应用图标 *</Label> <div className="flex items-center justify-center w-full"> <label htmlFor="logo" className={cn( "flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer", logoPreview ? "border-primary" : "bg-muted/40 hover:bg-muted/60", isUploading && "opacity-50 cursor-not-allowed" )} > {logoPreview ? ( <div className="relative w-full h-full flex items-center justify-center"> <img src={logoPreview} alt="Logo preview" className="max-h-28 max-w-28 object-contain" /> <button type="button" onClick={(e) => { e.preventDefault(); handleDeleteLogo(); }} className="absolute top-2 right-2 p-1 rounded-full bg-background/80 hover:bg-background" disabled={isUploading} > <X className="h-4 w-4" /> </button> </div> ) : ( <div className="flex flex-col items-center justify-center pt-5 pb-6"> {isUploading ? ( <div className="w-8 h-8 mb-3 border-2 border-current border-t-transparent rounded-full animate-spin" /> ) : ( <Upload className="w-8 h-8 mb-3 text-muted-foreground" /> )} <p className="mb-2 text-sm text-muted-foreground"> {isUploading ? "上传中..." : "点击上传 或拖放文件"} </p> <p className="text-xs text-muted-foreground">SVG, PNG 或 JPG (最大 2MB)</p> </div> )} <input id="logo" type="file" className="hidden" accept="image/png,image/jpeg,image/svg+xml" onChange={handleLogoChange} disabled={isUploading} /> </label> </div> </div> </CardContent> <CardFooter className="flex justify-between"> <Button variant="outline" type="button" onClick={handleCancel}> 取消 </Button> <Button type="submit" disabled={isSubmitting || isUploading} className="min-w-[100px]" > {isSubmitting || isUploading ? ( <div className="flex items-center"> <span className="mr-2">提交中</span> <div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> </div> ) : ( "提交应用" )} </Button> </CardFooter> </Card> </form> </Form> </div> </div> ); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/metacode0602/open-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server