mirror of
https://github.com/f/awesome-chatgpt-prompts.git
synced 2026-02-12 15:52: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",
|
"enableContextBlocksToBuild": "Enable some context blocks to build a prompt",
|
||||||
"testContext": "Test Context"
|
"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")
|
notifications Notification[] @relation("NotificationRecipient")
|
||||||
notificationsActed Notification[] @relation("NotificationActor")
|
notificationsActed Notification[] @relation("NotificationActor")
|
||||||
collections Collection[]
|
collections Collection[]
|
||||||
|
userPromptExamples UserPromptExample[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -127,6 +128,7 @@ model Prompt {
|
|||||||
outgoingConnections PromptConnection[] @relation("ConnectionSource")
|
outgoingConnections PromptConnection[] @relation("ConnectionSource")
|
||||||
incomingConnections PromptConnection[] @relation("ConnectionTarget")
|
incomingConnections PromptConnection[] @relation("ConnectionTarget")
|
||||||
collectedBy Collection[]
|
collectedBy Collection[]
|
||||||
|
userExamples UserPromptExample[]
|
||||||
bestWithModels String[] // Model slugs this prompt works best with (max 3), e.g. ["gpt-4o", "claude-3-5-sonnet"]
|
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"]}]
|
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
|
workflowLink String? // URL to test/demo the workflow when prompt has previous/next connections
|
||||||
@@ -269,6 +271,21 @@ model Collection {
|
|||||||
@@map("collections")
|
@@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 {
|
model PromptReport {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
reason ReportReason
|
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" } } },
|
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 }),
|
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 { VersionCompareButton } from "@/components/prompts/version-compare-button";
|
||||||
import { FeaturePromptButton } from "@/components/prompts/feature-prompt-button";
|
import { FeaturePromptButton } from "@/components/prompts/feature-prompt-button";
|
||||||
import { UnlistPromptButton } from "@/components/prompts/unlist-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 { DelistBanner } from "@/components/prompts/delist-banner";
|
||||||
import { RestorePromptButton } from "@/components/prompts/restore-prompt-button";
|
import { RestorePromptButton } from "@/components/prompts/restore-prompt-button";
|
||||||
import { CommentSection } from "@/components/comments";
|
import { CommentSection } from "@/components/comments";
|
||||||
@@ -535,12 +535,16 @@ export default async function PromptPage({ params }: PromptPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="content" className="space-y-4 mt-0">
|
<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 && (
|
{prompt.mediaUrl && (
|
||||||
<MediaPreview
|
<UserExamplesSection
|
||||||
mediaUrl={prompt.mediaUrl}
|
mediaUrl={prompt.mediaUrl}
|
||||||
title={prompt.title}
|
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" } } },
|
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 }),
|
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 { PinButton } from "@/components/prompts/pin-button";
|
||||||
import { RunPromptButton } from "@/components/prompts/run-prompt-button";
|
import { RunPromptButton } from "@/components/prompts/run-prompt-button";
|
||||||
import { VariableFillModal, hasVariables, renderContentWithVariables } from "@/components/prompts/variable-fill-modal";
|
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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { AudioPlayer } from "@/components/prompts/audio-player";
|
import { AudioPlayer } from "@/components/prompts/audio-player";
|
||||||
import {
|
import {
|
||||||
@@ -73,6 +74,15 @@ export interface PromptCardProps {
|
|||||||
outgoingConnections?: number;
|
outgoingConnections?: number;
|
||||||
incomingConnections?: number;
|
incomingConnections?: number;
|
||||||
};
|
};
|
||||||
|
userExamples?: Array<{
|
||||||
|
id: string;
|
||||||
|
mediaUrl: string;
|
||||||
|
user: {
|
||||||
|
username: string;
|
||||||
|
name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
showPinButton?: boolean;
|
showPinButton?: boolean;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
@@ -178,7 +188,14 @@ export function PromptCard({ prompt, showPinButton = false, isPinned = false }:
|
|||||||
{hasMediaBackground && (
|
{hasMediaBackground && (
|
||||||
<div className="relative bg-muted">
|
<div className="relative bg-muted">
|
||||||
{prompt.mediaUrl && !imageError ? (
|
{prompt.mediaUrl && !imageError ? (
|
||||||
isVideo ? (
|
prompt.userExamples && prompt.userExamples.length > 0 ? (
|
||||||
|
<ExamplesSlider
|
||||||
|
examples={prompt.userExamples}
|
||||||
|
mainMediaUrl={prompt.mediaUrl}
|
||||||
|
title={prompt.title}
|
||||||
|
isVideo={isVideo}
|
||||||
|
/>
|
||||||
|
) : isVideo ? (
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={prompt.mediaUrl}
|
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