├── .env_example ├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── (auth) │ ├── layout.tsx │ ├── sign-in │ │ └── [[...sign-in]] │ │ │ └── page.tsx │ └── sign-up │ │ └── [[...sign-up]] │ │ └── page.tsx ├── (root) │ ├── credits │ │ └── page.tsx │ ├── layout.tsx │ ├── page.tsx │ ├── profile │ │ └── page.tsx │ └── transformations │ │ ├── [id] │ │ ├── page.tsx │ │ └── update │ │ │ └── page.tsx │ │ ├── add │ │ └── [type] │ │ │ └── page.tsx │ │ └── page.tsx ├── api │ └── webhooks │ │ ├── clerk │ │ └── route.ts │ │ └── stripe │ │ └── route.ts ├── globals.css ├── icon.ico └── layout.tsx ├── components.json ├── components ├── shared │ ├── Checkout.tsx │ ├── Collection.tsx │ ├── CustomField.tsx │ ├── DeleteConfirmation.tsx │ ├── Header.tsx │ ├── InsufficientCreditsModal.tsx │ ├── MediaUploader.tsx │ ├── MobileNav.tsx │ ├── Search.tsx │ ├── Sidebar.tsx │ ├── TransformationForm.tsx │ └── TransformedImage.tsx └── ui │ ├── alert-dialog.tsx │ ├── button.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── pagination.tsx │ ├── select.tsx │ ├── sheet.tsx │ ├── toast.tsx │ ├── toaster.tsx │ └── use-toast.ts ├── constants └── index.ts ├── lib ├── actions │ ├── image.actions.ts │ ├── transaction.actions.ts │ └── user.actions.ts ├── database │ ├── models │ │ ├── image.model.ts │ │ ├── transaction.model.ts │ │ └── user.model.ts │ └── mongoose.ts └── utils.ts ├── middleware.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── assets │ ├── icons │ │ ├── add.svg │ │ ├── bag.svg │ │ ├── camera.svg │ │ ├── caret-down.svg │ │ ├── check.svg │ │ ├── close.svg │ │ ├── coins.svg │ │ ├── credit-coins.svg │ │ ├── cross.svg │ │ ├── download.svg │ │ ├── filter.svg │ │ ├── free-plan.svg │ │ ├── home.svg │ │ ├── image.svg │ │ ├── menu.svg │ │ ├── photo.svg │ │ ├── profile.svg │ │ ├── scan.svg │ │ ├── search.svg │ │ ├── spinner.svg │ │ └── stars.svg │ └── images │ │ ├── banner-bg.png │ │ ├── gradient-bg.svg │ │ ├── logo-icon.png │ │ ├── logo-text.png │ │ └── stacked-coins.png ├── imagenko-1.png ├── imagenko-2.png ├── imagenko-3.png └── imagenko-4.png ├── tailwind.config.ts ├── tsconfig.json └── types └── index.d.ts /.env_example: -------------------------------------------------------------------------------- 1 | #NEXT 2 | NEXT_PUBLIC_SERVER_URL= 3 | 4 | #MONGODB 5 | MONGODB_URL= 6 | 7 | #CLERK 8 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 9 | CLERK_SECRET_KEY= 10 | WEBHOOK_SECRET= 11 | 12 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in 13 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up 14 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/ 15 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/ 16 | 17 | #CLOUDINARY 18 | NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME= 19 | CLOUDINARY_API_KEY= 20 | CLOUDINARY_API_SECRET= 21 | 22 | #STRIPE 23 | STRIPE_SECRET_KEY= 24 | STRIPE_WEBHOOK_SECRET= 25 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | **Imagenko** it's an AI-powered image generator. The project was implemented as a real Software-as-a-Service app with AI features and payments & credits system. 4 | 5 | The project also aimed to improve real-world application development skills. Thanks to **JavaScript Mastery** tutorials and masterclasses from **Adrian Hajdin** 🚀. 6 | 7 | ![](https://github.com/getFrontend/next-app-ai-saas/blob/main/public/imagenko-1.png?raw=true) 8 | 9 | ## Tech Stack 10 | 11 | ⚙️ Next.js 14 12 | 13 | ⚙️ TypeScript 14 | 15 | ⚙️ MongoDB & Mongoose 16 | 17 | ⚙️ React Hook Form & Zod (for form validation) 18 | 19 | ⚙️ Clerk 20 | 21 | ⚙️ Cloudinary 22 | 23 | ⚙️ Stripe 24 | 25 | ⚙️ Shadcn UI & Tailwind CSS 26 | 27 | ## Features 28 | 29 | 🔋 **Authentication and Authorization**: Secure user access with registration, login, and route protection. 30 | 31 | ![](https://github.com/getFrontend/next-app-ai-saas/blob/main/public/imagenko-2.png?raw=true) 32 | 33 | 🔋 **Community Image Showcase**: Explore user transformations with easy navigation using pagination 34 | 35 | 🔋 **Advanced Image Search**: Find images by content or objects present inside the image quickly and accurately 36 | 37 | 🔋 **Image Restoration**: Revive old or damaged images effortlessly 38 | 39 | 🔋 **Image Recoloring**: Customize images by replacing objects with desired colors easily 40 | 41 | 🔋 **Image Generative Fill**: Fill in missing areas of images seamlessly 42 | 43 | 🔋 **Object Removal**: Clean up images by removing unwanted objects with precision 44 | 45 | 🔋 **Background Removal**: Extract objects from backgrounds with ease 46 | 47 | ![](https://github.com/getFrontend/next-app-ai-saas/blob/main/public/imagenko-4.png?raw=true) 48 | 49 | 🔋 **Download Transformed Images**: Save and share AI-transformed images conveniently 50 | 51 | 🔋 **Transformed Image Details**: View details of transformations for each image 52 | 53 | 🔋 **Transformation Management**: Control over deletion and updates of transformations 54 | 55 | 🔋 **Credits System**: Earn or purchase credits for image transformations 56 | 57 | 🔋 **Profile Page**: Access transformed images and credit information personally 58 | 59 | ![](https://github.com/getFrontend/next-app-ai-saas/blob/main/public/imagenko-3.png?raw=true) 60 | 61 | 🔋 **Credits Purchase**: Securely buy credits via Stripe for uninterrupted use 62 | 63 | 🔋 **Responsive UI/UX**: A seamless experience across devices with a user-friendly interface 64 | 65 | ## Quick Start 66 | 67 | Follow these steps to set up the project locally on your machine. 68 | 69 | **Prerequisites** 70 | 71 | Make sure you have the following installed on your machine: 72 | 73 | - [Git](https://git-scm.com/) 74 | - [Node.js](https://nodejs.org/en) 75 | - [npm](https://www.npmjs.com/) 76 | 77 | **Cloning the Repository** 78 | 79 | ```bash 80 | git clone https://github.com/getFrontend/next-app-ai-saas.git 81 | ``` 82 | 83 | **Installation** 84 | 85 | Install the project dependencies using npm: 86 | 87 | ```bash 88 | npm run dev 89 | ``` 90 | 91 | **Set Up Environment Variables** 92 | 93 | Rename the `.env_example` file to `.env.local`. 94 | 95 | Replace the placeholder values with your actual respective account credentials from [Clerk](https://clerk.com/), [MongoDB](https://www.mongodb.com/), [Cloudinary](https://cloudinary.com/) and [Stripe](https://stripe.com) 96 | 97 | **Running the Project** 98 | 99 | ```bash 100 | npm run dev 101 | ``` 102 | 103 | Open [http://localhost:3000](http://localhost:3000) in your browser to view the project. -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | const Layout = ({ children }: { children: React.ReactNode }) => { 2 | return ( 3 |
4 | {children} 5 |
6 | ) 7 | } 8 | 9 | export default Layout; -------------------------------------------------------------------------------- /app/(auth)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn, SignUp } from '@clerk/nextjs'; 2 | import React from 'react'; 3 | 4 | const SignInPage = () => { 5 | return 6 | } 7 | 8 | export default SignInPage; -------------------------------------------------------------------------------- /app/(auth)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from '@clerk/nextjs'; 2 | import React from 'react'; 3 | 4 | const SignUpPage = () => { 5 | return 6 | } 7 | 8 | export default SignUpPage; -------------------------------------------------------------------------------- /app/(root)/credits/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignedIn, auth } from "@clerk/nextjs"; 2 | import Image from "next/image"; 3 | import { redirect } from "next/navigation"; 4 | 5 | import Header from "@/components/shared/Header"; 6 | import { Button } from "@/components/ui/button"; 7 | import { plans } from "@/constants"; 8 | import { getUserById } from "@/lib/actions/user.actions"; 9 | import Checkout from "@/components/shared/Checkout"; 10 | 11 | const Credits = async () => { 12 | const { userId } = auth(); 13 | 14 | if (!userId) redirect("/sign-in"); 15 | 16 | const user = await getUserById(userId); 17 | 18 | return ( 19 | <> 20 |
24 | 25 |
26 |
    27 | {plans.map((plan) => ( 28 |
  • 29 |
    30 | check 31 |

    32 | {plan.name} 33 |

    34 |

    ${plan.price}

    35 |

    {plan.credits} Credits

    36 |
    37 | 38 | {/* Inclusions */} 39 |
      40 | {plan.inclusions.map((inclusion) => ( 41 |
    • 45 | check 53 |

      {inclusion.label}

      54 |
    • 55 | ))} 56 |
    57 | 58 | {plan.name === "Free" ? ( 59 | 62 | ) : ( 63 | 64 | 70 | 71 | )} 72 |
  • 73 | ))} 74 |
75 |
76 | 77 | ); 78 | }; 79 | 80 | export default Credits; -------------------------------------------------------------------------------- /app/(root)/layout.tsx: -------------------------------------------------------------------------------- 1 | import MobileNav from "@/components/shared/MobileNav"; 2 | import SideBar from "@/components/shared/Sidebar"; 3 | import { Toaster } from "@/components/ui/toaster"; 4 | 5 | const Layout = ({ children }: { children: React.ReactNode }) => { 6 | return ( 7 |
8 | 9 | 10 |
11 |
12 | {children} 13 |
14 |
15 | 16 |
17 | ) 18 | } 19 | 20 | export default Layout; -------------------------------------------------------------------------------- /app/(root)/page.tsx: -------------------------------------------------------------------------------- 1 | import { Collection } from "@/components/shared/Collection"; 2 | import { navLinks } from "@/constants"; 3 | import { getAllImages } from "@/lib/actions/image.actions"; 4 | import Image from "next/image"; 5 | import Link from "next/link"; 6 | 7 | const Home = async ({ searchParams }: SearchParamProps) => { 8 | const page = Number(searchParams?.page) || 1; 9 | const searchQuery = (searchParams?.query as string) || ''; 10 | 11 | const images = await getAllImages({ page, searchQuery }); 12 | 13 | return ( 14 | <> 15 |
16 |

17 | Your AI magic artist who never gets tired! 18 |

19 |
    20 | {navLinks.slice(1, 5).map((link) => ( 21 |
  • 24 | 28 | icon image 35 | 36 | {link.label} 37 | 38 | 39 |
  • 40 | ))} 41 |
42 |
43 | 44 |
45 | 51 |
52 | 53 |
54 |
55 |

Imagenko - your tool for generating creative and unique images with AI.

56 |

Developer: Sergey UP

57 |

© 2024 - All right reserved by AI Magic Artist

58 |
59 |
60 | 61 | ); 62 | }; 63 | 64 | export default Home; -------------------------------------------------------------------------------- /app/(root)/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs"; 2 | import Image from "next/image"; 3 | import { redirect } from "next/navigation"; 4 | 5 | import { Collection } from "@/components/shared/Collection"; 6 | import Header from "@/components/shared/Header"; 7 | import { getUserImages } from "@/lib/actions/image.actions"; 8 | import { getUserById } from "@/lib/actions/user.actions"; 9 | 10 | const Profile = async ({ searchParams }: SearchParamProps) => { 11 | const page = Number(searchParams?.page) || 1; 12 | const { userId } = auth(); 13 | 14 | if (!userId) redirect("/sign-in"); 15 | 16 | const user = await getUserById(userId); 17 | const images = await getUserImages({ page, userId: user._id }); 18 | 19 | return ( 20 | <> 21 |
22 | 23 |
24 |
25 |

CREDITS AVAILABLE

26 |
27 | coins 34 |

{user.creditBalance}

35 |
36 |
37 | 38 |
39 |

IMAGE MANIPULATION DONE

40 |
41 | coins 48 |

{images?.data.length}

49 |
50 |
51 |
52 | 53 |
54 | 59 |
60 | 61 | ); 62 | }; 63 | 64 | export default Profile; -------------------------------------------------------------------------------- /app/(root)/transformations/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs"; 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | 5 | import Header from "@/components/shared/Header"; 6 | import TransformedImage from "@/components/shared/TransformedImage"; 7 | import { Button } from "@/components/ui/button"; 8 | import { getImageById } from "@/lib/actions/image.actions"; 9 | import { getImageSize } from "@/lib/utils"; 10 | import { DeleteConfirmation } from "@/components/shared/DeleteConfirmation"; 11 | 12 | const ImageDetails = async ({ params: { id } }: SearchParamProps) => { 13 | const { userId } = auth(); 14 | 15 | const image = await getImageById(id); 16 | 17 | return ( 18 | <> 19 |
20 | 21 |
22 |
23 |

Transformation:

24 |

25 | {image.transformationType} 26 |

27 |
28 | 29 | {image.prompt && ( 30 | <> 31 |

32 |
33 |

Prompt:

34 |

{image.prompt}

35 |
36 | 37 | )} 38 | 39 | {image.color && ( 40 | <> 41 |

42 |
43 |

Color:

44 |

{image.color}

45 |
46 | 47 | )} 48 | 49 | {image.aspectRatio && ( 50 | <> 51 |

52 |
53 |

Aspect Ratio:

54 |

{image.aspectRatio}

55 |
56 | 57 | )} 58 |
59 | 60 |
61 |
62 | {/* MEDIA UPLOADER */} 63 |
64 |

Original

65 | 66 | image 73 |
74 | 75 | {/* TRANSFORMED IMAGE */} 76 | 84 |
85 | 86 | {userId === image.author.clerkId && ( 87 |
88 | 93 | 94 | 95 |
96 | )} 97 |
98 | 99 | ); 100 | }; 101 | 102 | export default ImageDetails; -------------------------------------------------------------------------------- /app/(root)/transformations/[id]/update/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import Header from "@/components/shared/Header"; 5 | import TransformationForm from "@/components/shared/TransformationForm"; 6 | import { transformationTypes } from "@/constants"; 7 | import { getUserById } from "@/lib/actions/user.actions"; 8 | import { getImageById } from "@/lib/actions/image.actions"; 9 | 10 | const Page = async ({ params: { id } }: SearchParamProps) => { 11 | const { userId } = auth(); 12 | 13 | if (!userId) redirect("/sign-in"); 14 | 15 | const user = await getUserById(userId); 16 | const image = await getImageById(id); 17 | 18 | const transformation = 19 | transformationTypes[image.transformationType as TransformationTypeKey]; 20 | 21 | return ( 22 | <> 23 |
24 | 25 |
26 | 34 |
35 | 36 | ); 37 | }; 38 | 39 | export default Page; -------------------------------------------------------------------------------- /app/(root)/transformations/add/[type]/page.tsx: -------------------------------------------------------------------------------- 1 | import Header from "@/components/shared/Header"; 2 | import TransformationForm from "@/components/shared/TransformationForm"; 3 | import { transformationTypes } from "@/constants"; 4 | import { getUserById } from "@/lib/actions/user.actions"; 5 | import { auth } from "@clerk/nextjs"; 6 | import { redirect } from "next/navigation"; 7 | 8 | const AddTransformationType = async ({ params: { type } }: SearchParamProps) => { 9 | const { userId } = auth(); 10 | const transformation = transformationTypes[type]; 11 | 12 | if (!userId) redirect("/sign-in"); 13 | 14 | const user = await getUserById(userId); 15 | 16 | return ( 17 | <> 18 |
22 |
23 | 29 |
30 | 31 | ) 32 | } 33 | 34 | export default AddTransformationType; -------------------------------------------------------------------------------- /app/(root)/transformations/page.tsx: -------------------------------------------------------------------------------- 1 | const TransformationsPage = () => { 2 | return ( 3 |
TransformationsPage
4 | ) 5 | } 6 | 7 | export default TransformationsPage; -------------------------------------------------------------------------------- /app/api/webhooks/clerk/route.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { clerkClient } from "@clerk/nextjs"; 3 | import { WebhookEvent } from "@clerk/nextjs/server"; 4 | import { headers } from "next/headers"; 5 | import { NextResponse } from "next/server"; 6 | import { Webhook } from "svix"; 7 | 8 | import { createUser, deleteUser, updateUser } from "@/lib/actions/user.actions"; 9 | 10 | export async function POST(req: Request) { 11 | // You can find this in the Clerk Dashboard -> Webhooks -> choose the webhook 12 | const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; 13 | 14 | if (!WEBHOOK_SECRET) { 15 | throw new Error( 16 | "Please add WEBHOOK_SECRET from Clerk Dashboard to .env or .env.local" 17 | ); 18 | } 19 | 20 | // Get the headers 21 | const headerPayload = headers(); 22 | const svix_id = headerPayload.get("svix-id"); 23 | const svix_timestamp = headerPayload.get("svix-timestamp"); 24 | const svix_signature = headerPayload.get("svix-signature"); 25 | 26 | // If there are no headers, error out 27 | if (!svix_id || !svix_timestamp || !svix_signature) { 28 | return new Response("Error occured -- no svix headers", { 29 | status: 400, 30 | }); 31 | } 32 | 33 | // Get the body 34 | const payload = await req.json(); 35 | const body = JSON.stringify(payload); 36 | 37 | // Create a new Svix instance with your secret. 38 | const wh = new Webhook(WEBHOOK_SECRET); 39 | 40 | let evt: WebhookEvent; 41 | 42 | // Verify the payload with the headers 43 | try { 44 | evt = wh.verify(body, { 45 | "svix-id": svix_id, 46 | "svix-timestamp": svix_timestamp, 47 | "svix-signature": svix_signature, 48 | }) as WebhookEvent; 49 | } catch (err) { 50 | console.error("Error verifying webhook:", err); 51 | return new Response("Error occured", { 52 | status: 400, 53 | }); 54 | } 55 | 56 | // Get the ID and type 57 | const { id } = evt.data; 58 | const eventType = evt.type; 59 | 60 | // CREATE 61 | if (eventType === "user.created") { 62 | const { id, email_addresses, image_url, first_name, last_name, username } = evt.data; 63 | 64 | const user = { 65 | clerkId: id, 66 | email: email_addresses[0].email_address, 67 | username: username!, 68 | firstName: first_name, 69 | lastName: last_name, 70 | photo: image_url, 71 | }; 72 | 73 | const newUser = await createUser(user); 74 | 75 | // Set public metadata 76 | if (newUser) { 77 | await clerkClient.users.updateUserMetadata(id, { 78 | publicMetadata: { 79 | userId: newUser._id, 80 | }, 81 | }); 82 | } 83 | 84 | return NextResponse.json({ message: "OK", user: newUser }); 85 | } 86 | 87 | // UPDATE 88 | if (eventType === "user.updated") { 89 | const { id, image_url, first_name, last_name, username } = evt.data; 90 | 91 | const user = { 92 | firstName: first_name, 93 | lastName: last_name, 94 | username: username!, 95 | photo: image_url, 96 | }; 97 | 98 | const updatedUser = await updateUser(id, user); 99 | 100 | return NextResponse.json({ message: "OK", user: updatedUser }); 101 | } 102 | 103 | // DELETE 104 | if (eventType === "user.deleted") { 105 | const { id } = evt.data; 106 | 107 | const deletedUser = await deleteUser(id!); 108 | 109 | return NextResponse.json({ message: "OK", user: deletedUser }); 110 | } 111 | 112 | console.log(`Webhook with and ID of ${id} and type of ${eventType}`); 113 | console.log("Webhook body:", body); 114 | 115 | return new Response("", { status: 200 }); 116 | } -------------------------------------------------------------------------------- /app/api/webhooks/stripe/route.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { createTransaction } from "@/lib/actions/transaction.actions"; 3 | import { NextResponse } from "next/server"; 4 | import stripe from "stripe"; 5 | 6 | export async function POST(request: Request) { 7 | const body = await request.text(); 8 | 9 | const sig = request.headers.get("stripe-signature") as string; 10 | const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!; 11 | 12 | let event; 13 | 14 | try { 15 | event = stripe.webhooks.constructEvent(body, sig, endpointSecret); 16 | } catch (err) { 17 | return NextResponse.json({ message: "Webhook error", error: err }); 18 | } 19 | 20 | // Get the ID and type 21 | const eventType = event.type; 22 | 23 | // CREATE 24 | if (eventType === "checkout.session.completed") { 25 | const { id, amount_total, metadata } = event.data.object; 26 | 27 | const transaction = { 28 | stripeId: id, 29 | amount: amount_total ? amount_total / 100 : 0, 30 | plan: metadata?.plan || "", 31 | credits: Number(metadata?.credits) || 0, 32 | buyerId: metadata?.buyerId || "", 33 | createdAt: new Date(), 34 | }; 35 | 36 | const newTransaction = await createTransaction(transaction); 37 | 38 | return NextResponse.json({ message: "OK", transaction: newTransaction }); 39 | } 40 | 41 | return new Response("", { status: 200 }); 42 | } -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | .auth { 79 | @apply flex-center min-h-screen w-full bg-purple-100 80 | } 81 | 82 | .root { 83 | @apply flex min-h-screen w-full flex-col bg-white lg:flex-row; 84 | } 85 | 86 | .root-container { 87 | @apply mt-16 flex-1 overflow-auto py-8 lg:mt-0 lg:max-h-screen lg:py-10 88 | } 89 | 90 | /* ========================================== TAILWIND STYLES */ 91 | @layer utilities { 92 | /* ===== UTILITIES */ 93 | .wrapper { 94 | @apply max-w-5xl mx-auto px-5 md:px-10 w-full text-dark-400 p-16-regular; 95 | } 96 | 97 | .gradient-text { 98 | @apply bg-purple-gradient bg-cover bg-clip-text text-transparent; 99 | } 100 | 101 | /* ===== ALIGNMENTS */ 102 | .flex-center { 103 | @apply flex justify-center items-center; 104 | } 105 | 106 | .flex-between { 107 | @apply flex justify-between items-center; 108 | } 109 | 110 | /* ===== TYPOGRAPHY */ 111 | /* 44 */ 112 | .h1-semibold { 113 | @apply text-[36px] font-semibold sm:text-[44px] leading-[120%] sm:leading-[56px]; 114 | } 115 | 116 | /* 36 */ 117 | .h2-bold { 118 | @apply text-[30px] font-bold md:text-[36px] leading-[110%]; 119 | } 120 | 121 | /* 30 */ 122 | .h3-bold { 123 | @apply font-bold text-[30px] leading-[140%]; 124 | } 125 | 126 | /* 24 */ 127 | .p-24-bold { 128 | @apply font-bold text-[24px] leading-[120%]; 129 | } 130 | 131 | /* 20 */ 132 | .p-20-semibold { 133 | @apply font-semibold text-[20px] leading-[140%]; 134 | } 135 | 136 | .p-20-regular { 137 | @apply font-normal text-[20px] leading-[140%]; 138 | } 139 | 140 | /* 18 */ 141 | .p-18-semibold { 142 | @apply font-semibold text-[18px] leading-[140%]; 143 | } 144 | 145 | /* 16 */ 146 | .p-16-semibold { 147 | @apply font-semibold text-[16px] leading-[140%]; 148 | } 149 | 150 | .p-16-medium { 151 | @apply font-medium text-[16px] leading-[140%]; 152 | } 153 | 154 | .p-16-regular { 155 | @apply font-normal text-[16px] leading-[140%]; 156 | } 157 | 158 | /* 14 */ 159 | .p-14-medium { 160 | @apply font-medium text-[14px] leading-[120%]; 161 | } 162 | 163 | /* 10 */ 164 | .p-10-medium { 165 | @apply font-medium text-[10px] leading-[140%]; 166 | } 167 | 168 | /* ===== SHADCN OVERRIDES */ 169 | .button { 170 | @apply py-4 px-6 flex-center gap-3 rounded-full p-16-semibold focus-visible:ring-offset-0 focus-visible:ring-transparent !important; 171 | } 172 | 173 | .dropdown-content { 174 | @apply shadow-lg rounded-md overflow-hidden p-0; 175 | } 176 | 177 | .dropdown-item { 178 | @apply p-16-semibold text-dark-700 cursor-pointer transition-all px-4 py-3 rounded-none outline-none hover:border-none focus-visible:ring-transparent hover:text-white hover:bg-purple-gradient hover:bg-cover focus-visible:ring-offset-0 focus-visible:outline-none !important; 179 | } 180 | 181 | .input-field { 182 | @apply rounded-[16px] border-2 border-purple-200/20 shadow-sm shadow-purple-200/15 text-dark-600 disabled:opacity-100 p-16-semibold h-[50px] md:h-[54px] focus-visible:ring-offset-0 px-4 py-3 focus-visible:ring-transparent !important; 183 | } 184 | 185 | .search-field { 186 | @apply border-0 bg-transparent text-dark-600 w-full placeholder:text-dark-400 h-[50px] p-16-medium focus-visible:ring-offset-0 p-3 focus-visible:ring-transparent !important; 187 | } 188 | 189 | .submit-button { 190 | @apply bg-purple-gradient bg-cover rounded-full py-4 px-6 p-16-semibold h-[50px] w-full md:h-[54px]; 191 | } 192 | 193 | .select-field { 194 | @apply w-full border-2 border-purple-200/20 shadow-sm shadow-purple-200/15 rounded-[16px] h-[50px] md:h-[54px] text-dark-600 p-16-semibold disabled:opacity-100 placeholder:text-dark-400/50 px-4 py-3 focus:ring-offset-0 focus-visible:ring-transparent focus:ring-transparent focus-visible:ring-0 focus-visible:outline-none !important; 195 | } 196 | 197 | .select-trigger { 198 | @apply flex items-center gap-2 py-5 capitalize focus-visible:outline-none; 199 | } 200 | 201 | .select-item { 202 | @apply py-3 cursor-pointer hover:bg-purple-100; 203 | } 204 | 205 | .IconButton { 206 | @apply focus-visible:ring-transparent focus:ring-offset-0 focus-visible:ring-offset-0 focus-visible:outline-none focus-visible:border-none !important; 207 | } 208 | 209 | .sheet-content button { 210 | @apply focus:ring-0 focus-visible:ring-transparent focus:ring-offset-0 focus-visible:ring-offset-0 focus-visible:outline-none focus-visible:border-none !important; 211 | } 212 | 213 | .success-toast { 214 | @apply bg-green-100 text-green-900; 215 | } 216 | 217 | .error-toast { 218 | @apply bg-red-100 text-red-900; 219 | } 220 | 221 | /* Home Page */ 222 | .home { 223 | @apply sm:flex-center hidden h-72 flex-col gap-4 rounded-[20px] border bg-banner bg-cover bg-no-repeat p-10 shadow-inner; 224 | } 225 | 226 | .home-heading { 227 | @apply h1-semibold max-w-[500px] flex-wrap text-center text-white shadow-sm; 228 | } 229 | 230 | /* Credits Page */ 231 | .credits-list { 232 | @apply mt-11 grid grid-cols-1 gap-5 sm:grid-cols-2 md:gap-9 xl:grid-cols-3; 233 | } 234 | 235 | .credits-item { 236 | @apply w-full rounded-[16px] border-2 border-purple-200/20 bg-white p-8 shadow-xl shadow-purple-200/20 lg:max-w-none; 237 | } 238 | 239 | .credits-btn { 240 | @apply w-full rounded-full bg-purple-100 bg-cover text-purple-500 hover:text-purple-500; 241 | } 242 | 243 | /* Profile Page */ 244 | .profile { 245 | @apply mt-5 flex flex-col gap-5 sm:flex-row md:mt-8 md:gap-10; 246 | } 247 | 248 | .profile-balance { 249 | @apply w-full rounded-[16px] border-2 border-purple-200/20 bg-white p-5 shadow-lg shadow-purple-200/10 md:px-6 md:py-8; 250 | } 251 | 252 | .profile-image-manipulation { 253 | @apply w-full rounded-[16px] border-2 border-purple-200/20 bg-white p-5 shadow-lg shadow-purple-200/10 md:px-6 md:py-8; 254 | } 255 | 256 | /* Transformation Details */ 257 | .transformation-grid { 258 | @apply grid h-fit min-h-[200px] grid-cols-1 gap-5 py-8 md:grid-cols-2; 259 | } 260 | 261 | .transformation-original_image { 262 | @apply h-fit min-h-72 w-full rounded-[10px] border border-dashed bg-purple-100/20 object-cover p-2; 263 | } 264 | 265 | /* Collection Component */ 266 | .collection-heading { 267 | @apply md:flex-between mb-6 flex flex-col gap-5 md:flex-row; 268 | } 269 | 270 | .collection-list { 271 | @apply grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3; 272 | } 273 | 274 | .collection-empty { 275 | @apply flex-center h-60 w-full rounded-[10px] border border-dark-400/10 bg-white/20; 276 | } 277 | 278 | .collection-btn { 279 | @apply button w-32 bg-purple-gradient bg-cover text-white; 280 | } 281 | 282 | .collection-card { 283 | @apply flex flex-1 cursor-pointer flex-col gap-5 rounded-[16px] border-2 border-purple-200/15 bg-white p-4 shadow-xl shadow-purple-200/10 transition-all hover:shadow-purple-200/20; 284 | } 285 | 286 | /* MediaUploader Component */ 287 | .media-uploader_cldImage { 288 | @apply h-fit min-h-72 w-full rounded-[10px] border border-dashed bg-purple-100/20 object-cover p-2; 289 | } 290 | 291 | .media-uploader_cta { 292 | @apply flex-center flex h-72 cursor-pointer flex-col gap-5 rounded-[16px] border border-dashed bg-purple-100/20 shadow-inner; 293 | } 294 | 295 | .media-uploader_cta-image { 296 | @apply rounded-[16px] bg-white p-5 shadow-sm shadow-purple-200/50; 297 | } 298 | 299 | /* Navbar Component */ 300 | .header { 301 | @apply flex-between fixed h-16 w-full border-b-4 border-purple-100 bg-white p-5 lg:hidden; 302 | } 303 | 304 | .header-nav_elements { 305 | @apply mt-8 flex w-full flex-col items-start gap-5; 306 | } 307 | 308 | /* Search Component */ 309 | .search { 310 | @apply flex w-full rounded-[16px] border-2 border-purple-200/20 bg-white px-4 shadow-sm shadow-purple-200/15 md:max-w-96; 311 | } 312 | 313 | /* Sidebar Component */ 314 | .sidebar { 315 | @apply hidden h-screen w-72 bg-white p-5 shadow-md shadow-purple-200/50 lg:flex; 316 | } 317 | 318 | .sidebar-logo { 319 | @apply flex items-center gap-2 md:py-2; 320 | } 321 | 322 | .sidebar-nav { 323 | @apply h-full flex-col justify-between md:flex md:gap-4; 324 | } 325 | 326 | .sidebar-nav_elements { 327 | @apply hidden w-full flex-col items-start gap-2 md:flex; 328 | } 329 | 330 | .sidebar-nav_element { 331 | @apply flex-center p-16-semibold w-full whitespace-nowrap rounded-full bg-cover transition-all hover:bg-purple-100 hover:shadow-inner; 332 | } 333 | 334 | .sidebar-link { 335 | @apply p-16-semibold flex size-full gap-4 p-4; 336 | } 337 | 338 | /* TransformationForm Component */ 339 | .prompt-field { 340 | @apply flex flex-col gap-5 lg:flex-row lg:gap-10; 341 | } 342 | 343 | .media-uploader-field { 344 | @apply grid h-fit min-h-[200px] grid-cols-1 gap-5 py-4 md:grid-cols-2; 345 | } 346 | 347 | /* TransformedImage Component */ 348 | .download-btn { 349 | @apply p-14-medium mt-2 flex items-center gap-2 px-2; 350 | } 351 | 352 | .transformed-image { 353 | @apply h-fit min-h-72 w-full rounded-[10px] border border-dashed bg-purple-100/20 object-cover p-2; 354 | } 355 | 356 | .transforming-loader { 357 | @apply flex-center absolute left-[50%] top-[50%] size-full -translate-x-1/2 -translate-y-1/2 flex-col gap-2 rounded-[10px] border bg-dark-700/90; 358 | } 359 | 360 | .transformed-placeholder { 361 | @apply flex-center p-14-medium h-full min-h-72 flex-col gap-5 rounded-[16px] border border-dashed bg-purple-100/20 shadow-inner; 362 | } 363 | } 364 | 365 | /* ===== CLERK OVERRIDES */ 366 | .cl-userButtonBox { 367 | display: flex; 368 | flex-flow: row-reverse; 369 | gap: 12px; 370 | } 371 | 372 | .cl-userButtonOuterIdentifier { 373 | font-size: 16px; 374 | font-weight: 600; 375 | color: #384262; 376 | } -------------------------------------------------------------------------------- /app/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getFrontend/next-app-ai-saas/247659542a62d271bad24a0824432b7195a34b02/app/icon.ico -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { IBM_Plex_Sans } from "next/font/google"; 3 | import "./globals.css"; 4 | import { cn } from "@/lib/utils"; 5 | import { ClerkProvider } from "@clerk/nextjs"; 6 | 7 | const IBMPlex = IBM_Plex_Sans({ 8 | subsets: ["latin"], 9 | weight: ["400", "500", "600", "700"], 10 | variable: "--font-ibm-plex", 11 | }); 12 | 13 | export const metadata: Metadata = { 14 | title: "Imagenko - an AI-powered image generator!", 15 | description: "Create masterpieces at the touch of a button! Imagenko: your tool for generating creative and unique images with AI. Endless possibilities, easy to use, instant results.", 16 | }; 17 | 18 | export default function RootLayout({ 19 | children, 20 | }: Readonly<{ 21 | children: React.ReactNode; 22 | }>) { 23 | return ( 24 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/shared/Checkout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { loadStripe } from "@stripe/stripe-js"; 4 | import { useEffect } from "react"; 5 | 6 | import { useToast } from "@/components/ui/use-toast"; 7 | import { checkoutCredits } from "@/lib/actions/transaction.actions"; 8 | 9 | import { Button } from "../ui/button"; 10 | 11 | const Checkout = ({ 12 | plan, 13 | amount, 14 | credits, 15 | buyerId, 16 | }: { 17 | plan: string; 18 | amount: number; 19 | credits: number; 20 | buyerId: string; 21 | }) => { 22 | const { toast } = useToast(); 23 | 24 | useEffect(() => { 25 | loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!); 26 | }, []); 27 | 28 | useEffect(() => { 29 | // Check to see if this is a redirect back from Checkout 30 | const query = new URLSearchParams(window.location.search); 31 | if (query.get("success")) { 32 | toast({ 33 | title: "Order placed!", 34 | description: "You will receive an email confirmation", 35 | duration: 5000, 36 | className: "success-toast", 37 | }); 38 | } 39 | 40 | if (query.get("canceled")) { 41 | toast({ 42 | title: "Order canceled!", 43 | description: "Continue to shop around and checkout when you're ready", 44 | duration: 5000, 45 | className: "error-toast", 46 | }); 47 | } 48 | }, []); 49 | 50 | const onCheckout = async () => { 51 | const transaction = { 52 | plan, 53 | amount, 54 | credits, 55 | buyerId, 56 | }; 57 | 58 | await checkoutCredits(transaction); 59 | }; 60 | 61 | return ( 62 |
63 |
64 | 71 |
72 |
73 | ); 74 | }; 75 | 76 | export default Checkout; -------------------------------------------------------------------------------- /components/shared/Collection.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | import { useSearchParams, useRouter } from "next/navigation"; 6 | import { CldImage } from "next-cloudinary"; 7 | 8 | import { 9 | Pagination, 10 | PaginationContent, 11 | PaginationNext, 12 | PaginationPrevious, 13 | } from "@/components/ui/pagination"; 14 | import { transformationTypes } from "@/constants"; 15 | import { IImage } from "@/lib/database/models/image.model"; 16 | import { formUrlQuery } from "@/lib/utils"; 17 | 18 | import { Button } from "../ui/button"; 19 | 20 | import { Search } from "./Search"; 21 | 22 | export const Collection = ({ 23 | hasSearch = false, 24 | images, 25 | totalPages = 1, 26 | page, 27 | }: { 28 | images: IImage[]; 29 | totalPages?: number; 30 | page: number; 31 | hasSearch?: boolean; 32 | }) => { 33 | const router = useRouter(); 34 | const searchParams = useSearchParams(); 35 | 36 | // PAGINATION HANDLER 37 | const onPageChange = (action: string) => { 38 | const pageValue = action === "next" ? Number(page) + 1 : Number(page) - 1; 39 | 40 | const newUrl = formUrlQuery({ 41 | searchParams: searchParams.toString(), 42 | key: "page", 43 | value: pageValue, 44 | }); 45 | 46 | router.push(newUrl, { scroll: false }); 47 | }; 48 | 49 | return ( 50 | <> 51 |
52 |

Recent Edits

53 | {hasSearch && } 54 |
55 | 56 | {images.length > 0 ? ( 57 |
    58 | {images.map((image) => ( 59 | 60 | ))} 61 |
62 | ) : ( 63 |
64 |

Empty List

65 |
66 | )} 67 | 68 | {totalPages > 1 && ( 69 | 70 | 71 | 78 | 79 |

80 | {page} / {totalPages} 81 |

82 | 83 | 90 |
91 |
92 | )} 93 | 94 | ); 95 | }; 96 | 97 | const Card = ({ image }: { image: IImage }) => { 98 | return ( 99 |
  • 100 | 101 | 111 |
    112 |

    113 | {image.title} 114 |

    115 | {image.title} 124 |
    125 | 126 |
  • 127 | ); 128 | }; -------------------------------------------------------------------------------- /components/shared/CustomField.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Control } from "react-hook-form"; 3 | import { z } from "zod"; 4 | 5 | import { 6 | FormField, 7 | FormItem, 8 | FormControl, 9 | FormMessage, 10 | FormLabel, 11 | } from "../ui/form"; 12 | 13 | import { formSchema } from "./TransformationForm"; 14 | 15 | type CustomFieldProps = { 16 | control: Control> | undefined; 17 | render: (props: { field: any }) => React.ReactNode; 18 | name: keyof z.infer; 19 | formLabel?: string; 20 | className?: string; 21 | }; 22 | 23 | export const CustomField = ({ 24 | control, 25 | render, 26 | name, 27 | formLabel, 28 | className, 29 | }: CustomFieldProps) => { 30 | return ( 31 | ( 35 | 36 | {formLabel && {formLabel}} 37 | {render({ field })} 38 | 39 | 40 | )} 41 | /> 42 | ); 43 | }; -------------------------------------------------------------------------------- /components/shared/DeleteConfirmation.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTransition } from "react"; 4 | 5 | import { 6 | AlertDialog, 7 | AlertDialogAction, 8 | AlertDialogCancel, 9 | AlertDialogContent, 10 | AlertDialogDescription, 11 | AlertDialogFooter, 12 | AlertDialogHeader, 13 | AlertDialogTitle, 14 | AlertDialogTrigger, 15 | } from "@/components/ui/alert-dialog"; 16 | import { deleteImage } from "@/lib/actions/image.actions"; 17 | 18 | import { Button } from "../ui/button"; 19 | 20 | export const DeleteConfirmation = ({ imageId }: { imageId: string }) => { 21 | const [isPending, startTransition] = useTransition(); 22 | 23 | return ( 24 | 25 | 26 | 33 | 34 | 35 | 36 | 37 | 38 | Are you sure you want to delete this image? 39 | 40 | 41 | This will permanently delete this image 42 | 43 | 44 | 45 | 46 | Cancel 47 | 50 | startTransition(async () => { 51 | await deleteImage(imageId); 52 | }) 53 | } 54 | > 55 | {isPending ? "Deleting..." : "Delete"} 56 | 57 | 58 | 59 | 60 | ); 61 | }; -------------------------------------------------------------------------------- /components/shared/Header.tsx: -------------------------------------------------------------------------------- 1 | const Header = ({ title, subtitle }: { title: string, subtitle?: string }) => { 2 | return ( 3 | <> 4 |

    5 | {title} 6 |

    7 | {subtitle && 8 |

    {subtitle}

    9 | } 10 | 11 | ) 12 | }; 13 | 14 | export default Header; -------------------------------------------------------------------------------- /components/shared/InsufficientCreditsModal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { useRouter } from "next/navigation"; 5 | 6 | import { 7 | AlertDialog, 8 | AlertDialogAction, 9 | AlertDialogCancel, 10 | AlertDialogContent, 11 | AlertDialogDescription, 12 | AlertDialogFooter, 13 | AlertDialogHeader, 14 | AlertDialogTitle, 15 | } from "@/components/ui/alert-dialog"; 16 | 17 | export const InsufficientCreditsModal = () => { 18 | const router = useRouter(); 19 | 20 | return ( 21 | 22 | 23 | 24 |
    25 |

    Insufficient Credits

    26 | router.push("/profile")} 29 | > 30 | credit coins 37 | 38 |
    39 | 40 | credit coins 46 | 47 | 48 | Oops.... Looks like you've run out of free credits! 49 | 50 | 51 | 52 | No worries, though - you can keep enjoying our services by grabbing 53 | more credits. 54 | 55 |
    56 | 57 | router.push("/profile")} 60 | > 61 | No, Cancel 62 | 63 | router.push("/credits")} 66 | > 67 | Yes, Proceed 68 | 69 | 70 |
    71 |
    72 | ); 73 | }; -------------------------------------------------------------------------------- /components/shared/MediaUploader.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useToast } from "@/components/ui/use-toast"; 4 | import { dataUrl, getImageSize } from "@/lib/utils"; 5 | import { CldImage, CldUploadWidget } from "next-cloudinary"; 6 | import { PlaceholderValue } from "next/dist/shared/lib/get-img-props"; 7 | import Image from "next/image"; 8 | import React from "react"; 9 | 10 | type MediaUploaderProps = { 11 | onValueChange: (value: string) => void; 12 | setImage: React.Dispatch; 13 | image: any; 14 | publicId: string; 15 | type: string; 16 | } 17 | 18 | const MediaUploader = ({ 19 | onValueChange, 20 | setImage, 21 | image, 22 | publicId, 23 | type 24 | }: MediaUploaderProps) => { 25 | const { toast } = useToast(); 26 | 27 | const onUploadSuccessHandler = (result: any) => { 28 | setImage((prevState: any) => ({ 29 | ...prevState, 30 | publicId: result.info.public_id, 31 | width: result?.info.width, 32 | height: result?.info.height, 33 | secureURL: result?.info?.secure_url 34 | })); 35 | 36 | onValueChange(result?.info?.public_id); 37 | 38 | toast({ 39 | title: "Image uploaded successfully!", 40 | description: "1 credit has been deducted from your account", 41 | duration: 5000, 42 | className: "success-toast" 43 | }); 44 | } 45 | 46 | const onUploadErrorHandler = () => { 47 | toast({ 48 | title: "Error! Something went wrong, while uploading image", 49 | description: "Please try again.", 50 | duration: 5000, 51 | className: "error-toast" 52 | }); 53 | } 54 | 55 | return ( 56 | 65 | {({ open }) => ( 66 |
    67 |

    68 | Original 69 |

    70 | 71 | {publicId ? ( 72 | <> 73 |
    74 | 83 |
    84 | 85 | ) : ( 86 |
    open()}> 88 |
    89 | add image 95 |
    96 |

    Click here to upload image

    97 |
    98 | )} 99 |
    100 | )} 101 |
    102 | ) 103 | }; 104 | 105 | export default MediaUploader; -------------------------------------------------------------------------------- /components/shared/MobileNav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SignedIn, SignedOut, UserButton } from "@clerk/nextjs"; 4 | import Image from "next/image"; 5 | import Link from "next/link"; 6 | import { 7 | Sheet, 8 | SheetContent, 9 | SheetDescription, 10 | SheetHeader, 11 | SheetTitle, 12 | SheetTrigger, 13 | } from "@/components/ui/sheet"; 14 | import { usePathname } from "next/navigation"; 15 | import { navLinks } from "@/constants"; 16 | import { Button } from "../ui/button"; 17 | 18 | const MobileNav = () => { 19 | const pathname = usePathname(); 20 | 21 | return ( 22 |
    23 | 24 | imagenko logo 30 | 31 | 82 |
    83 | ) 84 | } 85 | 86 | export default MobileNav; -------------------------------------------------------------------------------- /components/shared/Search.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { useRouter, useSearchParams } from "next/navigation"; 5 | import { useEffect, useState } from "react"; 6 | 7 | import { Input } from "@/components/ui/input"; 8 | import { formUrlQuery, removeKeysFromQuery } from "@/lib/utils"; 9 | 10 | export const Search = () => { 11 | const router = useRouter(); 12 | const searchParams = useSearchParams(); 13 | const [query, setQuery] = useState(""); 14 | 15 | useEffect(() => { 16 | const delayDebounceFn = setTimeout(() => { 17 | if (query) { 18 | const newUrl = formUrlQuery({ 19 | searchParams: searchParams.toString(), 20 | key: "query", 21 | value: query, 22 | }); 23 | 24 | router.push(newUrl, { scroll: false }); 25 | } else { 26 | const newUrl = removeKeysFromQuery({ 27 | searchParams: searchParams.toString(), 28 | keysToRemove: ["query"], 29 | }); 30 | 31 | router.push(newUrl, { scroll: false }); 32 | } 33 | }, 300); 34 | 35 | return () => clearTimeout(delayDebounceFn); 36 | }, [router, searchParams, query]); 37 | 38 | return ( 39 |
    40 | search 46 | 47 | setQuery(e.target.value)} 51 | /> 52 |
    53 | ); 54 | }; -------------------------------------------------------------------------------- /components/shared/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { navLinks } from "@/constants"; 4 | import { SignedIn, SignedOut, UserButton } from "@clerk/nextjs"; 5 | import Image from "next/image"; 6 | import Link from "next/link"; 7 | import { usePathname } from "next/navigation"; 8 | import { Button } from "../ui/button"; 9 | 10 | const SideBar = () => { 11 | const pathname = usePathname(); 12 | 13 | return ( 14 | 80 | ) 81 | } 82 | 83 | export default SideBar; -------------------------------------------------------------------------------- /components/shared/TransformationForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { useForm } from "react-hook-form"; 5 | import { z } from "zod"; 6 | import { 7 | Form, 8 | FormControl, 9 | FormDescription, 10 | FormField, 11 | FormItem, 12 | FormLabel, 13 | FormMessage, 14 | } from "@/components/ui/form"; 15 | import { 16 | Select, 17 | SelectContent, 18 | SelectItem, 19 | SelectTrigger, 20 | SelectValue, 21 | } from "@/components/ui/select"; 22 | import { Input } from "@/components/ui/input"; 23 | import { aspectRatioOptions, creditFee, defaultValues, transformationTypes } from "@/constants"; 24 | import { CustomField } from "./CustomField"; 25 | import { useEffect, useState, useTransition } from "react"; 26 | import { AspectRatioKey, debounce, deepMergeObjects } from "@/lib/utils"; 27 | import { Button } from "../ui/button"; 28 | import MediaUploader from "./MediaUploader"; 29 | import TransformedImage from "./TransformedImage"; 30 | import { updateCredits } from "@/lib/actions/user.actions"; 31 | import { getCldImageUrl } from "next-cloudinary"; 32 | import { addImage, updateImage } from "@/lib/actions/image.actions"; 33 | import { useRouter } from "next/navigation"; 34 | import { InsufficientCreditsModal } from "./InsufficientCreditsModal"; 35 | 36 | export const formSchema = z.object({ 37 | title: z.string(), 38 | aspectRatio: z.string().optional(), 39 | color: z.string().optional(), 40 | prompt: z.string().optional(), 41 | publicId: z.string() 42 | }); 43 | 44 | const TransformationForm = ({ data = null, action, userId, type, creditBalance, config = null }: TransformationFormProps) => { 45 | const transformationType = transformationTypes[type]; 46 | const [image, setImage] = useState(data); 47 | const [newTransformation, setNewTransformation] = useState(null); 48 | const [isSubmitting, setIsSubmitting] = useState(false); 49 | const [isTransforming, setIsTransforming] = useState(false); 50 | const [transformationConfig, setTransformationConfig] = useState(config); 51 | const [isPending, startTransition] = useTransition(); 52 | const router = useRouter(); 53 | 54 | const initialValues = data && action === "Update" ? { 55 | title: data?.title, 56 | aspectRatio: data?.aspectRatio, 57 | color: data?.color, 58 | prompt: data?.prompt, 59 | publicId: data?.publicId, 60 | } : defaultValues; 61 | 62 | // 1. Define form. 63 | const form = useForm>({ 64 | resolver: zodResolver(formSchema), 65 | defaultValues: initialValues 66 | }) 67 | 68 | // 2. Define a submit handler. 69 | async function onSubmit(values: z.infer) { 70 | setIsSubmitting(true); 71 | if (data || image) { 72 | const transformationUrl = getCldImageUrl({ 73 | width: image?.width, 74 | height: image?.height, 75 | src: image?.publicId, 76 | ...transformationConfig 77 | }); 78 | 79 | const imageData = { 80 | title: values.title, 81 | publicId: image?.publicId, 82 | transformationType: type, 83 | width: image?.width, 84 | height: image?.height, 85 | config: transformationConfig, 86 | secureURL: image?.secureURL, 87 | transformationURL: transformationUrl, 88 | aspectRatio: values.aspectRatio, 89 | prompt: values.prompt, 90 | color: values.color 91 | }; 92 | 93 | if (action === "Add") { 94 | try { 95 | const newImage = await addImage({ 96 | image: imageData, 97 | userId, 98 | path: "/" 99 | }); 100 | 101 | if (newImage) { 102 | form.reset(); 103 | setImage(data); 104 | router.push(`/transformations/${newImage._id}`); 105 | } 106 | } catch (error) { 107 | console.log(error); 108 | }; 109 | }; 110 | 111 | if (action === "Update") { 112 | try { 113 | const updatedImage = await updateImage({ 114 | image: { 115 | ...imageData, 116 | _id: image?._id 117 | }, 118 | userId, 119 | path: `/transformations/${data._id}` 120 | }); 121 | 122 | if (updatedImage) { 123 | router.push(`/transformations/${updatedImage._id}`); 124 | } 125 | } catch (error) { 126 | console.log(error); 127 | }; 128 | } 129 | }; 130 | 131 | setIsSubmitting(false); 132 | }; 133 | 134 | const onSelectFieldHandler = (value: string, onChangeField: (value: string) => void) => { 135 | const imageSize = aspectRatioOptions[value as AspectRatioKey]; 136 | 137 | setImage((prevState: any) => ({ 138 | ...prevState, 139 | aspectRatio: imageSize.aspectRatio, 140 | width: imageSize.width, 141 | height: imageSize.height, 142 | })) 143 | 144 | setNewTransformation(transformationType.config); 145 | 146 | return onChangeField(value); 147 | }; 148 | 149 | const onInputChangeHandler = (fieldName: string, value: string, type: string, onChangeField: (value: string) => void) => { 150 | debounce(() => { 151 | setNewTransformation((prevState: any) => ({ 152 | ...prevState, 153 | [type]: { 154 | ...prevState?.[type], 155 | [fieldName === 'prompt' ? 'prompt' : 'to']: value 156 | } 157 | })); 158 | }, 1000)(); 159 | 160 | return onChangeField(value); 161 | }; 162 | 163 | // To do more... Update creditFee to something else 164 | const onTransformHandler = async () => { 165 | setIsTransforming(true); 166 | 167 | setTransformationConfig( 168 | deepMergeObjects(newTransformation, transformationConfig) 169 | ); 170 | 171 | setNewTransformation(null); 172 | 173 | startTransition(async () => { 174 | await updateCredits(userId, creditFee); 175 | }); 176 | }; 177 | 178 | useEffect(() => { 179 | if (image && (type === "restore" || type === "removeBackground")) { 180 | setNewTransformation(transformationType.config); 181 | } 182 | }, [image, type, transformationType.config]); 183 | 184 | return ( 185 |
    186 | 188 | {creditBalance < Math.abs(creditFee) && } 189 | } 195 | /> 196 | 197 | {type === 'fill' && ( 198 | ( 204 | 219 | )} 220 | /> 221 | )} 222 | 223 | {(type === "remove" || type === "recolor") && ( 224 |
    225 | ( 233 | onInputChangeHandler( 237 | "prompt", e.target.value, type, field.onChange 238 | )} 239 | /> 240 | )} 241 | /> 242 | 243 | {type === "recolor" && ( 244 | ( 250 | onInputChangeHandler( 254 | "color", e.target.value, "recolor", field.onChange 255 | )} 256 | /> 257 | )} 258 | /> 259 | )} 260 |
    261 | )} 262 | 263 |
    264 | ( 269 | 276 | )} 277 | /> 278 | 279 | 287 |
    288 | 289 |
    290 | 298 | 305 |
    306 | 307 | 308 | ) 309 | }; 310 | 311 | export default TransformationForm; -------------------------------------------------------------------------------- /components/shared/TransformedImage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { dataUrl, debounce, download, getImageSize } from "@/lib/utils"; 4 | import { CldImage, getCldImageUrl } from "next-cloudinary"; 5 | import { PlaceholderValue } from "next/dist/shared/lib/get-img-props"; 6 | import Image from "next/image"; 7 | import React from "react"; 8 | 9 | const TransformedImage = ({ image, type, title, transformationConfig, isTransforming, setIsTransforming, hasDownload = false }: TransformedImageProps) => { 10 | const downloadHandler = (e: React.MouseEvent) => { 11 | e.preventDefault(); 12 | download(getCldImageUrl({ 13 | width: image?.width, 14 | height: image?.height, 15 | src: image?.publicId, 16 | ...transformationConfig 17 | }), title); 18 | }; 19 | 20 | return ( 21 |
    22 |
    23 |

    24 | Transformed 25 |

    26 | 27 | {hasDownload && ( 28 | 38 | )} 39 |
    40 | 41 | {image?.publicId && transformationConfig ? ( 42 |
    43 | { 52 | setIsTransforming && setIsTransforming(false); 53 | }} 54 | onError={() => { 55 | debounce(() => { 56 | setIsTransforming && setIsTransforming(false); 57 | }, 8000)(); 58 | }} 59 | {...transformationConfig} 60 | /> 61 | 62 | {isTransforming && ( 63 |
    64 | transforming loader 70 |

    Please wait...

    71 |
    72 | )} 73 |
    74 | ) : ( 75 |
    76 | Transformed Image 77 |
    78 | )} 79 |
    80 | ) 81 | }; 82 | 83 | export default TransformedImage; -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )) 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
    59 | ) 60 | AlertDialogHeader.displayName = "AlertDialogHeader" 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
    73 | ) 74 | AlertDialogFooter.displayName = "AlertDialogFooter" 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )) 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )) 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )) 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogPortal, 132 | AlertDialogOverlay, 133 | AlertDialogTrigger, 134 | AlertDialogContent, 135 | AlertDialogHeader, 136 | AlertDialogFooter, 137 | AlertDialogTitle, 138 | AlertDialogDescription, 139 | AlertDialogAction, 140 | AlertDialogCancel, 141 | } 142 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/lib/utils" 14 | import { Label } from "@/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
    82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |