mirror of
https://github.com/f/awesome-chatgpt-prompts.git
synced 2026-02-12 07:42:47 +00:00
feat(api): add user prompt examples functionality
This commit is contained in:
@@ -2174,5 +2174,30 @@
|
||||
"enableContextBlocksToBuild": "Enable some context blocks to build a prompt",
|
||||
"testContext": "Test Context"
|
||||
}
|
||||
},
|
||||
"userExamples": {
|
||||
"addMyExample": "Add My Example",
|
||||
"addExampleTitle": "Add Your Example",
|
||||
"addExampleDescriptionImage": "Share an image you created using this prompt.",
|
||||
"addExampleDescriptionVideo": "Share a video you created using this prompt.",
|
||||
"imageUrl": "Image URL",
|
||||
"videoUrl": "Video URL",
|
||||
"imagePreview": "Image Preview",
|
||||
"videoPreview": "Video Preview",
|
||||
"urlTab": "URL",
|
||||
"uploadTab": "Upload",
|
||||
"clickToUpload": "Click to upload an image",
|
||||
"clickToUploadVideo": "Click to upload a video",
|
||||
"uploading": "Uploading...",
|
||||
"maxFileSize": "Max 4MB (JPEG, PNG, GIF, WebP)",
|
||||
"fileTooLarge": "File is too large. Maximum size is 4MB.",
|
||||
"invalidFileType": "Invalid file type. Only JPEG, PNG, GIF, and WebP are allowed.",
|
||||
"invalidVideoType": "Invalid file type. Only MP4 videos are allowed.",
|
||||
"commentOptional": "Comment (optional)",
|
||||
"commentPlaceholder": "Describe your creation or share tips...",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Submit",
|
||||
"communityExamples": "Community Examples",
|
||||
"userExample": "User example"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_prompt_examples" (
|
||||
"id" TEXT NOT NULL,
|
||||
"mediaUrl" TEXT NOT NULL,
|
||||
"comment" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"promptId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "user_prompt_examples_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_prompt_examples_promptId_idx" ON "user_prompt_examples"("promptId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_prompt_examples_userId_idx" ON "user_prompt_examples"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_prompt_examples" ADD CONSTRAINT "user_prompt_examples_promptId_fkey" FOREIGN KEY ("promptId") REFERENCES "prompts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_prompt_examples" ADD CONSTRAINT "user_prompt_examples_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -47,6 +47,7 @@ model User {
|
||||
notifications Notification[] @relation("NotificationRecipient")
|
||||
notificationsActed Notification[] @relation("NotificationActor")
|
||||
collections Collection[]
|
||||
userPromptExamples UserPromptExample[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -127,6 +128,7 @@ model Prompt {
|
||||
outgoingConnections PromptConnection[] @relation("ConnectionSource")
|
||||
incomingConnections PromptConnection[] @relation("ConnectionTarget")
|
||||
collectedBy Collection[]
|
||||
userExamples UserPromptExample[]
|
||||
bestWithModels String[] // Model slugs this prompt works best with (max 3), e.g. ["gpt-4o", "claude-3-5-sonnet"]
|
||||
bestWithMCP Json? // MCP configs array, e.g. [{command: "npx -y @mcp/server", tools: ["tool1"]}]
|
||||
workflowLink String? // URL to test/demo the workflow when prompt has previous/next connections
|
||||
@@ -269,6 +271,21 @@ model Collection {
|
||||
@@map("collections")
|
||||
}
|
||||
|
||||
model UserPromptExample {
|
||||
id String @id @default(cuid())
|
||||
mediaUrl String
|
||||
comment String?
|
||||
createdAt DateTime @default(now())
|
||||
promptId String
|
||||
userId String
|
||||
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([promptId])
|
||||
@@index([userId])
|
||||
@@map("user_prompt_examples")
|
||||
}
|
||||
|
||||
model PromptReport {
|
||||
id String @id @default(cuid())
|
||||
reason ReportReason
|
||||
|
||||
161
src/app/api/prompts/[id]/examples/route.ts
Normal file
161
src/app/api/prompts/[id]/examples/route.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { z } from "zod";
|
||||
|
||||
const addExampleSchema = z.object({
|
||||
mediaUrl: z.string().url(),
|
||||
comment: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id: promptId } = await params;
|
||||
|
||||
const prompt = await db.prompt.findUnique({
|
||||
where: { id: promptId },
|
||||
select: { id: true, type: true },
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
return NextResponse.json({ error: "Prompt not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only allow examples for IMAGE and VIDEO prompts
|
||||
if (prompt.type !== "IMAGE" && prompt.type !== "VIDEO") {
|
||||
return NextResponse.json({ error: "Examples not supported for this prompt type" }, { status: 400 });
|
||||
}
|
||||
|
||||
const examples = await db.userPromptExample.findMany({
|
||||
where: { promptId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ examples });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: promptId } = await params;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { mediaUrl, comment } = addExampleSchema.parse(body);
|
||||
|
||||
const prompt = await db.prompt.findUnique({
|
||||
where: { id: promptId },
|
||||
select: { id: true, type: true, isPrivate: true, authorId: true },
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
return NextResponse.json({ error: "Prompt not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only allow examples for IMAGE and VIDEO prompts
|
||||
if (prompt.type !== "IMAGE" && prompt.type !== "VIDEO") {
|
||||
return NextResponse.json({ error: "Examples not supported for this prompt type" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Don't allow adding examples to private prompts (unless owner)
|
||||
if (prompt.isPrivate && prompt.authorId !== session.user.id) {
|
||||
return NextResponse.json({ error: "Cannot add example to private prompt" }, { status: 403 });
|
||||
}
|
||||
|
||||
const example = await db.userPromptExample.create({
|
||||
data: {
|
||||
mediaUrl,
|
||||
comment: comment || null,
|
||||
promptId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ example });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: "Invalid input", details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error("Failed to add example:", error);
|
||||
return NextResponse.json({ error: "Failed to add example" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: promptId } = await params;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const exampleId = searchParams.get("exampleId");
|
||||
|
||||
if (!exampleId) {
|
||||
return NextResponse.json({ error: "exampleId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const example = await db.userPromptExample.findUnique({
|
||||
where: { id: exampleId },
|
||||
select: { id: true, userId: true, promptId: true },
|
||||
});
|
||||
|
||||
if (!example) {
|
||||
return NextResponse.json({ error: "Example not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (example.promptId !== promptId) {
|
||||
return NextResponse.json({ error: "Example does not belong to this prompt" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Only allow owner or admin to delete
|
||||
const isAdmin = session.user.role === "ADMIN";
|
||||
if (example.userId !== session.user.id && !isAdmin) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.userPromptExample.delete({
|
||||
where: { id: exampleId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ deleted: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete example:", error);
|
||||
return NextResponse.json({ error: "Failed to delete example" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -403,6 +403,21 @@ export async function GET(request: Request) {
|
||||
incomingConnections: { where: { label: { not: "related" } } },
|
||||
},
|
||||
},
|
||||
userExamples: {
|
||||
take: 5,
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
mediaUrl: true,
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.prompt.count({ where }),
|
||||
|
||||
@@ -22,7 +22,7 @@ import { VersionCompareModal } from "@/components/prompts/version-compare-modal"
|
||||
import { VersionCompareButton } from "@/components/prompts/version-compare-button";
|
||||
import { FeaturePromptButton } from "@/components/prompts/feature-prompt-button";
|
||||
import { UnlistPromptButton } from "@/components/prompts/unlist-prompt-button";
|
||||
import { MediaPreview } from "@/components/prompts/media-preview";
|
||||
import { UserExamplesSection } from "@/components/prompts/user-examples-section";
|
||||
import { DelistBanner } from "@/components/prompts/delist-banner";
|
||||
import { RestorePromptButton } from "@/components/prompts/restore-prompt-button";
|
||||
import { CommentSection } from "@/components/comments";
|
||||
@@ -535,12 +535,16 @@ export default async function PromptPage({ params }: PromptPageProps) {
|
||||
</div>
|
||||
|
||||
<TabsContent value="content" className="space-y-4 mt-0">
|
||||
{/* Media Preview (for image/video prompts) */}
|
||||
{/* Media Preview with User Examples (for image/video prompts) */}
|
||||
{prompt.mediaUrl && (
|
||||
<MediaPreview
|
||||
<UserExamplesSection
|
||||
mediaUrl={prompt.mediaUrl}
|
||||
title={prompt.title}
|
||||
type={prompt.type}
|
||||
type={prompt.type}
|
||||
promptId={prompt.id}
|
||||
isLoggedIn={!!session?.user}
|
||||
currentUserId={session?.user?.id}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -123,6 +123,21 @@ function getCachedPrompts(
|
||||
incomingConnections: { where: { label: { not: "related" } } },
|
||||
},
|
||||
},
|
||||
userExamples: {
|
||||
take: 5,
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
mediaUrl: true,
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.prompt.count({ where }),
|
||||
|
||||
310
src/components/prompts/add-example-dialog.tsx
Normal file
310
src/components/prompts/add-example-dialog.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ImagePlus, Loader2, Upload, Link, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface AddExampleDialogProps {
|
||||
promptId: string;
|
||||
promptType: string;
|
||||
isLoggedIn: boolean;
|
||||
onExampleAdded?: () => void;
|
||||
asThumbnail?: boolean;
|
||||
}
|
||||
|
||||
export function AddExampleDialog({
|
||||
promptId,
|
||||
promptType,
|
||||
isLoggedIn,
|
||||
onExampleAdded,
|
||||
asThumbnail = false,
|
||||
}: AddExampleDialogProps) {
|
||||
const t = useTranslations("userExamples");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mediaUrl, setMediaUrl] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [uploadEnabled, setUploadEnabled] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<string>("url");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isVideoType = promptType === "VIDEO";
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/config/storage")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setUploadEnabled(data.mode !== "url"))
|
||||
.catch(() => setUploadEnabled(false));
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isLoggedIn) {
|
||||
window.location.href = "/login";
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const maxSize = 4 * 1024 * 1024; // 4MB
|
||||
if (file.size > maxSize) {
|
||||
setError(t("fileTooLarge"));
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedImageTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||
const allowedVideoTypes = ["video/mp4"];
|
||||
const allowedTypes = isVideoType ? allowedVideoTypes : allowedImageTypes;
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
setError(t(isVideoType ? "invalidVideoType" : "invalidFileType"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Upload failed");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setMediaUrl(result.url);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload failed");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/prompts/${promptId}/examples`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mediaUrl,
|
||||
comment: comment.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Failed to add example");
|
||||
}
|
||||
|
||||
setMediaUrl("");
|
||||
setComment("");
|
||||
setOpen(false);
|
||||
onExampleAdded?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to add example");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearMedia = () => {
|
||||
setMediaUrl("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const isValidUrl = (url: string) => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{asThumbnail ? (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
className="flex flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-muted-foreground/30 bg-muted/30 hover:border-primary/50 hover:bg-muted/50 transition-colors cursor-pointer w-16 h-16 sm:w-20 sm:h-20"
|
||||
>
|
||||
<ImagePlus className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-[8px] text-muted-foreground font-medium">{t("addMyExample")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={handleClick} className="gap-1.5">
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
{t("addMyExample")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("addExampleTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isVideoType ? t("addExampleDescriptionVideo") : t("addExampleDescriptionImage")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
{mediaUrl ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{isVideoType ? t("videoPreview") : t("imagePreview")}</Label>
|
||||
<div className="relative inline-block w-full">
|
||||
<div className="rounded-lg overflow-hidden border bg-muted/30">
|
||||
{isVideoType ? (
|
||||
<video
|
||||
src={mediaUrl}
|
||||
controls
|
||||
className="w-full max-h-48 object-contain"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={mediaUrl}
|
||||
alt="Preview"
|
||||
className="w-full max-h-48 object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute -top-2 -right-2 h-6 w-6"
|
||||
onClick={clearMedia}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="url" className="gap-1.5">
|
||||
<Link className="h-4 w-4" />
|
||||
{t("urlTab")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="upload" className="gap-1.5" disabled={!uploadEnabled}>
|
||||
<Upload className="h-4 w-4" />
|
||||
{t("uploadTab")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="url" className="space-y-2 mt-3">
|
||||
<Label htmlFor="mediaUrl">{isVideoType ? t("videoUrl") : t("imageUrl")}</Label>
|
||||
<Input
|
||||
id="mediaUrl"
|
||||
type="url"
|
||||
placeholder={isVideoType ? "https://example.com/my-video.mp4" : "https://example.com/my-image.png"}
|
||||
value={mediaUrl}
|
||||
onChange={(e) => setMediaUrl(e.target.value)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="upload" className="mt-3">
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-2 p-6 border-2 border-dashed rounded-md cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{isUploading ? t("uploading") : t(isVideoType ? "clickToUploadVideo" : "clickToUpload")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("maxFileSize")}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={isVideoType ? "video/mp4" : "image/jpeg,image/png,image/gif,image/webp"}
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="comment">{t("commentOptional")}</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
placeholder={t("commentPlaceholder")}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{comment.length}/500
|
||||
</p>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isLoading || isUploading}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading || isUploading || !mediaUrl || !isValidUrl(mediaUrl)}>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
{t("submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
114
src/components/prompts/examples-slider.tsx
Normal file
114
src/components/prompts/examples-slider.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
|
||||
interface ExamplesSliderProps {
|
||||
examples: Array<{
|
||||
id: string;
|
||||
mediaUrl: string;
|
||||
user: {
|
||||
username: string;
|
||||
name: string | null;
|
||||
avatar: string | null;
|
||||
};
|
||||
}>;
|
||||
mainMediaUrl: string;
|
||||
title: string;
|
||||
isVideo?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExamplesSlider({
|
||||
examples,
|
||||
mainMediaUrl,
|
||||
title,
|
||||
isVideo = false,
|
||||
className,
|
||||
}: ExamplesSliderProps) {
|
||||
const allMedia = [
|
||||
{ id: "main", mediaUrl: mainMediaUrl, user: null },
|
||||
...examples.map((e) => ({ id: e.id, mediaUrl: e.mediaUrl, user: e.user })),
|
||||
];
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
const nextSlide = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % allMedia.length);
|
||||
}, [allMedia.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHovering || allMedia.length <= 1) return;
|
||||
|
||||
const interval = setInterval(nextSlide, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isHovering, allMedia.length, nextSlide]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
style={{ height: "400px" }}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => { setIsHovering(false); setCurrentIndex(0); }}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col transition-transform duration-500 ease-in-out h-full"
|
||||
style={{ transform: `translateY(-${currentIndex * 100}%)` }}
|
||||
>
|
||||
{allMedia.map((media) => (
|
||||
<div key={media.id} className="w-full h-full flex-shrink-0">
|
||||
{isVideo ? (
|
||||
<video
|
||||
src={media.mediaUrl}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
autoPlay
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={media.mediaUrl}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover object-top"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* User tag - positioned at bottom, just above title */}
|
||||
{allMedia[currentIndex]?.user && (
|
||||
<div className="absolute bottom-1 left-3 z-20 flex items-center gap-1">
|
||||
<Avatar className="h-3.5 w-3.5 border border-white/30">
|
||||
<AvatarImage src={allMedia[currentIndex].user?.avatar || undefined} />
|
||||
<AvatarFallback className="text-[7px] bg-muted">
|
||||
{allMedia[currentIndex].user?.name?.charAt(0) || allMedia[currentIndex].user?.username.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[10px] font-medium text-foreground">@{allMedia[currentIndex].user?.username}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slide indicators */}
|
||||
{allMedia.length > 1 && (
|
||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1 z-10">
|
||||
{allMedia.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
className={cn(
|
||||
"w-1.5 h-1.5 rounded-full transition-all",
|
||||
index === currentIndex
|
||||
? "bg-white w-3"
|
||||
: "bg-white/50 hover:bg-white/70"
|
||||
)}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
src/components/prompts/media-preview-with-examples.tsx
Normal file
147
src/components/prompts/media-preview-with-examples.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ImageIcon, AlertTriangle } from "lucide-react";
|
||||
import { AudioPlayer } from "./audio-player";
|
||||
import { UserExamplesGallery } from "./user-examples-gallery";
|
||||
|
||||
interface UserExample {
|
||||
id: string;
|
||||
mediaUrl: string;
|
||||
comment: string | null;
|
||||
createdAt: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string | null;
|
||||
avatar: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface MediaPreviewWithExamplesProps {
|
||||
mediaUrl: string;
|
||||
title: string;
|
||||
type: string;
|
||||
promptId: string;
|
||||
currentUserId?: string;
|
||||
isAdmin?: boolean;
|
||||
refreshTrigger?: number;
|
||||
renderAddButton?: (asThumbnail: boolean) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function MediaPreviewWithExamples({
|
||||
mediaUrl,
|
||||
title,
|
||||
type,
|
||||
promptId,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
refreshTrigger = 0,
|
||||
renderAddButton,
|
||||
}: MediaPreviewWithExamplesProps) {
|
||||
const t = useTranslations("prompts");
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [selectedExample, setSelectedExample] = useState<UserExample | null>(null);
|
||||
|
||||
const handleSelectExample = (example: UserExample | null) => {
|
||||
setSelectedExample(example);
|
||||
setHasError(false);
|
||||
};
|
||||
|
||||
const displayUrl = selectedExample?.mediaUrl || mediaUrl;
|
||||
const displayTitle = selectedExample?.comment || title;
|
||||
const isShowingExample = !!selectedExample;
|
||||
|
||||
const supportsExamples = type === "IMAGE" || type === "VIDEO";
|
||||
|
||||
if (hasError && !isShowingExample) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span>{t("mediaLoadError")}</span>
|
||||
</div>
|
||||
<div className="rounded-lg overflow-hidden border bg-muted/30 h-48 flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<ImageIcon className="h-12 w-12 mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm">{t("mediaUnavailable")}</p>
|
||||
</div>
|
||||
</div>
|
||||
{supportsExamples && (
|
||||
<UserExamplesGallery
|
||||
promptId={promptId}
|
||||
promptType={type}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
onSelectExample={handleSelectExample}
|
||||
refreshTrigger={refreshTrigger}
|
||||
renderAddButton={renderAddButton}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg overflow-hidden border bg-muted/30 relative">
|
||||
{type === "VIDEO" ? (
|
||||
<video
|
||||
src={displayUrl}
|
||||
controls
|
||||
className="w-full max-h-[500px] object-contain block"
|
||||
onError={() => setHasError(true)}
|
||||
/>
|
||||
) : type === "AUDIO" ? (
|
||||
<AudioPlayer
|
||||
src={displayUrl}
|
||||
onError={() => setHasError(true)}
|
||||
/>
|
||||
) : (
|
||||
<a
|
||||
href={displayUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block relative"
|
||||
>
|
||||
{/* Blurred background for vertical images */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center blur-2xl opacity-50 scale-110"
|
||||
style={{ backgroundImage: `url(${displayUrl})` }}
|
||||
/>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt={displayTitle}
|
||||
className="relative w-full max-h-[500px] object-contain block"
|
||||
onError={() => setHasError(true)}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
{isShowingExample && selectedExample && (
|
||||
<div className="absolute bottom-2 left-2 right-2 px-3 py-2 rounded-lg bg-black/70 backdrop-blur-sm">
|
||||
<p className="text-xs text-white/90">
|
||||
<span className="font-medium">@{selectedExample.user.username}</span>
|
||||
{selectedExample.comment && (
|
||||
<span className="ml-2 text-white/70">{selectedExample.comment}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{supportsExamples && (
|
||||
<UserExamplesGallery
|
||||
promptId={promptId}
|
||||
promptType={type}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
onSelectExample={handleSelectExample}
|
||||
refreshTrigger={refreshTrigger}
|
||||
renderAddButton={renderAddButton}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { prettifyJson } from "@/lib/format";
|
||||
import { PinButton } from "@/components/prompts/pin-button";
|
||||
import { RunPromptButton } from "@/components/prompts/run-prompt-button";
|
||||
import { VariableFillModal, hasVariables, renderContentWithVariables } from "@/components/prompts/variable-fill-modal";
|
||||
import { ExamplesSlider } from "@/components/prompts/examples-slider";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { AudioPlayer } from "@/components/prompts/audio-player";
|
||||
import {
|
||||
@@ -73,6 +74,15 @@ export interface PromptCardProps {
|
||||
outgoingConnections?: number;
|
||||
incomingConnections?: number;
|
||||
};
|
||||
userExamples?: Array<{
|
||||
id: string;
|
||||
mediaUrl: string;
|
||||
user: {
|
||||
username: string;
|
||||
name: string | null;
|
||||
avatar: string | null;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
showPinButton?: boolean;
|
||||
isPinned?: boolean;
|
||||
@@ -178,7 +188,14 @@ export function PromptCard({ prompt, showPinButton = false, isPinned = false }:
|
||||
{hasMediaBackground && (
|
||||
<div className="relative bg-muted">
|
||||
{prompt.mediaUrl && !imageError ? (
|
||||
isVideo ? (
|
||||
prompt.userExamples && prompt.userExamples.length > 0 ? (
|
||||
<ExamplesSlider
|
||||
examples={prompt.userExamples}
|
||||
mainMediaUrl={prompt.mediaUrl}
|
||||
title={prompt.title}
|
||||
isVideo={isVideo}
|
||||
/>
|
||||
) : isVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={prompt.mediaUrl}
|
||||
|
||||
205
src/components/prompts/user-examples-gallery.tsx
Normal file
205
src/components/prompts/user-examples-gallery.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface UserExample {
|
||||
id: string;
|
||||
mediaUrl: string;
|
||||
comment: string | null;
|
||||
createdAt: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string | null;
|
||||
avatar: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface UserExamplesGalleryProps {
|
||||
promptId: string;
|
||||
promptType: string;
|
||||
currentUserId?: string;
|
||||
isAdmin?: boolean;
|
||||
onSelectExample?: (example: UserExample | null) => void;
|
||||
refreshTrigger?: number;
|
||||
renderAddButton?: (asThumbnail: boolean) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function UserExamplesGallery({
|
||||
promptId,
|
||||
promptType,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
onSelectExample,
|
||||
refreshTrigger = 0,
|
||||
renderAddButton,
|
||||
}: UserExamplesGalleryProps) {
|
||||
const t = useTranslations("userExamples");
|
||||
const [examples, setExamples] = useState<UserExample[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const fetchExamples = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/prompts/${promptId}/examples`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setExamples(data.examples);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch examples:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchExamples();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [promptId, refreshTrigger]);
|
||||
|
||||
const handleSelect = (example: UserExample) => {
|
||||
if (selectedId === example.id) {
|
||||
setSelectedId(null);
|
||||
onSelectExample?.(null);
|
||||
} else {
|
||||
setSelectedId(example.id);
|
||||
onSelectExample?.(example);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (exampleId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setDeletingId(exampleId);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/prompts/${promptId}/examples?exampleId=${exampleId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setExamples((prev) => prev.filter((ex) => ex.id !== exampleId));
|
||||
if (selectedId === exampleId) {
|
||||
setSelectedId(null);
|
||||
onSelectExample?.(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete example:", error);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (examples.length === 0) {
|
||||
return renderAddButton ? <>{renderAddButton(false)}</> : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("communityExamples")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{examples.map((example) => {
|
||||
const canDelete = currentUserId === example.user.id || isAdmin;
|
||||
const isSelected = selectedId === example.id;
|
||||
|
||||
return (
|
||||
<Tooltip key={example.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSelect(example)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelect(example);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative group rounded-lg overflow-hidden border-2 transition-all cursor-pointer",
|
||||
"w-16 h-16 sm:w-20 sm:h-20",
|
||||
isSelected
|
||||
? "border-primary ring-2 ring-primary/20"
|
||||
: "border-transparent hover:border-muted-foreground/30"
|
||||
)}
|
||||
>
|
||||
{promptType === "VIDEO" ? (
|
||||
<video
|
||||
src={example.mediaUrl}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={example.mediaUrl}
|
||||
alt={example.comment || t("userExample")}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Avatar className="h-4 w-4 border border-white/50 shrink-0">
|
||||
<AvatarImage src={example.user.avatar || undefined} />
|
||||
<AvatarFallback className="text-[6px] bg-primary text-primary-foreground">
|
||||
{example.user.name?.charAt(0) || example.user.username.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[8px] text-white/90 truncate font-medium">
|
||||
@{example.user.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute top-1 right-1 h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => handleDelete(example.id, e)}
|
||||
disabled={deletingId === example.id}
|
||||
>
|
||||
{deletingId === example.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">@{example.user.username}</p>
|
||||
{example.comment && (
|
||||
<p className="text-xs text-muted-foreground">{example.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{renderAddButton && renderAddButton(true)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/components/prompts/user-examples-section.tsx
Normal file
56
src/components/prompts/user-examples-section.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { MediaPreviewWithExamples } from "./media-preview-with-examples";
|
||||
import { AddExampleDialog } from "./add-example-dialog";
|
||||
|
||||
interface UserExamplesSectionProps {
|
||||
mediaUrl: string;
|
||||
title: string;
|
||||
type: string;
|
||||
promptId: string;
|
||||
isLoggedIn: boolean;
|
||||
currentUserId?: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export function UserExamplesSection({
|
||||
mediaUrl,
|
||||
title,
|
||||
type,
|
||||
promptId,
|
||||
isLoggedIn,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
}: UserExamplesSectionProps) {
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
const handleExampleAdded = useCallback(() => {
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
const supportsExamples = type === "IMAGE" || type === "VIDEO";
|
||||
|
||||
const renderAddButton = useCallback((asThumbnail: boolean) => (
|
||||
<AddExampleDialog
|
||||
promptId={promptId}
|
||||
promptType={type}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onExampleAdded={handleExampleAdded}
|
||||
asThumbnail={asThumbnail}
|
||||
/>
|
||||
), [promptId, type, isLoggedIn, handleExampleAdded]);
|
||||
|
||||
return (
|
||||
<MediaPreviewWithExamples
|
||||
mediaUrl={mediaUrl}
|
||||
title={title}
|
||||
type={type}
|
||||
promptId={promptId}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
refreshTrigger={refreshTrigger}
|
||||
renderAddButton={supportsExamples ? renderAddButton : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user