feat(api): add user prompt examples functionality

This commit is contained in:
Fatih Kadir Akın
2026-02-01 21:59:13 +03:00
parent 41e8270e66
commit b856594c98
13 changed files with 1114 additions and 5 deletions

View File

@@ -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"
}
}

View File

@@ -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;

View File

@@ -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

View 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 });
}
}

View File

@@ -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 }),

View File

@@ -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}
/>
)}

View File

@@ -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 }),

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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}

View 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>
);
}

View 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}
/>
);
}