From b856594c98b3914357ce0b6c60818409273ac5e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fatih=20Kadir=20Ak=C4=B1n?= Date: Sun, 1 Feb 2026 21:59:13 +0300 Subject: [PATCH] feat(api): add user prompt examples functionality --- messages/en.json | 25 ++ .../migration.sql | 23 ++ prisma/schema.prisma | 17 + src/app/api/prompts/[id]/examples/route.ts | 161 +++++++++ src/app/api/prompts/route.ts | 15 + src/app/prompts/[id]/page.tsx | 12 +- src/app/prompts/page.tsx | 15 + src/components/prompts/add-example-dialog.tsx | 310 ++++++++++++++++++ src/components/prompts/examples-slider.tsx | 114 +++++++ .../prompts/media-preview-with-examples.tsx | 147 +++++++++ src/components/prompts/prompt-card.tsx | 19 +- .../prompts/user-examples-gallery.tsx | 205 ++++++++++++ .../prompts/user-examples-section.tsx | 56 ++++ 13 files changed, 1114 insertions(+), 5 deletions(-) create mode 100644 prisma/migrations/20260201175000_add_user_prompt_examples/migration.sql create mode 100644 src/app/api/prompts/[id]/examples/route.ts create mode 100644 src/components/prompts/add-example-dialog.tsx create mode 100644 src/components/prompts/examples-slider.tsx create mode 100644 src/components/prompts/media-preview-with-examples.tsx create mode 100644 src/components/prompts/user-examples-gallery.tsx create mode 100644 src/components/prompts/user-examples-section.tsx diff --git a/messages/en.json b/messages/en.json index 88c11489..0cce30d3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" } } diff --git a/prisma/migrations/20260201175000_add_user_prompt_examples/migration.sql b/prisma/migrations/20260201175000_add_user_prompt_examples/migration.sql new file mode 100644 index 00000000..7a04578f --- /dev/null +++ b/prisma/migrations/20260201175000_add_user_prompt_examples/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2aebf1f3..6ef020de 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/api/prompts/[id]/examples/route.ts b/src/app/api/prompts/[id]/examples/route.ts new file mode 100644 index 00000000..8009c90a --- /dev/null +++ b/src/app/api/prompts/[id]/examples/route.ts @@ -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 }); + } +} diff --git a/src/app/api/prompts/route.ts b/src/app/api/prompts/route.ts index 274d6aa6..26eb7a6f 100644 --- a/src/app/api/prompts/route.ts +++ b/src/app/api/prompts/route.ts @@ -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 }), diff --git a/src/app/prompts/[id]/page.tsx b/src/app/prompts/[id]/page.tsx index b8edc7ff..beaa5334 100644 --- a/src/app/prompts/[id]/page.tsx +++ b/src/app/prompts/[id]/page.tsx @@ -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) { - {/* Media Preview (for image/video prompts) */} + {/* Media Preview with User Examples (for image/video prompts) */} {prompt.mediaUrl && ( - )} diff --git a/src/app/prompts/page.tsx b/src/app/prompts/page.tsx index b76d97a5..fdb8b6f8 100644 --- a/src/app/prompts/page.tsx +++ b/src/app/prompts/page.tsx @@ -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 }), diff --git a/src/components/prompts/add-example-dialog.tsx b/src/components/prompts/add-example-dialog.tsx new file mode 100644 index 00000000..031709ea --- /dev/null +++ b/src/components/prompts/add-example-dialog.tsx @@ -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(null); + const [uploadEnabled, setUploadEnabled] = useState(false); + const [activeTab, setActiveTab] = useState("url"); + const fileInputRef = useRef(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) => { + 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 ( + + + {asThumbnail ? ( +
{ + 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" + > + + {t("addMyExample")} +
+ ) : ( + + )} +
+ +
+ + {t("addExampleTitle")} + + {isVideoType ? t("addExampleDescriptionVideo") : t("addExampleDescriptionImage")} + + +
+ {mediaUrl ? ( +
+ +
+
+ {isVideoType ? ( +
+ +
+
+ ) : ( + + + + + {t("urlTab")} + + + + {t("uploadTab")} + + + + + setMediaUrl(e.target.value)} + /> + + +
fileInputRef.current?.click()} + > + {isUploading ? ( + + ) : ( + + )} +

+ {isUploading ? t("uploading") : t(isVideoType ? "clickToUploadVideo" : "clickToUpload")} +

+

+ {t("maxFileSize")} +

+
+ +
+
+ )} + +
+ +