Build a ChatGPT-powered image generator and creators will be able to install it to their whops.
npx create-next-app@latest ai-image-generator -e https://github.com/whopio/whop-nextjs-app-template
cd ai-image-generator
pnpm i
pnpm dev
.env.local
filelocalhost
mode. See example:
pnpm add prisma @prisma/client
pnpm prisma init
prisma init
command will create a new prisma
directory with a schema.prisma
file
Now, go copy your database connection strings from Supabase for Prisma to use. Then paste the values in your .env.local
file.
Replace your password with [YOUR-PASSWORD]
prisma/schema.prisma
with:
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
model Experience {
id String @unique
prompt String
}
These are custom scripts we defined in the package.json file to load your env from .env.local and run the prisma commands. If you want to use the native prisma CLI, you’ll need to move your.env.local
to.env
and run the commands manually.
pnpm prisma:generate
pnpm prisma:db:push
pnpm add openai sharp react-dropzone @radix-ui/react-slot gsap
pnpm dlx shadcn@latest add button
.env.local
:
# OpenAI API Key for image generation
OPENAI_API_KEY=your_openai_api_key_here
<ImageUploader>
"use client";
import { Button } from "@/components/ui/button";
import gsap from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
import Image from "next/image";
import { useCallback, useEffect, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
gsap.registerPlugin(DrawSVGPlugin);
function Loader() {
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (!svgRef.current) return;
const mid = gsap.utils.toArray("#mid *").reverse();
const fatTl = gsap.timeline();
fatTl.fromTo(
"#fat *",
{
drawSVG: "0% 20%",
},
{
drawSVG: "40% 69%",
stagger: {
each: 0.05,
repeat: -1,
yoyo: true,
},
duration: 0.75,
ease: "sine.inOut",
}
);
const midTl = gsap.timeline();
midTl.fromTo(
mid,
{
drawSVG: "0% 20%",
},
{
drawSVG: "56% 86%",
stagger: {
each: 0.08,
repeat: -1,
yoyo: true,
},
duration: 0.81,
ease: "sine.inOut",
}
);
const thinTl = gsap.timeline();
thinTl.fromTo(
"#thin *",
{
drawSVG: "20% 51%",
},
{
drawSVG: "40% 80%",
stagger: {
each: 0.092,
repeat: -1,
yoyo: true,
},
duration: 1.4,
ease: "sine.inOut",
}
);
const mainTl = gsap.timeline();
mainTl.add([fatTl, midTl, thinTl], 0);
return () => {
mainTl.kill();
};
}, []);
return (
<div className="w-full h-full flex items-center justify-center">
<svg
ref={svgRef}
id="mainSVG"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 800 600"
className="w-100 h-100"
role="img"
aria-label="Loading animation"
>
<title>Loading animation</title>
<linearGradient
id="grad1"
x1="393.05"
y1="400"
x2="393.05"
y2="200"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#3D28F7" />
<stop offset="1" stopColor="#FF3C20" />
</linearGradient>
<linearGradient
id="grad2"
x1="393.05"
y1="391.01"
x2="393.05"
y2="247.71"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#F72785" />
<stop offset="1" stopColor="#FFEE2A" />
</linearGradient>
<linearGradient
id="grad3"
x1="393.05"
y1="400"
x2="393.05"
y2="200"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#FF6820" />
<stop offset="1" stopColor="#D1FE21" />
</linearGradient>
<linearGradient
id="grad4"
x1="393.05"
y1="400"
x2="393.05"
y2="250"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#35AAF9" />
<stop offset="1" stopColor="#993BDC" />
</linearGradient>
<g>
<g
id="bg"
stroke="url(#grad3)"
fill="none"
strokeLinecap="round"
strokeMiterlimit="10"
>
<path d="M594.5,250v-.29L594.6,350" />
<line x1="580.5" y1="390" x2="580.32" y2="210" />
<line x1="565.5" y1="415" x2="565.28" y2="185" />
<line x1="550.5" y1="434" x2="550.24" y2="166" />
<line x1="535.5" y1="449" x2="535.22" y2="151" />
<line x1="520.5" y1="462" x2="520.2" y2="138" />
<line x1="505.5" y1="472" x2="505.18" y2="128" />
<line x1="490.5" y1="480" x2="490.16" y2="120" />
<line x1="475.5" y1="487" x2="475.14" y2="113" />
<line x1="460.5" y1="492" x2="460.14" y2="108" />
<line x1="445.5" y1="496" x2="445.12" y2="104" />
<line x1="430.5" y1="499" x2="430.12" y2="101" />
<line x1="415.5" y1="501" x2="415.12" y2="99" />
<line x1="400.5" y1="501" x2="400.12" y2="99" />
<line x1="385.5" y1="501" x2="385.12" y2="99" />
<line x1="370.5" y1="499" x2="370.12" y2="101" />
<line x1="355.5" y1="496" x2="355.12" y2="104" />
<line x1="340.5" y1="492" x2="340.14" y2="108" />
<line x1="325.5" y1="487" x2="325.14" y2="113" />
<line x1="310.5" y1="480" x2="310.16" y2="120" />
<line x1="295.5" y1="472" x2="295.18" y2="128" />
<line x1="280.5" y1="462" x2="280.2" y2="138" />
<line x1="265.5" y1="449" x2="265.22" y2="151" />
<line x1="250.5" y1="434" x2="250.24" y2="166" />
<line x1="235.5" y1="415" x2="235.28" y2="185" />
<line x1="220.5" y1="390" x2="220.32" y2="210" />
<polyline points="204.5 250 204.5 350.29 204.5 350" />
</g>
<g
id="thin"
stroke="url(#grad1)"
fill="none"
strokeLinecap="round"
strokeMiterlimit="10"
strokeWidth="2"
>
<path d="M594.6,350l-.1-100.29V250" />
<line x1="580.5" y1="390" x2="580.32" y2="210" />
<line x1="565.5" y1="415" x2="565.28" y2="185" />
<line x1="550.5" y1="434" x2="550.24" y2="166" />
<line x1="535.5" y1="449" x2="535.22" y2="151" />
<line x1="520.5" y1="462" x2="520.2" y2="138" />
<line x1="505.5" y1="472" x2="505.18" y2="128" />
<line x1="490.5" y1="480" x2="490.16" y2="120" />
<line x1="475.5" y1="487" x2="475.14" y2="113" />
<line x1="460.5" y1="492" x2="460.14" y2="108" />
<line x1="445.5" y1="496" x2="445.12" y2="104" />
<line x1="430.5" y1="499" x2="430.12" y2="101" />
<line x1="415.5" y1="501" x2="415.12" y2="99" />
<line x1="400.5" y1="501" x2="400.12" y2="99" />
<line x1="385.5" y1="501" x2="385.12" y2="99" />
<line x1="370.5" y1="499" x2="370.12" y2="101" />
<line x1="355.5" y1="496" x2="355.12" y2="104" />
<line x1="340.5" y1="492" x2="340.14" y2="108" />
<line x1="325.5" y1="487" x2="325.14" y2="113" />
<line x1="310.5" y1="480" x2="310.16" y2="120" />
<line x1="295.5" y1="472" x2="295.18" y2="128" />
<line x1="280.5" y1="462" x2="280.2" y2="138" />
<line x1="265.5" y1="449" x2="265.22" y2="151" />
<line x1="250.5" y1="434" x2="250.24" y2="166" />
<line x1="235.5" y1="415" x2="235.28" y2="185" />
<line x1="220.5" y1="390" x2="220.32" y2="210" />
<polyline points="204.5 350 204.5 350.29 204.5 250" />
</g>
<g
id="mid"
stroke="url(#grad2)"
fill="none"
strokeLinecap="round"
strokeMiterlimit="10"
strokeWidth="4"
>
<path d="M594.6,350l-.1-100.29V250" />
<line x1="580.5" y1="390" x2="580.32" y2="210" />
<line x1="565.5" y1="415" x2="565.28" y2="185" />
<line x1="550.5" y1="434" x2="550.24" y2="166" />
<line x1="535.5" y1="449" x2="535.22" y2="151" />
<line x1="520.5" y1="462" x2="520.2" y2="138" />
<line x1="505.5" y1="472" x2="505.18" y2="128" />
<line x1="490.5" y1="480" x2="490.16" y2="120" />
<line x1="475.5" y1="487" x2="475.14" y2="113" />
<line x1="460.5" y1="492" x2="460.14" y2="108" />
<line x1="445.5" y1="496" x2="445.12" y2="104" />
<line x1="430.5" y1="499" x2="430.12" y2="101" />
<line x1="415.5" y1="501" x2="415.12" y2="99" />
<line x1="400.5" y1="501" x2="400.12" y2="99" />
<line x1="385.5" y1="501" x2="385.12" y2="99" />
<line x1="370.5" y1="499" x2="370.12" y2="101" />
<line x1="355.5" y1="496" x2="355.12" y2="104" />
<line x1="340.5" y1="492" x2="340.14" y2="108" />
<line x1="325.5" y1="487" x2="325.14" y2="113" />
<line x1="310.5" y1="480" x2="310.16" y2="120" />
<line x1="295.5" y1="472" x2="295.18" y2="128" />
<line x1="280.5" y1="462" x2="280.2" y2="138" />
<line x1="265.5" y1="449" x2="265.22" y2="151" />
<line x1="250.5" y1="434" x2="250.24" y2="166" />
<line x1="235.5" y1="415" x2="235.28" y2="185" />
<line x1="220.5" y1="390" x2="220.32" y2="210" />
<polyline points="204.5 350 204.5 350.29 204.5 250" />
</g>
<g
id="fat"
stroke="url(#grad4)"
fill="none"
strokeLinecap="round"
strokeMiterlimit="10"
strokeWidth="7"
>
<path d="M594.6,350l-.1-100.29V250" />
<line x1="580.5" y1="390" x2="580.32" y2="210" />
<line x1="565.5" y1="415" x2="565.28" y2="185" />
<line x1="550.5" y1="434" x2="550.24" y2="166" />
<line x1="535.5" y1="449" x2="535.22" y2="151" />
<line x1="520.5" y1="462" x2="520.2" y2="138" />
<line x1="505.5" y1="472" x2="505.18" y2="128" />
<line x1="490.5" y1="480" x2="490.16" y2="120" />
<line x1="475.5" y1="487" x2="475.14" y2="113" />
<line x1="460.5" y1="492" x2="460.14" y2="108" />
<line x1="445.5" y1="496" x2="445.12" y2="104" />
<line x1="430.5" y1="499" x2="430.12" y2="101" />
<line x1="415.5" y1="501" x2="415.12" y2="99" />
<line x1="400.5" y1="501" x2="400.12" y2="99" />
<line x1="385.5" y1="501" x2="385.12" y2="99" />
<line x1="370.5" y1="499" x2="370.12" y2="101" />
<line x1="355.5" y1="496" x2="355.12" y2="104" />
<line x1="340.5" y1="492" x2="340.14" y2="108" />
<line x1="325.5" y1="487" x2="325.14" y2="113" />
<line x1="310.5" y1="480" x2="310.16" y2="120" />
<line x1="295.5" y1="472" x2="295.18" y2="128" />
<line x1="280.5" y1="462" x2="280.2" y2="138" />
<line x1="265.5" y1="449" x2="265.22" y2="151" />
<line x1="250.5" y1="434" x2="250.24" y2="166" />
<line x1="235.5" y1="415" x2="235.28" y2="185" />
<line x1="220.5" y1="390" x2="220.32" y2="210" />
<polyline points="204.5 350 204.5 350.29 204.5 250" />
</g>
</g>
</svg>
</div>
);
}
export default function ImageUploader({
experienceId,
}: {
experienceId: string;
}) {
const [image, setImage] = useState<{
file: File;
preview: string;
} | null>(null);
const [generatedImage, setGeneratedImage] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
// Clean up the object URL when the image is changed
useEffect(() => {
const objectUrl = image?.preview;
if (objectUrl) {
return () => {
URL.revokeObjectURL(objectUrl);
};
}
}, [image?.preview]);
const onDrop = useCallback((acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (file) {
setImage({
file,
preview: URL.createObjectURL(file),
});
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
"image/*": [".jpeg", ".jpg", ".png", ".gif"],
},
maxFiles: 1,
});
const handleUpload = async () => {
if (!image) return;
try {
const response = await fetch(
`/api/experiences/${experienceId}/generate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: image.file,
}
);
if (!response.ok) {
throw new Error("Failed to get upload URL");
}
const data = await response.json();
setGeneratedImage(data.imageUrl);
} catch (error) {
console.error("Error uploading image:", error);
throw error;
}
};
const handleGenerate = async () => {
if (!image) return;
setIsGenerating(true);
setUploadProgress(0);
try {
await handleUpload();
} catch (error) {
console.error("Error generating image:", error);
} finally {
setIsGenerating(false);
}
};
const handleReset = () => {
setImage(null);
setGeneratedImage(null);
setUploadProgress(0);
};
if (isGenerating) {
return (
<div className="w-full max-w-2xl mx-auto p-4 space-y-8">
<div className="w-full aspect-square flex items-center justify-center">
<Loader />
</div>
<div className="flex gap-4">
<Button onClick={handleReset} variant="outline" className="flex-1">
Cancel
</Button>
<Button disabled className="flex-1">
Generating...
</Button>
</div>
</div>
);
}
const displayImage = generatedImage || image?.preview;
return (
<div className="w-full max-w-2xl mx-auto p-4 space-y-8">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
${
isDragActive
? "border-blue-500 bg-blue-50"
: "border-gray-300 hover:border-blue-400"
}`}
>
<input {...getInputProps()} capture="environment" />
{displayImage ? (
<div className="relative w-full aspect-square">
<Image
src={displayImage}
alt="Uploaded image"
fill
className="object-contain rounded-lg"
/>
</div>
) : (
<div className="space-y-4">
<div className="text-4xl">📸</div>
<p className="text-gray-600">
{isDragActive
? "Drop the image here..."
: "Drag & drop an image here, or click to select"}
</p>
<p className="text-sm text-gray-500">Supports JPG, PNG, GIF</p>
</div>
)}
</div>
{image && (
<div className="flex flex-col gap-4">
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
<div className="flex gap-4">
<Button onClick={handleReset} variant="outline" className="flex-1">
Reset
</Button>
<Button onClick={handleGenerate} className="flex-1">
Generate Image
</Button>
</div>
</div>
)}
</div>
);
}
<ExperiencePrompt>
import type { AccessLevel } from "@whop/api";
import Link from "next/link";
import ImageUploader from "./image-uploader";
import { Button } from "./ui/button";
export default function ExperiencePrompt({
prompt,
accessLevel,
experienceId,
}: {
prompt: string;
accessLevel: AccessLevel;
experienceId: string;
}) {
return (
<div>
<div className="flex justify-center items-center">
<div className="text-4xl font-bold text-center">
{prompt ? `"${prompt}"` : "Creator has not set a prompt yet."}
</div>
</div>
{accessLevel === "admin" && (
<div className="flex justify-center items-center">
<Link href={`/experiences/${experienceId}/edit`}>
<Button variant={"link"}>Edit prompt</Button>
</Link>
</div>
)}
{prompt ? <ImageUploader experienceId={experienceId} /> : null}
</div>
);
}
<EditExperiencePrompt>
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
export default function EditExperiencePage({
experienceId,
}: {
experienceId: string;
}) {
const router = useRouter();
const [prompt, setPrompt] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const response = await fetch(`/api/experiences/${experienceId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt }),
});
if (!response.ok) {
throw new Error("Failed to update experience");
}
router.push(`/experiences/${experienceId}`);
router.refresh();
} catch (error) {
console.error("Error updating experience:", error);
// You might want to show an error message to the user here
} finally {
setIsLoading(false);
}
};
return (
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Edit Prompt</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="prompt"
className="block text-sm font-medium text-gray-700 mb-2"
>
Prompt
</label>
<textarea
id="prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="w-full min-h-[200px] p-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter a new prompt here..."
required
/>
</div>
<div className="flex gap-4">
<Button
type="submit"
disabled={isLoading}
className="bg-blue-500 hover:bg-blue-600 text-white"
>
{isLoading ? "Saving..." : "Save Changes"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isLoading}
>
Cancel
</Button>
</div>
</form>
</div>
);
}
app/experiences/[experienceId]/page.tsx
import ExperiencePrompt from "@/components/experience-prompt";
import { whopSdk } from "@/lib/whop-sdk";
import { PrismaClient } from "@prisma/client";
import { headers } from "next/headers";
const prisma = new PrismaClient();
async function findOrCreateExperience(experienceId: string) {
let experience = await prisma.experience.findUnique({
where: { id: experienceId },
});
if (!experience) {
experience = await prisma.experience.create({
data: {
id: experienceId,
prompt: "",
},
});
}
return experience;
}
export default async function ExperiencePage({
params,
}: {
params: Promise<{ experienceId: string }>;
}) {
const headersList = await headers();
const { userId } = await whopSdk.verifyUserToken(headersList);
const { experienceId } = await params;
const experience = await findOrCreateExperience(experienceId);
const hasAccess = await whopSdk.access.checkIfUserHasAccessToExperience({
userId,
experienceId,
});
return (
<div className="flex flex-col gap-4 p-4 h-screen items-center justify-center">
<ExperiencePrompt
prompt={experience.prompt}
accessLevel={hasAccess.accessLevel}
experienceId={experienceId}
/>
</div>
);
}
app/experiences/[experienceId]/edit/page.tsx
import EditExperiencePrompt from "@/components/edit-experience-prompt";
export default async function Page({
params,
}: {
params: Promise<{ experienceId: string }>;
}) {
const { experienceId } = await params;
return <EditExperiencePrompt experienceId={experienceId} />;
}
app/api/experiences/[experienceId]/generate/route.ts
import { whopSdk } from "@/lib/whop-sdk";
import { PrismaClient } from "@prisma/client";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import OpenAI from "openai";
import sharp from "sharp";
const prisma = new PrismaClient();
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ experienceId: string }> }
) {
try {
const { experienceId } = await params;
if (!experienceId) {
return NextResponse.json(
{ error: "Missing experienceId" },
{ status: 400 }
);
}
const headersList = await headers();
const userToken = await whopSdk.verifyUserToken(headersList);
if (!userToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const hasAccess = await whopSdk.access.checkIfUserHasAccessToExperience({
userId: userToken.userId,
experienceId,
});
if (!hasAccess.hasAccess) {
return NextResponse.json(
{ error: "Unauthorized, no access" },
{ status: 401 }
);
}
const [publicUser, experience] = await Promise.all([
whopSdk.users.getUser({
userId: userToken.userId,
}),
prisma.experience.findUnique({
where: {
id: experienceId,
},
}),
]);
if (!request.body || !experience?.prompt) {
return NextResponse.json(
{ error: "Image and prompt are required" },
{ status: 400 }
);
}
const originalFile = new File(
[
await sharp(await request.clone().arrayBuffer())
.png()
.toBuffer(),
],
`${Date.now()}-original.png`,
{
type: "image/png",
}
);
// Generate image using DALL-E with prompt
const response = await openai.images.edit({
model: "gpt-image-1",
image: originalFile,
prompt: experience.prompt,
n: 1,
size: "auto",
quality: "low",
});
console.log("Response:", response);
// Get the base64 image data from the response
const base64Image = response.data?.[0]?.b64_json;
if (!base64Image) {
throw new Error("No image data returned from OpenAI");
}
const generatedImageBuffer = Buffer.from(base64Image, "base64");
const generationId = crypto.randomUUID();
const [originalFileUploadResponse, uploadResponse] = await Promise.all([
whopSdk.attachments.uploadAttachment({
file: originalFile,
record: "forum_post",
}),
whopSdk.attachments.uploadAttachment({
file: new File(
[generatedImageBuffer],
`${generationId}-generated.png`,
{
type: "image/png",
}
),
record: "forum_post",
}),
]);
const whopExperience = await whopSdk.experiences.getExperience({
experienceId,
});
const companyId = whopExperience.experience.company.id;
const generatedAttachmentId = uploadResponse.directUploadId;
const originalAttachmentId = originalFileUploadResponse.directUploadId;
const forum = await whopSdk.forums.findOrCreateForum({
experienceId: experience.id,
name: "AI Uploads",
});
const forumId = forum.createForum?.id;
const post = await whopSdk.forums.createForumPost({
forumExperienceId: forumId,
content: `@${publicUser.publicUser?.username} generated this image with the prompt: "${experience.prompt}"\n\nTry it yourself here: https://whop.com/hub/${companyId}/${experience.id}/app\n\nBefore vs After ⬇️`,
attachments: [
{ directUploadId: originalAttachmentId },
{ directUploadId: generatedAttachmentId },
],
});
return NextResponse.json({
success: true,
imageUrl: uploadResponse.attachment.source.url,
postId: post?.id,
});
} catch (error) {
console.error("Error generating image:", error);
return NextResponse.json(
{ error: "Failed to generate image" },
{ status: 500 }
);
}
}
app/api/experiences/[experienceId]/route.ts
import { whopSdk } from "@/lib/whop-sdk";
import { PrismaClient } from "@prisma/client";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function PUT(request: Request) {
try {
const { prompt } = await request.json();
const headersList = await headers();
const userToken = await whopSdk.verifyUserToken(headersList);
if (!userToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const match = url.pathname.match(/experiences\/([^/]+)/);
const experienceId = match ? match[1] : null;
if (!experienceId) {
return NextResponse.json(
{ error: "Missing experienceId" },
{ status: 400 }
);
}
const hasAccess = await whopSdk.access.checkIfUserHasAccessToExperience({
userId: userToken.userId,
experienceId,
});
if (hasAccess.accessLevel !== "admin") {
return NextResponse.json(
{ error: "Unauthorized, not admin" },
{ status: 401 }
);
}
const updatedExperience = await prisma.experience.update({
where: {
id: experienceId,
},
data: {
prompt,
},
});
await whopSdk.notifications.sendNotification({
content: prompt,
experienceId,
title: "Prompt updated ✨",
});
return NextResponse.json(updatedExperience);
} catch (error) {
console.error("Error updating experience:", error);
return NextResponse.json(
{ error: "Failed to update experience" },
{ status: 500 }
);
}
}
scripts
section of your package.json
to generate the Prisma client: "scripts": {
"postinstall": "prisma generate"
}
Vercel functions automatically timeout after 60 seconds on a hobby account. Images might take longer than 60 seconds. This will cause errors. You can upgrade to a paid account to avoid this or swap out the image generator to a different API.Your AI image generation app is now ready! Users can upload images, apply AI transformations based on prompts, and share their creations in the community forum.
Was this page helpful?