Skip to content

Commit b856594

Browse files
committed
feat(api): add user prompt examples functionality
1 parent 41e8270 commit b856594

File tree

13 files changed

+1114
-5
lines changed

13 files changed

+1114
-5
lines changed

messages/en.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2174,5 +2174,30 @@
21742174
"enableContextBlocksToBuild": "Enable some context blocks to build a prompt",
21752175
"testContext": "Test Context"
21762176
}
2177+
},
2178+
"userExamples": {
2179+
"addMyExample": "Add My Example",
2180+
"addExampleTitle": "Add Your Example",
2181+
"addExampleDescriptionImage": "Share an image you created using this prompt.",
2182+
"addExampleDescriptionVideo": "Share a video you created using this prompt.",
2183+
"imageUrl": "Image URL",
2184+
"videoUrl": "Video URL",
2185+
"imagePreview": "Image Preview",
2186+
"videoPreview": "Video Preview",
2187+
"urlTab": "URL",
2188+
"uploadTab": "Upload",
2189+
"clickToUpload": "Click to upload an image",
2190+
"clickToUploadVideo": "Click to upload a video",
2191+
"uploading": "Uploading...",
2192+
"maxFileSize": "Max 4MB (JPEG, PNG, GIF, WebP)",
2193+
"fileTooLarge": "File is too large. Maximum size is 4MB.",
2194+
"invalidFileType": "Invalid file type. Only JPEG, PNG, GIF, and WebP are allowed.",
2195+
"invalidVideoType": "Invalid file type. Only MP4 videos are allowed.",
2196+
"commentOptional": "Comment (optional)",
2197+
"commentPlaceholder": "Describe your creation or share tips...",
2198+
"cancel": "Cancel",
2199+
"submit": "Submit",
2200+
"communityExamples": "Community Examples",
2201+
"userExample": "User example"
21772202
}
21782203
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- CreateTable
2+
CREATE TABLE "user_prompt_examples" (
3+
"id" TEXT NOT NULL,
4+
"mediaUrl" TEXT NOT NULL,
5+
"comment" TEXT,
6+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
7+
"promptId" TEXT NOT NULL,
8+
"userId" TEXT NOT NULL,
9+
10+
CONSTRAINT "user_prompt_examples_pkey" PRIMARY KEY ("id")
11+
);
12+
13+
-- CreateIndex
14+
CREATE INDEX "user_prompt_examples_promptId_idx" ON "user_prompt_examples"("promptId");
15+
16+
-- CreateIndex
17+
CREATE INDEX "user_prompt_examples_userId_idx" ON "user_prompt_examples"("userId");
18+
19+
-- AddForeignKey
20+
ALTER TABLE "user_prompt_examples" ADD CONSTRAINT "user_prompt_examples_promptId_fkey" FOREIGN KEY ("promptId") REFERENCES "prompts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
21+
22+
-- AddForeignKey
23+
ALTER TABLE "user_prompt_examples" ADD CONSTRAINT "user_prompt_examples_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

prisma/schema.prisma

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ model User {
4747
notifications Notification[] @relation("NotificationRecipient")
4848
notificationsActed Notification[] @relation("NotificationActor")
4949
collections Collection[]
50+
userPromptExamples UserPromptExample[]
5051
5152
@@map("users")
5253
}
@@ -127,6 +128,7 @@ model Prompt {
127128
outgoingConnections PromptConnection[] @relation("ConnectionSource")
128129
incomingConnections PromptConnection[] @relation("ConnectionTarget")
129130
collectedBy Collection[]
131+
userExamples UserPromptExample[]
130132
bestWithModels String[] // Model slugs this prompt works best with (max 3), e.g. ["gpt-4o", "claude-3-5-sonnet"]
131133
bestWithMCP Json? // MCP configs array, e.g. [{command: "npx -y @mcp/server", tools: ["tool1"]}]
132134
workflowLink String? // URL to test/demo the workflow when prompt has previous/next connections
@@ -269,6 +271,21 @@ model Collection {
269271
@@map("collections")
270272
}
271273

274+
model UserPromptExample {
275+
id String @id @default(cuid())
276+
mediaUrl String
277+
comment String?
278+
createdAt DateTime @default(now())
279+
promptId String
280+
userId String
281+
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
282+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
283+
284+
@@index([promptId])
285+
@@index([userId])
286+
@@map("user_prompt_examples")
287+
}
288+
272289
model PromptReport {
273290
id String @id @default(cuid())
274291
reason ReportReason
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { auth } from "@/lib/auth";
3+
import { db } from "@/lib/db";
4+
import { z } from "zod";
5+
6+
const addExampleSchema = z.object({
7+
mediaUrl: z.string().url(),
8+
comment: z.string().max(500).optional(),
9+
});
10+
11+
export async function GET(
12+
req: NextRequest,
13+
{ params }: { params: Promise<{ id: string }> }
14+
) {
15+
const { id: promptId } = await params;
16+
17+
const prompt = await db.prompt.findUnique({
18+
where: { id: promptId },
19+
select: { id: true, type: true },
20+
});
21+
22+
if (!prompt) {
23+
return NextResponse.json({ error: "Prompt not found" }, { status: 404 });
24+
}
25+
26+
// Only allow examples for IMAGE and VIDEO prompts
27+
if (prompt.type !== "IMAGE" && prompt.type !== "VIDEO") {
28+
return NextResponse.json({ error: "Examples not supported for this prompt type" }, { status: 400 });
29+
}
30+
31+
const examples = await db.userPromptExample.findMany({
32+
where: { promptId },
33+
orderBy: { createdAt: "desc" },
34+
include: {
35+
user: {
36+
select: {
37+
id: true,
38+
username: true,
39+
name: true,
40+
avatar: true,
41+
},
42+
},
43+
},
44+
});
45+
46+
return NextResponse.json({ examples });
47+
}
48+
49+
export async function POST(
50+
req: NextRequest,
51+
{ params }: { params: Promise<{ id: string }> }
52+
) {
53+
const session = await auth();
54+
55+
if (!session?.user) {
56+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
57+
}
58+
59+
const { id: promptId } = await params;
60+
61+
try {
62+
const body = await req.json();
63+
const { mediaUrl, comment } = addExampleSchema.parse(body);
64+
65+
const prompt = await db.prompt.findUnique({
66+
where: { id: promptId },
67+
select: { id: true, type: true, isPrivate: true, authorId: true },
68+
});
69+
70+
if (!prompt) {
71+
return NextResponse.json({ error: "Prompt not found" }, { status: 404 });
72+
}
73+
74+
// Only allow examples for IMAGE and VIDEO prompts
75+
if (prompt.type !== "IMAGE" && prompt.type !== "VIDEO") {
76+
return NextResponse.json({ error: "Examples not supported for this prompt type" }, { status: 400 });
77+
}
78+
79+
// Don't allow adding examples to private prompts (unless owner)
80+
if (prompt.isPrivate && prompt.authorId !== session.user.id) {
81+
return NextResponse.json({ error: "Cannot add example to private prompt" }, { status: 403 });
82+
}
83+
84+
const example = await db.userPromptExample.create({
85+
data: {
86+
mediaUrl,
87+
comment: comment || null,
88+
promptId,
89+
userId: session.user.id,
90+
},
91+
include: {
92+
user: {
93+
select: {
94+
id: true,
95+
username: true,
96+
name: true,
97+
avatar: true,
98+
},
99+
},
100+
},
101+
});
102+
103+
return NextResponse.json({ example });
104+
} catch (error) {
105+
if (error instanceof z.ZodError) {
106+
return NextResponse.json({ error: "Invalid input", details: error.issues }, { status: 400 });
107+
}
108+
console.error("Failed to add example:", error);
109+
return NextResponse.json({ error: "Failed to add example" }, { status: 500 });
110+
}
111+
}
112+
113+
export async function DELETE(
114+
req: NextRequest,
115+
{ params }: { params: Promise<{ id: string }> }
116+
) {
117+
const session = await auth();
118+
119+
if (!session?.user) {
120+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
121+
}
122+
123+
const { id: promptId } = await params;
124+
125+
try {
126+
const { searchParams } = new URL(req.url);
127+
const exampleId = searchParams.get("exampleId");
128+
129+
if (!exampleId) {
130+
return NextResponse.json({ error: "exampleId required" }, { status: 400 });
131+
}
132+
133+
const example = await db.userPromptExample.findUnique({
134+
where: { id: exampleId },
135+
select: { id: true, userId: true, promptId: true },
136+
});
137+
138+
if (!example) {
139+
return NextResponse.json({ error: "Example not found" }, { status: 404 });
140+
}
141+
142+
if (example.promptId !== promptId) {
143+
return NextResponse.json({ error: "Example does not belong to this prompt" }, { status: 400 });
144+
}
145+
146+
// Only allow owner or admin to delete
147+
const isAdmin = session.user.role === "ADMIN";
148+
if (example.userId !== session.user.id && !isAdmin) {
149+
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
150+
}
151+
152+
await db.userPromptExample.delete({
153+
where: { id: exampleId },
154+
});
155+
156+
return NextResponse.json({ deleted: true });
157+
} catch (error) {
158+
console.error("Failed to delete example:", error);
159+
return NextResponse.json({ error: "Failed to delete example" }, { status: 500 });
160+
}
161+
}

src/app/api/prompts/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,21 @@ export async function GET(request: Request) {
403403
incomingConnections: { where: { label: { not: "related" } } },
404404
},
405405
},
406+
userExamples: {
407+
take: 5,
408+
orderBy: { createdAt: "desc" },
409+
select: {
410+
id: true,
411+
mediaUrl: true,
412+
user: {
413+
select: {
414+
username: true,
415+
name: true,
416+
avatar: true,
417+
},
418+
},
419+
},
420+
},
406421
},
407422
}),
408423
db.prompt.count({ where }),

src/app/prompts/[id]/page.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { VersionCompareModal } from "@/components/prompts/version-compare-modal"
2222
import { VersionCompareButton } from "@/components/prompts/version-compare-button";
2323
import { FeaturePromptButton } from "@/components/prompts/feature-prompt-button";
2424
import { UnlistPromptButton } from "@/components/prompts/unlist-prompt-button";
25-
import { MediaPreview } from "@/components/prompts/media-preview";
25+
import { UserExamplesSection } from "@/components/prompts/user-examples-section";
2626
import { DelistBanner } from "@/components/prompts/delist-banner";
2727
import { RestorePromptButton } from "@/components/prompts/restore-prompt-button";
2828
import { CommentSection } from "@/components/comments";
@@ -535,12 +535,16 @@ export default async function PromptPage({ params }: PromptPageProps) {
535535
</div>
536536

537537
<TabsContent value="content" className="space-y-4 mt-0">
538-
{/* Media Preview (for image/video prompts) */}
538+
{/* Media Preview with User Examples (for image/video prompts) */}
539539
{prompt.mediaUrl && (
540-
<MediaPreview
540+
<UserExamplesSection
541541
mediaUrl={prompt.mediaUrl}
542542
title={prompt.title}
543-
type={prompt.type}
543+
type={prompt.type}
544+
promptId={prompt.id}
545+
isLoggedIn={!!session?.user}
546+
currentUserId={session?.user?.id}
547+
isAdmin={isAdmin}
544548
/>
545549
)}
546550

src/app/prompts/page.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,21 @@ function getCachedPrompts(
123123
incomingConnections: { where: { label: { not: "related" } } },
124124
},
125125
},
126+
userExamples: {
127+
take: 5,
128+
orderBy: { createdAt: "desc" },
129+
select: {
130+
id: true,
131+
mediaUrl: true,
132+
user: {
133+
select: {
134+
username: true,
135+
name: true,
136+
avatar: true,
137+
},
138+
},
139+
},
140+
},
126141
},
127142
}),
128143
db.prompt.count({ where }),

0 commit comments

Comments
 (0)