├── .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 | 
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 | 
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 | 
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 | 
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 |
31 |
32 | {plan.name}
33 |
34 |
${plan.price}
35 |
{plan.credits} Credits
36 |
37 |
38 | {/* Inclusions */}
39 |
40 | {plan.inclusions.map((inclusion) => (
41 | -
45 |
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 |
35 |
36 | {link.label}
37 |
38 |
39 |
40 | ))}
41 |
42 |
43 |
44 |
52 |
53 |
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 |
34 |
{user.creditBalance}
35 |
36 |
37 |
38 |
39 |
IMAGE MANIPULATION DONE
40 |
41 |
48 |
{images?.data.length}
49 |
50 |
51 |
52 |
53 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
37 |
38 |
39 |
40 |
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 |
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 |
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 |
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 |
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 |
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 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/components/ui/pagination.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
3 |
4 | import { cn } from "@/lib/utils"
5 | import { ButtonProps, buttonVariants } from "@/components/ui/button"
6 |
7 | const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
8 |
14 | )
15 | Pagination.displayName = "Pagination"
16 |
17 | const PaginationContent = React.forwardRef<
18 | HTMLUListElement,
19 | React.ComponentProps<"ul">
20 | >(({ className, ...props }, ref) => (
21 |
26 | ))
27 | PaginationContent.displayName = "PaginationContent"
28 |
29 | const PaginationItem = React.forwardRef<
30 | HTMLLIElement,
31 | React.ComponentProps<"li">
32 | >(({ className, ...props }, ref) => (
33 |
34 | ))
35 | PaginationItem.displayName = "PaginationItem"
36 |
37 | type PaginationLinkProps = {
38 | isActive?: boolean
39 | } & Pick &
40 | React.ComponentProps<"a">
41 |
42 | const PaginationLink = ({
43 | className,
44 | isActive,
45 | size = "icon",
46 | ...props
47 | }: PaginationLinkProps) => (
48 |
59 | )
60 | PaginationLink.displayName = "PaginationLink"
61 |
62 | const PaginationPrevious = ({
63 | className,
64 | ...props
65 | }: React.ComponentProps) => (
66 |
72 |
73 | Previous
74 |
75 | )
76 | PaginationPrevious.displayName = "PaginationPrevious"
77 |
78 | const PaginationNext = ({
79 | className,
80 | ...props
81 | }: React.ComponentProps) => (
82 |
88 | Next
89 |
90 |
91 | )
92 | PaginationNext.displayName = "PaginationNext"
93 |
94 | const PaginationEllipsis = ({
95 | className,
96 | ...props
97 | }: React.ComponentProps<"span">) => (
98 |
103 |
104 | More pages
105 |
106 | )
107 | PaginationEllipsis.displayName = "PaginationEllipsis"
108 |
109 | export {
110 | Pagination,
111 | PaginationContent,
112 | PaginationEllipsis,
113 | PaginationItem,
114 | PaginationLink,
115 | PaginationNext,
116 | PaginationPrevious,
117 | }
118 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ))
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ))
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ))
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ))
100 | SelectContent.displayName = SelectPrimitive.Content.displayName
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ))
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ))
135 | SelectItem.displayName = SelectPrimitive.Item.displayName
136 |
137 | const SelectSeparator = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef
140 | >(({ className, ...props }, ref) => (
141 |
146 | ))
147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
148 |
149 | export {
150 | Select,
151 | SelectGroup,
152 | SelectValue,
153 | SelectTrigger,
154 | SelectContent,
155 | SelectLabel,
156 | SelectItem,
157 | SelectSeparator,
158 | SelectScrollUpButton,
159 | SelectScrollDownButton,
160 | }
161 |
--------------------------------------------------------------------------------
/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SheetPrimitive from "@radix-ui/react-dialog"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const Sheet = SheetPrimitive.Root
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger
13 |
14 | const SheetClose = SheetPrimitive.Close
15 |
16 | const SheetPortal = SheetPrimitive.Portal
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ))
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
32 |
33 | const sheetVariants = cva(
34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
35 | {
36 | variants: {
37 | side: {
38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39 | bottom:
40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42 | right:
43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44 | },
45 | },
46 | defaultVariants: {
47 | side: "right",
48 | },
49 | }
50 | )
51 |
52 | interface SheetContentProps
53 | extends React.ComponentPropsWithoutRef,
54 | VariantProps {}
55 |
56 | const SheetContent = React.forwardRef<
57 | React.ElementRef,
58 | SheetContentProps
59 | >(({ side = "right", className, children, ...props }, ref) => (
60 |
61 |
62 |
67 | {children}
68 |
69 |
70 | Close
71 |
72 |
73 |
74 | ))
75 | SheetContent.displayName = SheetPrimitive.Content.displayName
76 |
77 | const SheetHeader = ({
78 | className,
79 | ...props
80 | }: React.HTMLAttributes) => (
81 |
88 | )
89 | SheetHeader.displayName = "SheetHeader"
90 |
91 | const SheetFooter = ({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) => (
95 |
102 | )
103 | SheetFooter.displayName = "SheetFooter"
104 |
105 | const SheetTitle = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SheetTitle.displayName = SheetPrimitive.Title.displayName
116 |
117 | const SheetDescription = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ))
127 | SheetDescription.displayName = SheetPrimitive.Description.displayName
128 |
129 | export {
130 | Sheet,
131 | SheetPortal,
132 | SheetOverlay,
133 | SheetTrigger,
134 | SheetClose,
135 | SheetContent,
136 | SheetHeader,
137 | SheetFooter,
138 | SheetTitle,
139 | SheetDescription,
140 | }
141 |
--------------------------------------------------------------------------------
/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as ToastPrimitives from "@radix-ui/react-toast"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 | import { X } from "lucide-react"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ToastProvider = ToastPrimitives.Provider
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
24 |
25 | const toastVariants = cva(
26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27 | {
28 | variants: {
29 | variant: {
30 | default: "border bg-background text-foreground",
31 | destructive:
32 | "destructive group border-destructive bg-destructive text-destructive-foreground",
33 | },
34 | },
35 | defaultVariants: {
36 | variant: "default",
37 | },
38 | }
39 | )
40 |
41 | const Toast = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef &
44 | VariantProps
45 | >(({ className, variant, ...props }, ref) => {
46 | return (
47 |
52 | )
53 | })
54 | Toast.displayName = ToastPrimitives.Root.displayName
55 |
56 | const ToastAction = React.forwardRef<
57 | React.ElementRef,
58 | React.ComponentPropsWithoutRef
59 | >(({ className, ...props }, ref) => (
60 |
68 | ))
69 | ToastAction.displayName = ToastPrimitives.Action.displayName
70 |
71 | const ToastClose = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, ...props }, ref) => (
75 |
84 |
85 |
86 | ))
87 | ToastClose.displayName = ToastPrimitives.Close.displayName
88 |
89 | const ToastTitle = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
98 | ))
99 | ToastTitle.displayName = ToastPrimitives.Title.displayName
100 |
101 | const ToastDescription = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | ToastDescription.displayName = ToastPrimitives.Description.displayName
112 |
113 | type ToastProps = React.ComponentPropsWithoutRef
114 |
115 | type ToastActionElement = React.ReactElement
116 |
117 | export {
118 | type ToastProps,
119 | type ToastActionElement,
120 | ToastProvider,
121 | ToastViewport,
122 | Toast,
123 | ToastTitle,
124 | ToastDescription,
125 | ToastClose,
126 | ToastAction,
127 | }
128 |
--------------------------------------------------------------------------------
/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | Toast,
5 | ToastClose,
6 | ToastDescription,
7 | ToastProvider,
8 | ToastTitle,
9 | ToastViewport,
10 | } from "@/components/ui/toast"
11 | import { useToast } from "@/components/ui/use-toast"
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title}}
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | )
31 | })}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from "react"
3 |
4 | import type {
5 | ToastActionElement,
6 | ToastProps,
7 | } from "@/components/ui/toast"
8 |
9 | const TOAST_LIMIT = 1
10 | const TOAST_REMOVE_DELAY = 1000000
11 |
12 | type ToasterToast = ToastProps & {
13 | id: string
14 | title?: React.ReactNode
15 | description?: React.ReactNode
16 | action?: ToastActionElement
17 | }
18 |
19 | const actionTypes = {
20 | ADD_TOAST: "ADD_TOAST",
21 | UPDATE_TOAST: "UPDATE_TOAST",
22 | DISMISS_TOAST: "DISMISS_TOAST",
23 | REMOVE_TOAST: "REMOVE_TOAST",
24 | } as const
25 |
26 | let count = 0
27 |
28 | function genId() {
29 | count = (count + 1) % Number.MAX_SAFE_INTEGER
30 | return count.toString()
31 | }
32 |
33 | type ActionType = typeof actionTypes
34 |
35 | type Action =
36 | | {
37 | type: ActionType["ADD_TOAST"]
38 | toast: ToasterToast
39 | }
40 | | {
41 | type: ActionType["UPDATE_TOAST"]
42 | toast: Partial
43 | }
44 | | {
45 | type: ActionType["DISMISS_TOAST"]
46 | toastId?: ToasterToast["id"]
47 | }
48 | | {
49 | type: ActionType["REMOVE_TOAST"]
50 | toastId?: ToasterToast["id"]
51 | }
52 |
53 | interface State {
54 | toasts: ToasterToast[]
55 | }
56 |
57 | const toastTimeouts = new Map>()
58 |
59 | const addToRemoveQueue = (toastId: string) => {
60 | if (toastTimeouts.has(toastId)) {
61 | return
62 | }
63 |
64 | const timeout = setTimeout(() => {
65 | toastTimeouts.delete(toastId)
66 | dispatch({
67 | type: "REMOVE_TOAST",
68 | toastId: toastId,
69 | })
70 | }, TOAST_REMOVE_DELAY)
71 |
72 | toastTimeouts.set(toastId, timeout)
73 | }
74 |
75 | export const reducer = (state: State, action: Action): State => {
76 | switch (action.type) {
77 | case "ADD_TOAST":
78 | return {
79 | ...state,
80 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
81 | }
82 |
83 | case "UPDATE_TOAST":
84 | return {
85 | ...state,
86 | toasts: state.toasts.map((t) =>
87 | t.id === action.toast.id ? { ...t, ...action.toast } : t
88 | ),
89 | }
90 |
91 | case "DISMISS_TOAST": {
92 | const { toastId } = action
93 |
94 | // ! Side effects ! - This could be extracted into a dismissToast() action,
95 | // but I'll keep it here for simplicity
96 | if (toastId) {
97 | addToRemoveQueue(toastId)
98 | } else {
99 | state.toasts.forEach((toast) => {
100 | addToRemoveQueue(toast.id)
101 | })
102 | }
103 |
104 | return {
105 | ...state,
106 | toasts: state.toasts.map((t) =>
107 | t.id === toastId || toastId === undefined
108 | ? {
109 | ...t,
110 | open: false,
111 | }
112 | : t
113 | ),
114 | }
115 | }
116 | case "REMOVE_TOAST":
117 | if (action.toastId === undefined) {
118 | return {
119 | ...state,
120 | toasts: [],
121 | }
122 | }
123 | return {
124 | ...state,
125 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
126 | }
127 | }
128 | }
129 |
130 | const listeners: Array<(state: State) => void> = []
131 |
132 | let memoryState: State = { toasts: [] }
133 |
134 | function dispatch(action: Action) {
135 | memoryState = reducer(memoryState, action)
136 | listeners.forEach((listener) => {
137 | listener(memoryState)
138 | })
139 | }
140 |
141 | type Toast = Omit
142 |
143 | function toast({ ...props }: Toast) {
144 | const id = genId()
145 |
146 | const update = (props: ToasterToast) =>
147 | dispatch({
148 | type: "UPDATE_TOAST",
149 | toast: { ...props, id },
150 | })
151 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
152 |
153 | dispatch({
154 | type: "ADD_TOAST",
155 | toast: {
156 | ...props,
157 | id,
158 | open: true,
159 | onOpenChange: (open) => {
160 | if (!open) dismiss()
161 | },
162 | },
163 | })
164 |
165 | return {
166 | id: id,
167 | dismiss,
168 | update,
169 | }
170 | }
171 |
172 | function useToast() {
173 | const [state, setState] = React.useState(memoryState)
174 |
175 | React.useEffect(() => {
176 | listeners.push(setState)
177 | return () => {
178 | const index = listeners.indexOf(setState)
179 | if (index > -1) {
180 | listeners.splice(index, 1)
181 | }
182 | }
183 | }, [state])
184 |
185 | return {
186 | ...state,
187 | toast,
188 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
189 | }
190 | }
191 |
192 | export { useToast, toast }
193 |
--------------------------------------------------------------------------------
/constants/index.ts:
--------------------------------------------------------------------------------
1 | export const navLinks = [
2 | {
3 | label: "Home",
4 | route: "/",
5 | icon: "/assets/icons/home.svg",
6 | },
7 | {
8 | label: "Image Restore",
9 | route: "/transformations/add/restore",
10 | icon: "/assets/icons/image.svg",
11 | },
12 | {
13 | label: "Generative Fill",
14 | route: "/transformations/add/fill",
15 | icon: "/assets/icons/stars.svg",
16 | },
17 | {
18 | label: "Object Remove",
19 | route: "/transformations/add/remove",
20 | icon: "/assets/icons/scan.svg",
21 | },
22 | {
23 | label: "Object Recolor",
24 | route: "/transformations/add/recolor",
25 | icon: "/assets/icons/filter.svg",
26 | },
27 | {
28 | label: "Background Remove",
29 | route: "/transformations/add/removeBackground",
30 | icon: "/assets/icons/camera.svg",
31 | },
32 | {
33 | label: "Profile",
34 | route: "/profile",
35 | icon: "/assets/icons/profile.svg",
36 | },
37 | {
38 | label: "Buy Credits",
39 | route: "/credits",
40 | icon: "/assets/icons/bag.svg",
41 | },
42 | ];
43 |
44 | export const plans = [
45 | {
46 | _id: 1,
47 | name: "Free",
48 | icon: "/assets/icons/free-plan.svg",
49 | price: 0,
50 | credits: 10,
51 | inclusions: [
52 | {
53 | label: "10 Free Credits",
54 | isIncluded: true,
55 | },
56 | {
57 | label: "Basic Access to Services",
58 | isIncluded: true,
59 | },
60 | {
61 | label: "Priority Customer Support",
62 | isIncluded: false,
63 | },
64 | {
65 | label: "Priority Updates",
66 | isIncluded: false,
67 | },
68 | ],
69 | },
70 | {
71 | _id: 2,
72 | name: "Pro Package",
73 | icon: "/assets/icons/free-plan.svg",
74 | price: 59,
75 | credits: 100,
76 | inclusions: [
77 | {
78 | label: "100 Credits",
79 | isIncluded: true,
80 | },
81 | {
82 | label: "Full Access to Services",
83 | isIncluded: true,
84 | },
85 | {
86 | label: "Priority Customer Support",
87 | isIncluded: true,
88 | },
89 | {
90 | label: "Priority Updates",
91 | isIncluded: false,
92 | },
93 | ],
94 | },
95 | {
96 | _id: 3,
97 | name: "Premium Package",
98 | icon: "/assets/icons/free-plan.svg",
99 | price: 499,
100 | credits: 2000,
101 | inclusions: [
102 | {
103 | label: "2000 Credits",
104 | isIncluded: true,
105 | },
106 | {
107 | label: "Full Access to Services",
108 | isIncluded: true,
109 | },
110 | {
111 | label: "Priority Customer Support",
112 | isIncluded: true,
113 | },
114 | {
115 | label: "Priority Updates",
116 | isIncluded: true,
117 | },
118 | ],
119 | },
120 | ];
121 |
122 | export const transformationTypes = {
123 | restore: {
124 | type: "restore",
125 | title: "Restore Image",
126 | subTitle: "Refine images by removing noise and imperfections",
127 | config: { restore: true },
128 | icon: "image.svg",
129 | },
130 | removeBackground: {
131 | type: "removeBackground",
132 | title: "Background Remove",
133 | subTitle: "Removes the background of the image using AI",
134 | config: { removeBackground: true },
135 | icon: "camera.svg",
136 | },
137 | fill: {
138 | type: "fill",
139 | title: "Generative Fill",
140 | subTitle: "Enhance an image's dimensions using AI outpainting",
141 | config: { fillBackground: true },
142 | icon: "stars.svg",
143 | },
144 | remove: {
145 | type: "remove",
146 | title: "Object Remove",
147 | subTitle: "Identify and eliminate objects from images",
148 | config: {
149 | remove: { prompt: "", removeShadow: true, multiple: true },
150 | },
151 | icon: "scan.svg",
152 | },
153 | recolor: {
154 | type: "recolor",
155 | title: "Object Recolor",
156 | subTitle: "Identify and recolor objects from the image",
157 | config: {
158 | recolor: { prompt: "", to: "", multiple: true },
159 | },
160 | icon: "filter.svg",
161 | },
162 | };
163 |
164 | export const aspectRatioOptions = {
165 | "1:1": {
166 | aspectRatio: "1:1",
167 | label: "Square (1:1)",
168 | width: 1000,
169 | height: 1000,
170 | },
171 | "3:4": {
172 | aspectRatio: "3:4",
173 | label: "Standard Portrait (3:4)",
174 | width: 1000,
175 | height: 1334,
176 | },
177 | "9:16": {
178 | aspectRatio: "9:16",
179 | label: "Phone Portrait (9:16)",
180 | width: 1000,
181 | height: 1778,
182 | },
183 | "16:9": {
184 | aspectRatio: "16:9",
185 | label: "Phone Landscape (16:9)",
186 | width: 1920,
187 | height: 1080,
188 | },
189 | };
190 |
191 | export const defaultValues = {
192 | title: "",
193 | aspectRatio: "",
194 | color: "",
195 | prompt: "",
196 | publicId: "",
197 | };
198 |
199 | export const creditFee = -1;
--------------------------------------------------------------------------------
/lib/actions/image.actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { revalidatePath } from "next/cache";
4 | import { connectToDatabase } from "../database/mongoose";
5 | import { handleError } from "../utils";
6 | import User from "../database/models/user.model";
7 | import Image from "../database/models/image.model";
8 | import { redirect } from "next/navigation";
9 | import { v2 as cloudinary } from "cloudinary";
10 |
11 | const populateUser = (query: any) => query.populate({
12 | path: "author",
13 | model: User,
14 | select: "_id firstName lastName clerkId"
15 | });
16 |
17 | // Add image to database
18 | export async function addImage(
19 | { image, userId, path }: AddImageParams) {
20 | try {
21 | await connectToDatabase();
22 |
23 | const author = await User.findById(userId);
24 |
25 | if (!author) {
26 | throw new Error("User not found!");
27 | }
28 |
29 | const newImage = await Image.create({
30 | ...image,
31 | author: author._id
32 | })
33 |
34 | revalidatePath(path);
35 |
36 | return JSON.parse(JSON.stringify(newImage));
37 | } catch (error) {
38 | handleError(error)
39 | };
40 | };
41 |
42 | // Update image
43 | export async function updateImage(
44 | { image, userId, path }: UpdateImageParams) {
45 | try {
46 | await connectToDatabase();
47 |
48 | const imageToUpdate = await Image.findById(image._id);
49 |
50 | if (!imageToUpdate || imageToUpdate.author.toHexString() !== userId) {
51 | throw new Error("Unauthorized or image not found!");
52 | };
53 |
54 | const updateImage = await Image.findByIdAndUpdate(
55 | imageToUpdate._id,
56 | image,
57 | { new: true }
58 | );
59 |
60 | revalidatePath(path);
61 |
62 | return JSON.parse(JSON.stringify(updateImage));
63 | } catch (error) {
64 | handleError(error);
65 | }
66 | };
67 |
68 | // Delete image
69 | export async function deleteImage(imageId: string) {
70 | try {
71 | await connectToDatabase();
72 |
73 | await Image.findByIdAndDelete(imageId);
74 | } catch (error) {
75 | handleError(error);
76 | } finally {
77 | redirect("/");
78 | }
79 | };
80 |
81 | // Get image by ID
82 | export async function getImageById(imageId: string) {
83 | try {
84 | await connectToDatabase();
85 |
86 | const image = await populateUser(Image.findById(imageId));
87 |
88 | if (!image) {
89 | throw new Error("Image not found!");
90 | }
91 |
92 | return JSON.parse(JSON.stringify(image));
93 | } catch (error) {
94 | handleError(error)
95 | }
96 | };
97 |
98 | // Get all images
99 | export async function getAllImages({ limit = 9, page = 1, searchQuery = '' }: {
100 | limit?: number;
101 | page: number;
102 | searchQuery?: string;
103 | }) {
104 | try {
105 | await connectToDatabase();
106 |
107 | cloudinary.config({
108 | cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
109 | api_key: process.env.CLOUDINARY_API_KEY,
110 | api_secret: process.env.CLOUDINARY_API_SECRET,
111 | secure: true,
112 | });
113 |
114 | let expression = "folder=imagenko";
115 |
116 | if (searchQuery) {
117 | expression += ` AND ${searchQuery}`
118 | };
119 |
120 | const { resources } = await cloudinary.search
121 | .expression(expression)
122 | .execute();
123 |
124 | const resourceIds = resources.map((resource: any) => resource.public_id);
125 |
126 | let query = {};
127 |
128 | if (searchQuery) {
129 | query = {
130 | publicId: {
131 | $in: resourceIds
132 | }
133 | };
134 | };
135 |
136 | const skipAmount = (Number(page) - 1) * limit;
137 |
138 | const images = await populateUser(Image.find(query))
139 | .sort({ updatedAt: -1 })
140 | .skip(skipAmount)
141 | .limit(limit);
142 |
143 | const totalImages = await Image.find(query).countDocuments();
144 | const savedImages = await Image.find().countDocuments();
145 |
146 | return {
147 | data: JSON.parse(JSON.stringify(images)),
148 | totalPage: Math.ceil(totalImages / limit),
149 | savedImages,
150 | };
151 | } catch (error) {
152 | handleError(error);
153 | };
154 | };
155 |
156 | // Get User Images
157 | export async function getUserImages({
158 | limit = 9,
159 | page = 1,
160 | userId,
161 | }: {
162 | limit?: number;
163 | page: number;
164 | userId: string;
165 | }) {
166 | try {
167 | await connectToDatabase();
168 |
169 | const skipAmount = (Number(page) - 1) * limit;
170 |
171 | const images = await populateUser(Image.find({ author: userId }))
172 | .sort({ updatedAt: -1 })
173 | .skip(skipAmount)
174 | .limit(limit);
175 |
176 | const totalImages = await Image.find({ author: userId }).countDocuments();
177 |
178 | return {
179 | data: JSON.parse(JSON.stringify(images)),
180 | totalPages: Math.ceil(totalImages / limit),
181 | };
182 | } catch (error) {
183 | handleError(error);
184 | };
185 | };
--------------------------------------------------------------------------------
/lib/actions/transaction.actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 | import { redirect } from "next/navigation";
3 | import Stripe from "stripe";
4 | import { connectToDatabase } from "../database/mongoose";
5 | import Transaction from "../database/models/transaction.model";
6 | import { updateCredits } from "./user.actions";
7 | import { handleError } from "../utils";
8 |
9 | export async function checkoutCredits(transaction: CheckoutTransactionParams) {
10 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
11 |
12 | const amount = Number(transaction.amount) * 100;
13 |
14 | const session = await stripe.checkout.sessions.create({
15 | line_items: [
16 | {
17 | price_data: {
18 | currency: 'usd',
19 | unit_amount: amount,
20 | product_data: {
21 | name: transaction.plan,
22 | }
23 | },
24 | quantity: 1
25 | }
26 | ],
27 | metadata: {
28 | plan: transaction.plan,
29 | credits: transaction.credits,
30 | buyerId: transaction.buyerId,
31 | },
32 | mode: 'payment',
33 | success_url: `${process.env.NEXT_PUBLIC_SERVER_URL}/profile`,
34 | cancel_url: `${process.env.NEXT_PUBLIC_SERVER_URL}/`,
35 | });
36 |
37 | redirect(session.url!);
38 | };
39 |
40 | export async function createTransaction(transaction: CreateTransactionParams) {
41 | try {
42 | await connectToDatabase();
43 |
44 | // Create a new transaction with a buyerId
45 | const newTransaction = await Transaction.create({
46 | ...transaction, buyer: transaction.buyerId
47 | });
48 |
49 | await updateCredits(transaction.buyerId, transaction.credits);
50 |
51 | return JSON.parse(JSON.stringify(newTransaction));
52 | } catch (error) {
53 | handleError(error);
54 | };
55 | };
--------------------------------------------------------------------------------
/lib/actions/user.actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { revalidatePath } from "next/cache";
4 |
5 | import User from "../database/models/user.model";
6 | import { connectToDatabase } from "../database/mongoose";
7 | import { handleError } from "../utils";
8 |
9 | // CREATE
10 | export async function createUser(user: CreateUserParams) {
11 | try {
12 | await connectToDatabase();
13 |
14 | const newUser = await User.create(user);
15 |
16 | return JSON.parse(JSON.stringify(newUser));
17 | } catch (error) {
18 | handleError(error);
19 | }
20 | }
21 |
22 | // READ
23 | export async function getUserById(userId: string) {
24 | try {
25 | await connectToDatabase();
26 |
27 | const user = await User.findOne({ clerkId: userId });
28 |
29 | if (!user) throw new Error("User not found");
30 |
31 | return JSON.parse(JSON.stringify(user));
32 | } catch (error) {
33 | handleError(error);
34 | }
35 | }
36 |
37 | // UPDATE
38 | export async function updateUser(clerkId: string, user: UpdateUserParams) {
39 | try {
40 | await connectToDatabase();
41 |
42 | const updatedUser = await User.findOneAndUpdate({ clerkId }, user, {
43 | new: true,
44 | });
45 |
46 | if (!updatedUser) throw new Error("User update failed");
47 |
48 | return JSON.parse(JSON.stringify(updatedUser));
49 | } catch (error) {
50 | handleError(error);
51 | }
52 | }
53 |
54 | // DELETE
55 | export async function deleteUser(clerkId: string) {
56 | try {
57 | await connectToDatabase();
58 |
59 | // Find user to delete
60 | const userToDelete = await User.findOne({ clerkId });
61 |
62 | if (!userToDelete) {
63 | throw new Error("User not found");
64 | }
65 |
66 | // Delete user
67 | const deletedUser = await User.findByIdAndDelete(userToDelete._id);
68 | revalidatePath("/");
69 |
70 | return deletedUser ? JSON.parse(JSON.stringify(deletedUser)) : null;
71 | } catch (error) {
72 | handleError(error);
73 | }
74 | }
75 |
76 | // USE CREDITS
77 | export async function updateCredits(userId: string, creditFee: number) {
78 | try {
79 | await connectToDatabase();
80 |
81 | const updatedUserCredits = await User.findOneAndUpdate(
82 | { _id: userId },
83 | { $inc: { creditBalance: creditFee } },
84 | { new: true }
85 | )
86 |
87 | if (!updatedUserCredits) throw new Error("User credits update failed");
88 |
89 | return JSON.parse(JSON.stringify(updatedUserCredits));
90 | } catch (error) {
91 | handleError(error);
92 | }
93 | }
--------------------------------------------------------------------------------
/lib/database/models/image.model.ts:
--------------------------------------------------------------------------------
1 | import { Document, Schema, model, models } from "mongoose";
2 | import { stringify } from "qs";
3 |
4 | export interface IImage extends Document {
5 | title: string;
6 | transformationType: string;
7 | publicId: string;
8 | secureURL: string;
9 | width?: number;
10 | height?: number;
11 | config?: object;
12 | transformationURL?: string;
13 | aspectRatio?: string;
14 | color?: string;
15 | prompt?: string;
16 | author: {
17 | _id: string;
18 | firstName: string;
19 | lastName: string;
20 | }
21 | createdAt?: Date;
22 | updatedAt?: Date;
23 | }
24 |
25 | const ImageShema = new Schema({
26 | title: { type: String, required: true },
27 | transformationType: { type: String, required: true },
28 | publicId: { type: String, required: true },
29 | secureURL: { type: String, required: true },
30 | width: { type: Number },
31 | height: { type: Number },
32 | config: { type: Object },
33 | transformationURL: { type: String },
34 | aspectRatio: { type: String },
35 | color: { type: String },
36 | prompt: { type: String },
37 | author: { type: Schema.Types.ObjectId, ref: "User" },
38 | createdAt: { type: Date, default: Date.now },
39 | updateAt: { type: Date, default: Date.now },
40 | });
41 |
42 | const Image = models?.Image || model("Image", ImageShema);
43 |
44 | export default Image;
--------------------------------------------------------------------------------
/lib/database/models/transaction.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model, models } from "mongoose";
2 |
3 | const TransactionSchema = new Schema({
4 | createdAt: {
5 | type: Date,
6 | default: Date.now,
7 | },
8 | stripeId: {
9 | type: String,
10 | required: true,
11 | unique: true,
12 | },
13 | amount: {
14 | type: Number,
15 | required: true,
16 | },
17 | plan: {
18 | type: String,
19 | },
20 | credits: {
21 | type: Number,
22 | },
23 | buyer: {
24 | type: Schema.Types.ObjectId,
25 | ref: "User",
26 | },
27 | });
28 |
29 | const Transaction = models?.Transaction || model("Transaction", TransactionSchema);
30 |
31 | export default Transaction;
--------------------------------------------------------------------------------
/lib/database/models/user.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model, models } from "mongoose";
2 |
3 | const UserSchema = new Schema({
4 | clerkId: {
5 | type: String,
6 | required: true,
7 | unique: true,
8 | },
9 | email: {
10 | type: String,
11 | required: true,
12 | unique: true,
13 | },
14 | username: {
15 | type: String,
16 | required: true,
17 | unique: true,
18 | },
19 | photo: {
20 | type: String,
21 | required: true,
22 | },
23 | firstName: {
24 | type: String,
25 | },
26 | lastName: {
27 | type: String,
28 | },
29 | planId: {
30 | type: Number,
31 | default: 1,
32 | },
33 | creditBalance: {
34 | type: Number,
35 | default: 10,
36 | },
37 | });
38 |
39 | const User = models?.User || model("User", UserSchema);
40 |
41 | export default User;
--------------------------------------------------------------------------------
/lib/database/mongoose.ts:
--------------------------------------------------------------------------------
1 | import mongoose, { Mongoose } from "mongoose";
2 |
3 | const MONGODB_URL = process.env.MONGODB_URL;
4 |
5 | interface MongooseConnection {
6 | conn: Mongoose | null;
7 | promise: Promise | null;
8 | }
9 |
10 | let cached: MongooseConnection = (global as any).mongoose;
11 |
12 | if (!cached) {
13 | cached = (global as any).mongoose = { conn: null, promise: null };
14 | }
15 |
16 | export const connectToDatabase = async () => {
17 | if (cached.conn) {
18 | return cached.conn;
19 | }
20 |
21 | if (!MONGODB_URL) {
22 | throw new Error("Missing MONGODB_URL environment variable inside .env.local");
23 | }
24 |
25 | cached.promise = cached.promise || mongoose.connect(MONGODB_URL), {
26 | dbName: "imagenko_db",
27 | bufferCommands: false
28 | };
29 |
30 | cached.conn = await cached.promise;
31 |
32 | return cached.conn;
33 | }
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable prefer-const */
2 | /* eslint-disable no-prototype-builtins */
3 | import { type ClassValue, clsx } from "clsx";
4 | import qs from "qs";
5 | import { twMerge } from "tailwind-merge";
6 |
7 | import { aspectRatioOptions } from "@/constants";
8 |
9 | export function cn(...inputs: ClassValue[]) {
10 | return twMerge(clsx(inputs));
11 | }
12 |
13 | // ERROR HANDLER
14 | export const handleError = (error: unknown) => {
15 | if (error instanceof Error) {
16 | // This is a native JavaScript error (e.g., TypeError, RangeError)
17 | console.error(error.message);
18 | throw new Error(`Error: ${error.message}`);
19 | } else if (typeof error === "string") {
20 | // This is a string error message
21 | console.error(error);
22 | throw new Error(`Error: ${error}`);
23 | } else {
24 | // This is an unknown type of error
25 | console.error(error);
26 | throw new Error(`Unknown error: ${JSON.stringify(error)}`);
27 | }
28 | };
29 |
30 | // PLACEHOLDER LOADER - while image is transforming
31 | const shimmer = (w: number, h: number) => `
32 | `;
44 |
45 | const toBase64 = (str: string) =>
46 | typeof window === "undefined"
47 | ? Buffer.from(str).toString("base64")
48 | : window.btoa(str);
49 |
50 | export const dataUrl = `data:image/svg+xml;base64,${toBase64(
51 | shimmer(1000, 1000)
52 | )}`;
53 | // ==== End
54 |
55 | // FORM URL QUERY
56 | export const formUrlQuery = ({
57 | searchParams,
58 | key,
59 | value,
60 | }: FormUrlQueryParams) => {
61 | const params = { ...qs.parse(searchParams.toString()), [key]: value };
62 |
63 | return `${window.location.pathname}?${qs.stringify(params, {
64 | skipNulls: true,
65 | })}`;
66 | };
67 |
68 | // REMOVE KEY FROM QUERY
69 | export function removeKeysFromQuery({
70 | searchParams,
71 | keysToRemove,
72 | }: RemoveUrlQueryParams) {
73 | const currentUrl = qs.parse(searchParams);
74 |
75 | keysToRemove.forEach((key) => {
76 | delete currentUrl[key];
77 | });
78 |
79 | // Remove null or undefined values
80 | Object.keys(currentUrl).forEach(
81 | (key) => currentUrl[key] == null && delete currentUrl[key]
82 | );
83 |
84 | return `${window.location.pathname}?${qs.stringify(currentUrl)}`;
85 | }
86 |
87 | // DEBOUNCE
88 | export const debounce = (func: (...args: any[]) => void, delay: number) => {
89 | let timeoutId: NodeJS.Timeout | null;
90 | return (...args: any[]) => {
91 | if (timeoutId) clearTimeout(timeoutId);
92 | timeoutId = setTimeout(() => func.apply(null, args), delay);
93 | };
94 | };
95 |
96 | // GET IMAGE SIZE
97 | export type AspectRatioKey = keyof typeof aspectRatioOptions;
98 | export const getImageSize = (
99 | type: string,
100 | image: any,
101 | dimension: "width" | "height"
102 | ): number => {
103 | if (type === "fill") {
104 | return (
105 | aspectRatioOptions[image.aspectRatio as AspectRatioKey]?.[dimension] ||
106 | 1000
107 | );
108 | }
109 | return image?.[dimension] || 1000;
110 | };
111 |
112 | // DOWNLOAD IMAGE
113 | export const download = (url: string, filename: string) => {
114 | if (!url) {
115 | throw new Error("Resource URL not provided! You need to provide one");
116 | }
117 |
118 | fetch(url)
119 | .then((response) => response.blob())
120 | .then((blob) => {
121 | const blobURL = URL.createObjectURL(blob);
122 | const a = document.createElement("a");
123 | a.href = blobURL;
124 |
125 | if (filename && filename.length)
126 | a.download = `${filename.replace(" ", "_")}.png`;
127 | document.body.appendChild(a);
128 | a.click();
129 | })
130 | .catch((error) => console.log({ error }));
131 | };
132 |
133 | // DEEP MERGE OBJECTS
134 | export const deepMergeObjects = (obj1: any, obj2: any) => {
135 | if (obj2 === null || obj2 === undefined) {
136 | return obj1;
137 | }
138 |
139 | let output = { ...obj2 };
140 |
141 | for (let key in obj1) {
142 | if (obj1.hasOwnProperty(key)) {
143 | if (
144 | obj1[key] &&
145 | typeof obj1[key] === "object" &&
146 | obj2[key] &&
147 | typeof obj2[key] === "object"
148 | ) {
149 | output[key] = deepMergeObjects(obj1[key], obj2[key]);
150 | } else {
151 | output[key] = obj1[key];
152 | }
153 | }
154 | }
155 |
156 | return output;
157 | };
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { authMiddleware } from "@clerk/nextjs";
2 |
3 | export default authMiddleware({
4 | // Routes that can be accessed while signed out
5 | publicRoutes: ['/', '/api/webhooks/clerk', '/api/webhooks/stripe']
6 | });
7 |
8 | export const config = {
9 | // Protects all routes, including api/trpc.
10 | // See https://clerk.com/docs/references/nextjs/auth-middleware
11 | // for more information about configuring your Middleware
12 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
13 | };
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: 'https',
7 | hostname: 'res.cloudinary.com',
8 | port: ''
9 | }
10 | ]
11 | }
12 | };
13 |
14 | export default nextConfig;
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-app-ai-saas",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbo",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@clerk/nextjs": "^4.29.7",
13 | "@hookform/resolvers": "^3.3.4",
14 | "@radix-ui/react-alert-dialog": "^1.0.5",
15 | "@radix-ui/react-dialog": "^1.0.5",
16 | "@radix-ui/react-label": "^2.0.2",
17 | "@radix-ui/react-select": "^2.0.0",
18 | "@radix-ui/react-slot": "^1.0.2",
19 | "@radix-ui/react-toast": "^1.1.5",
20 | "@stripe/stripe-js": "^3.0.3",
21 | "class-variance-authority": "^0.7.0",
22 | "cloudinary": "^2.0.1",
23 | "clsx": "^2.1.0",
24 | "lucide-react": "^0.331.0",
25 | "mongodb": "^6.3.0",
26 | "mongoose": "^8.1.3",
27 | "next": "14.1.0",
28 | "next-cloudinary": "^5.20.0",
29 | "qs": "^6.11.2",
30 | "react": "^18",
31 | "react-dom": "^18",
32 | "react-hook-form": "^7.50.1",
33 | "stripe": "^14.17.0",
34 | "svix": "^1.18.0",
35 | "tailwind-merge": "^2.2.1",
36 | "tailwindcss-animate": "^1.0.7",
37 | "zod": "^3.22.4"
38 | },
39 | "devDependencies": {
40 | "@types/node": "^20",
41 | "@types/react": "^18",
42 | "@types/react-dom": "^18",
43 | "autoprefixer": "^10.0.1",
44 | "eslint": "^8",
45 | "eslint-config-next": "14.1.0",
46 | "postcss": "^8",
47 | "tailwindcss": "^3.3.0",
48 | "typescript": "^5"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/assets/icons/add.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/bag.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/camera.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/caret-down.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/public/assets/icons/check.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/public/assets/icons/close.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/coins.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/public/assets/icons/credit-coins.svg:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/public/assets/icons/cross.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/public/assets/icons/download.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/filter.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/free-plan.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/public/assets/icons/home.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/image.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/menu.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/photo.svg:
--------------------------------------------------------------------------------
1 |
76 |
--------------------------------------------------------------------------------
/public/assets/icons/profile.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/scan.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/search.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/spinner.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/icons/stars.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/images/banner-bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getFrontend/next-app-ai-saas/247659542a62d271bad24a0824432b7195a34b02/public/assets/images/banner-bg.png
--------------------------------------------------------------------------------
/public/assets/images/gradient-bg.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/public/assets/images/logo-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getFrontend/next-app-ai-saas/247659542a62d271bad24a0824432b7195a34b02/public/assets/images/logo-icon.png
--------------------------------------------------------------------------------
/public/assets/images/logo-text.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getFrontend/next-app-ai-saas/247659542a62d271bad24a0824432b7195a34b02/public/assets/images/logo-text.png
--------------------------------------------------------------------------------
/public/assets/images/stacked-coins.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getFrontend/next-app-ai-saas/247659542a62d271bad24a0824432b7195a34b02/public/assets/images/stacked-coins.png
--------------------------------------------------------------------------------
/public/imagenko-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getFrontend/next-app-ai-saas/247659542a62d271bad24a0824432b7195a34b02/public/imagenko-1.png
--------------------------------------------------------------------------------
/public/imagenko-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getFrontend/next-app-ai-saas/247659542a62d271bad24a0824432b7195a34b02/public/imagenko-2.png
--------------------------------------------------------------------------------
/public/imagenko-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getFrontend/next-app-ai-saas/247659542a62d271bad24a0824432b7195a34b02/public/imagenko-3.png
--------------------------------------------------------------------------------
/public/imagenko-4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getFrontend/next-app-ai-saas/247659542a62d271bad24a0824432b7195a34b02/public/imagenko-4.png
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{ts,tsx}",
7 | "./components/**/*.{ts,tsx}",
8 | "./app/**/*.{ts,tsx}",
9 | "./src/**/*.{ts,tsx}",
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | purple: {
28 | 100: "#F4F7FE",
29 | 200: "#BCB6FF",
30 | 400: "#868CFF",
31 | 500: "#7857FF",
32 | 600: "#4318FF",
33 | },
34 | dark: {
35 | 400: "#7986AC",
36 | 500: "#606C80",
37 | 600: "#2B3674",
38 | 700: "#384262",
39 | },
40 | primary: {
41 | DEFAULT: "hsl(var(--primary))",
42 | foreground: "hsl(var(--primary-foreground))",
43 | },
44 | secondary: {
45 | DEFAULT: "hsl(var(--secondary))",
46 | foreground: "hsl(var(--secondary-foreground))",
47 | },
48 | destructive: {
49 | DEFAULT: "hsl(var(--destructive))",
50 | foreground: "hsl(var(--destructive-foreground))",
51 | },
52 | muted: {
53 | DEFAULT: "hsl(var(--muted))",
54 | foreground: "hsl(var(--muted-foreground))",
55 | },
56 | accent: {
57 | DEFAULT: "hsl(var(--accent))",
58 | foreground: "hsl(var(--accent-foreground))",
59 | },
60 | popover: {
61 | DEFAULT: "hsl(var(--popover))",
62 | foreground: "hsl(var(--popover-foreground))",
63 | },
64 | card: {
65 | DEFAULT: "hsl(var(--card))",
66 | foreground: "hsl(var(--card-foreground))",
67 | },
68 | },
69 | fontFamily: {
70 | IBMPlex: ["var(--font-ibm-plex)"],
71 | },
72 | backgroundImage: {
73 | "purple-gradient": "url('/assets/images/gradient-bg.svg')",
74 | banner: "url('/assets/images/banner-bg.png')",
75 | },
76 | borderRadius: {
77 | lg: "var(--radius)",
78 | md: "calc(var(--radius) - 2px)",
79 | sm: "calc(var(--radius) - 4px)",
80 | },
81 | keyframes: {
82 | "accordion-down": {
83 | from: { height: "0" },
84 | to: { height: "var(--radix-accordion-content-height)" },
85 | },
86 | "accordion-up": {
87 | from: { height: "var(--radix-accordion-content-height)" },
88 | to: { height: "0" },
89 | },
90 | },
91 | animation: {
92 | "accordion-down": "accordion-down 0.2s ease-out",
93 | "accordion-up": "accordion-up 0.2s ease-out",
94 | },
95 | },
96 | },
97 | plugins: [require("tailwindcss-animate")],
98 | } satisfies Config
99 |
100 | export default config
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 |
3 | // ====== USER PARAMS
4 | declare type CreateUserParams = {
5 | clerkId: string;
6 | email: string;
7 | username: string;
8 | firstName: string;
9 | lastName: string;
10 | photo: string;
11 | };
12 |
13 | declare type UpdateUserParams = {
14 | firstName: string;
15 | lastName: string;
16 | username: string;
17 | photo: string;
18 | };
19 |
20 | // ====== IMAGE PARAMS
21 | declare type AddImageParams = {
22 | image: {
23 | title: string;
24 | publicId: string;
25 | transformationType: string;
26 | width: number;
27 | height: number;
28 | config: any;
29 | secureURL: string;
30 | transformationURL: string;
31 | aspectRatio: string | undefined;
32 | prompt: string | undefined;
33 | color: string | undefined;
34 | };
35 | userId: string;
36 | path: string;
37 | };
38 |
39 | declare type UpdateImageParams = {
40 | image: {
41 | _id: string;
42 | title: string;
43 | publicId: string;
44 | transformationType: string;
45 | width: number;
46 | height: number;
47 | config: any;
48 | secureURL: string;
49 | transformationURL: string;
50 | aspectRatio: string | undefined;
51 | prompt: string | undefined;
52 | color: string | undefined;
53 | };
54 | userId: string;
55 | path: string;
56 | };
57 |
58 | declare type Transformations = {
59 | restore?: boolean;
60 | fillBackground?: boolean;
61 | remove?: {
62 | prompt: string;
63 | removeShadow?: boolean;
64 | multiple?: boolean;
65 | };
66 | recolor?: {
67 | prompt?: string;
68 | to: string;
69 | multiple?: boolean;
70 | };
71 | removeBackground?: boolean;
72 | };
73 |
74 | // ====== TRANSACTION PARAMS
75 | declare type CheckoutTransactionParams = {
76 | plan: string;
77 | credits: number;
78 | amount: number;
79 | buyerId: string;
80 | };
81 |
82 | declare type CreateTransactionParams = {
83 | stripeId: string;
84 | amount: number;
85 | credits: number;
86 | plan: string;
87 | buyerId: string;
88 | createdAt: Date;
89 | };
90 |
91 | declare type TransformationTypeKey =
92 | | "restore"
93 | | "fill"
94 | | "remove"
95 | | "recolor"
96 | | "removeBackground";
97 |
98 | // ====== URL QUERY PARAMS
99 | declare type FormUrlQueryParams = {
100 | searchParams: string;
101 | key: string;
102 | value: string | number | null;
103 | };
104 |
105 | declare type UrlQueryParams = {
106 | params: string;
107 | key: string;
108 | value: string | null;
109 | };
110 |
111 | declare type RemoveUrlQueryParams = {
112 | searchParams: string;
113 | keysToRemove: string[];
114 | };
115 |
116 | declare type SearchParamProps = {
117 | params: { id: string; type: TransformationTypeKey };
118 | searchParams: { [key: string]: string | string[] | undefined };
119 | };
120 |
121 | declare type TransformationFormProps = {
122 | action: "Add" | "Update";
123 | userId: string;
124 | type: TransformationTypeKey;
125 | creditBalance: number;
126 | data?: IImage | null;
127 | config?: Transformations | null;
128 | };
129 |
130 | declare type TransformedImageProps = {
131 | image: any;
132 | type: string;
133 | title: string;
134 | transformationConfig: Transformations | null;
135 | isTransforming: boolean;
136 | hasDownload?: boolean;
137 | setIsTransforming?: React.Dispatch>;
138 | };
--------------------------------------------------------------------------------