Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/aknibircse/rupantor-ai-saas
Hereβs your paragraph with emojis added: A REAL π Software-as-a-Service (SaaS) App with AI Features π€; Payments π³ & Credits System ποΈ that You Might Even Turn Into a Side Income π° or Business Idea π‘ Using Next.js 14 π, Clerk π, MongoDB ποΈ, Cloudinary AI πΌοΈ, and Stripe π΅.
https://github.com/aknibircse/rupantor-ai-saas
clerk-auth cloudinary-ai cloudinary-sdk docker-container docker-image image-processing mongodb mongodb-atlas mongoose newman nextjs nginx-server nodejs postman shadcn stripe-payments tailwind-css typescript vercel-deployment webhooks-api
Last synced: 6 days ago
JSON representation
Hereβs your paragraph with emojis added: A REAL π Software-as-a-Service (SaaS) App with AI Features π€; Payments π³ & Credits System ποΈ that You Might Even Turn Into a Side Income π° or Business Idea π‘ Using Next.js 14 π, Clerk π, MongoDB ποΈ, Cloudinary AI πΌοΈ, and Stripe π΅.
- Host: GitHub
- URL: https://github.com/aknibircse/rupantor-ai-saas
- Owner: aknibircse
- License: mit
- Created: 2024-12-12T20:25:19.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2025-02-02T22:44:39.000Z (7 days ago)
- Last Synced: 2025-02-02T23:24:12.661Z (7 days ago)
- Topics: clerk-auth, cloudinary-ai, cloudinary-sdk, docker-container, docker-image, image-processing, mongodb, mongodb-atlas, mongoose, newman, nextjs, nginx-server, nodejs, postman, shadcn, stripe-payments, tailwind-css, typescript, vercel-deployment, webhooks-api
- Language: TypeScript
- Homepage: https://rupantor.aknibir.dev
- Size: 501 KB
- Stars: 3
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE.md
Awesome Lists containing this project
README
RUPANTOR - AN AI SAAS PLATFORM
![]()
![]()
![]()
![]()
![]()
A REAL Software-as-a-Service App with AI Features; Payments & Credits System that You Might Even Turn Into a Side Income or Business Idea Using Next.js 14, Clerk, MongoDB, Cloudinary AI, and Stripe.
1. π€ [INTRODUCTION](#introduction)
2. βοΈ [TECH STACK](#tech-stack)
3. π [FEATURES](#features)
4. π€Έ [QUICK START](#quick-start)
5. π³ [DOCKER SETUP & USAGE](#docker-setup-and-usage)
6. πΈοΈ [CODE SNIPPETS](#snippets)
7. π [COPYRIGHTS](#copyright)RUPANTOR is an AI Image SaaS Platform that Excels in Image Processing Capabilities, Integrates a Secure Payment Infrastructure, Offers Advanced Image Search Functionalities, and Supports Multiple AI features, including Image Restoration, Recoloring, Object Removal, Generative Filling, and Background Removal.
- Next JS
- TypeScript
- MongoDB
- Clerk
- Cloudinary
- Stripe
- Shadcn
- TailwindCSSπ **Authentication and Authorization**: Secure User Access with Registration, Login, and Route Protection.
π **Community Image Showcase**: Explore User Transformations with Easy Navigation Using Pagination.
π **Advanced Image Search**: Find Images by Content or Objects Present Inside the Image Quickly and Accurately.
π **Image Restoration**: Revive Old or Damaged Images Effortlessly.
π **Image Recoloring**: Customize Images by Replacing Objects with Desired Colors Easily
π **Image Generative Fill**: Fill in Missing Areas of Images Seamlessly.
π **Object Removal**: Clean Up Images by Removing Unwanted Objects with Precision.
π **Background Removal**: Extract Objects From Backgrounds with Ease.
π **Download Transformed Images**: Save and Share AI-Transformed Images Conveniently.
π **Transformed Image Details**: View Details of Transformations for Each Image.
π **Transformation Management**: Control Over Deletion and Updates of Transformations.
π **Credits System**: Earn or Purchase Credits for Image Transformations.
π **Profile Page**: Access Transformed Images and Credit Information Personally.
π **Credits Purchase**: Securely Buy Credits via Stripe for Uninterrupted Use.
π **Responsive UI/UX**: A Seamless Experience Across Devices with a User-Friendly Interface.
π And Many More, Including Code Architecture and Reusability.
Follow These Steps to Setup The Project Locally on Your Machine.
**Prerequisites**
Make Sure, You have The Following Dependancies Installed on Your Machine:
- [Git](https://git-scm.com/) (Git Version Control)
- [Node.js](https://nodejs.org/en) (JavaScript Runtime Environment)
- [nvm](https://github.com/nvm-sh/nvm) (Node Version Manager)
- [npm](https://www.npmjs.com/) (Node Package Manager)
- [mongodb](https://www.npmjs.com/) (MongoDB Database)**Cloning The Repository**
```bash
git clone https://github.com/aknibircse/rupantor-ai-saas.git
cd rupantor-ai-saas
```**Installation**
Install The Project Dependencies Using NPM: *(* *Reuired NodeJS_V.18.12.0_LTS* *)*
```bash
npm install --legacy-peer-deps
```**Setup Environment Variables**
Create a New File Named `.env.local` in The Root of Your Project & Add The Following Variables with Their Values:
```env
# NEXT.JS CONFIGURATIONNEXT_PUBLIC_CLERK_SIGN_IN_URL =
NEXT_PUBLIC_CLERK_SIGN_UP_URL =
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL =
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL =# MONGODB CONFIGURATION
MONGODB_URL =
# CLERK CONFIGURATION
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY =
CLERK_SECRET_KEY =
WEBHOOK_SECRET =# CLOUDINARY CONFIGURATION
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME =
NEXT_PUBLIC_CLOUDINARY_API_KEY =
NEXT_PUBLIC_CLOUDINARY_API_SECRET =
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET =
NEXT_PUBLIC_CLOUDINARY_BUCKET_NAME =# STRIPE CONFIGURATION
NEXT_PUBLIC_STRIPE_WEBHOOK_CHECKOUT_URL =
NEXT_PUBLIC_STRIPE_SECRET_KEY =
NEXT_PUBLIC_STRIPE_WEBHOOK_SECRET =
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY =
```Replace The Placeholder Values with Your Actual Respective Account Credentials. You can Obtain These Credentials by Signing-up on The ***[Clerk](https://clerk.com/)***, ***[MongoDB](https://www.mongodb.com/)***, ***[Cloudinary](https://cloudinary.com/)*** and ***[Stripe](https://stripe.com)***.
**Run This Project In Development Mode**
```bash
npm run start:dev
```**Build Production Version Of This Project**
```bash
npm run build:prod
```**Running The Build Version Of This Project**
```bash
npm run start:prod
```**Validate ESLint Rule For Errors & Warnings**
```bash
npm run valid:lint
```Open Your Browser & View The Project at: ***[http://localhost:3000](http://localhost:3000)***
## π³ Docker Setup & Usage
### Prerequisites
- Docker Installed On Your System.
- Docker Compose Installed On Your System.
- `.env` File With Required Environment Variables.### Docker Commands
1. **Build & Run The Application**
```bash
# Build & Start All Services
docker compose up --build# Run In Detached Mode (Background)
docker compose up -d --build
```2. **Stop The Application**
```bash
# Stop All Services
docker compose down# Stop And Remove Volumes
docker compose down -v
```3. **View Logs**
```bash
# View Logs Of All Services
docker compose logs# Follow Logs In Real-Time
docker compose logs -f# View logs Of Specific Service
docker compose logs app
docker compose logs mongodb
```4. **Container Management**
```bash
# List Running Containers
docker compose ps# Restart Services
docker compose restart# Restart Specific Service
docker compose restart app
```### Project Structure
- `Dockerfile`: Multi-Stage Build Configuration For The Next.js Application.
- `compose.yaml`: Docker Compose Configuration Defining Services:
- `app`: Next.js Application Service.
- `mongodb`: MongoDB Database Service.
- `nginx`: Reverse Proxy Service. (If Configured)### Environment Variables
Make Sure to Create a `.env` File In The Project Root With The Following Variables:
```plaintext
NEXT_PUBLIC_CLERK_SIGN_IN_URL=
NEXT_PUBLIC_CLERK_SIGN_UP_URL=
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=
MONGODB_URL=
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
WEBHOOK_SECRET=
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=
NEXT_PUBLIC_CLOUDINARY_API_KEY=
NEXT_PUBLIC_CLOUDINARY_API_SECRET=
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=
NEXT_PUBLIC_CLOUDINARY_BUCKET_NAME=
NEXT_PUBLIC_STRIPE_WEBHOOK_CHECKOUT_URL=
NEXT_PUBLIC_STRIPE_SECRET_KEY=
NEXT_PUBLIC_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
```### Networking
- The Application Runs On Port 3000.
- MongoDB Runs On Default Port 27017.
- Services Are Connected Through The `rupantor_network` Network.### Health Checks
- Built-In Health Checks Are Configured For Both The Application & MongoDB.
- Services Will Automatically Restart If They Become Unhealthy.### Production Deployment
For Production Deployment:
1. Ensure All Environment Variables Are Properly Set.
2. Use Production-Grade MongoDB Credentials.
3. Consider Using Docker Volumes For Persistent Data.
4. Enable & Configure SSL/TLS For Exposing To The Internet### Troubleshooting
1. **Container Fails To Start**
- Check Logs: `docker compose logs app`
- Verify Environment Variables.
- Ensure Ports Are Not In Use.2. **MongoDB Connection Issues**
- Ensure MongoDB Container Is Healthy.
- Check Network Connectivity.
- Verify MongoDB Credentials.3. **Performance Issues**
- Monitor Container Resources: `docker stats`
- Check Application Logs For Bottlenecks.
- Consider Scaling Services If Needed.
tailwind.config.ts
```typescript
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
purple: {
100: "#F4F7FE",
200: "#BCB6FF",
400: "#868CFF",
500: "#7857FF",
600: "#4318FF",
},
dark: {
400: "#7986AC",
500: "#606C80",
600: "#2B3674",
700: "#384262",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
fontFamily: {
IBMPlex: ["var(--font-ibm-plex)"],
},
backgroundImage: {
"purple-gradient": "url('/assets/images/gradient-bg.svg')",
banner: "url('/assets/images/banner-bg.png')",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
};
```
globals.css
```css
@tailwind base;
@tailwind components;
@tailwind utilities;@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;--radius: 0.5rem;
}.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}.auth {
@apply flex-center min-h-screen w-full bg-purple-100
}.root {
@apply flex min-h-screen w-full flex-col bg-white lg:flex-row;
}.root-container {
@apply mt-16 flex-1 overflow-auto py-8 lg:mt-0 lg:max-h-screen lg:py-10
}/* ========================================== TAILWIND STYLES */
@layer utilities {
/* ===== UTILITIES */
.wrapper {
@apply max-w-5xl mx-auto px-5 md:px-10 w-full text-dark-400 p-16-regular;
}.gradient-text {
@apply bg-purple-gradient bg-cover bg-clip-text text-transparent;
}/* ===== ALIGNMENTS */
.flex-center {
@apply flex justify-center items-center;
}.flex-between {
@apply flex justify-between items-center;
}/* ===== TYPOGRAPHY */
/* 44 */
.h1-semibold {
@apply text-[36px] font-semibold sm:text-[44px] leading-[120%] sm:leading-[56px];
}/* 36 */
.h2-bold {
@apply text-[30px] font-bold md:text-[36px] leading-[110%];
}/* 30 */
.h3-bold {
@apply font-bold text-[30px] leading-[140%];
}/* 24 */
.p-24-bold {
@apply font-bold text-[24px] leading-[120%];
}/* 20 */
.p-20-semibold {
@apply font-semibold text-[20px] leading-[140%];
}.p-20-regular {
@apply font-normal text-[20px] leading-[140%];
}/* 18 */
.p-18-semibold {
@apply font-semibold text-[18px] leading-[140%];
}/* 16 */
.p-16-semibold {
@apply font-semibold text-[16px] leading-[140%];
}.p-16-medium {
@apply font-medium text-[16px] leading-[140%];
}.p-16-regular {
@apply font-normal text-[16px] leading-[140%];
}/* 14 */
.p-14-medium {
@apply font-medium text-[14px] leading-[120%];
}/* 10 */
.p-10-medium {
@apply font-medium text-[10px] leading-[140%];
}/* ===== SHADCN OVERRIDES */
.button {
@apply py-4 px-6 flex-center gap-3 rounded-full p-16-semibold focus-visible:ring-offset-0 focus-visible:ring-transparent !important;
}.dropdown-content {
@apply shadow-lg rounded-md overflow-hidden p-0;
}.dropdown-item {
@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;
}.input-field {
@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;
}.search-field {
@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;
}.submit-button {
@apply bg-purple-gradient bg-cover rounded-full py-4 px-6 p-16-semibold h-[50px] w-full md:h-[54px];
}.select-field {
@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;
}.select-trigger {
@apply flex items-center gap-2 py-5 capitalize focus-visible:outline-none;
}.select-item {
@apply py-3 cursor-pointer hover:bg-purple-100;
}.IconButton {
@apply focus-visible:ring-transparent focus:ring-offset-0 focus-visible:ring-offset-0 focus-visible:outline-none focus-visible:border-none !important;
}.sheet-content button {
@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;
}.success-toast {
@apply bg-green-100 text-green-900;
}.error-toast {
@apply bg-red-100 text-red-900;
}/* HOME PAGE */
.home {
@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;
}.home-heading {
@apply h1-semibold max-w-[500px] flex-wrap text-center text-white shadow-sm;
}/* CREDITS PAGE */
.credits-list {
@apply mt-11 grid grid-cols-1 gap-5 sm:grid-cols-2 md:gap-9 xl:grid-cols-3;
}.credits-item {
@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;
}.credits-btn {
@apply w-full rounded-full bg-purple-100 bg-cover text-purple-500 hover:text-purple-500;
}/* PROFILE PAGE*/
.profile {
@apply mt-5 flex flex-col gap-5 sm:flex-row md:mt-8 md:gap-10;
}.profile-balance {
@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;
}.profile-image-manipulation {
@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;
}/* TRANSFORMATION DETAILS */
.transformation-grid {
@apply grid h-fit min-h-[200px] grid-cols-1 gap-5 py-8 md:grid-cols-2;
}.transformation-original_image {
@apply h-fit min-h-72 w-full rounded-[10px] border border-dashed bg-purple-100/20 object-cover p-2;
}/* COLLECTION COMPONENT */
.collection-heading {
@apply md:flex-between mb-6 flex flex-col gap-5 md:flex-row;
}.collection-list {
@apply grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3;
}.collection-empty {
@apply flex-center h-60 w-full rounded-[10px] border border-dark-400/10 bg-white/20;
}.collection-btn {
@apply button w-32 bg-purple-gradient bg-cover text-white;
}.collection-card {
@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;
}/* MEDIA UPLOADER COMPONENT */
.media-uploader_cldImage {
@apply h-fit min-h-72 w-full rounded-[10px] border border-dashed bg-purple-100/20 object-cover p-2;
}.media-uploader_cta {
@apply flex-center flex h-72 cursor-pointer flex-col gap-5 rounded-[16px] border border-dashed bg-purple-100/20 shadow-inner;
}.media-uploader_cta-image {
@apply rounded-[16px] bg-white p-5 shadow-sm shadow-purple-200/50;
}/* NAVBAR COMPONENT */
.header {
@apply flex-between fixed h-16 w-full border-b-4 border-purple-100 bg-white p-5 lg:hidden;
}.header-nav_elements {
@apply mt-8 flex w-full flex-col items-start gap-5;
}/* SEARCH COMPONENT */
.search {
@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;
}/* SIDEBAR COMPONENT */
.sidebar {
@apply hidden h-screen w-72 bg-white p-5 shadow-md shadow-purple-200/50 lg:flex;
}.sidebar-logo {
@apply flex items-center gap-2 md:py-2;
}.sidebar-nav {
@apply h-full flex-col justify-between md:flex md:gap-4;
}.sidebar-nav_elements {
@apply hidden w-full flex-col items-start gap-2 md:flex;
}.sidebar-nav_element {
@apply flex-center p-16-semibold w-full whitespace-nowrap rounded-full bg-cover transition-all hover:bg-purple-100 hover:shadow-inner;
}.sidebar-link {
@apply p-16-semibold flex size-full gap-4 p-4;
}/* TRANSFORMATION COMPONENT */
.prompt-field {
@apply flex flex-col gap-5 lg:flex-row lg:gap-10;
}.media-uploader-field {
@apply grid h-fit min-h-[200px] grid-cols-1 gap-5 py-4 md:grid-cols-2;
}/* TRANSFORMED IMAGE COMPONENT */
.download-btn {
@apply p-14-medium mt-2 flex items-center gap-2 px-2;
}.transformed-image {
@apply h-fit min-h-72 w-full rounded-[10px] border border-dashed bg-purple-100/20 object-cover p-2;
}.transforming-loader {
@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;
}.transformed-placeholder {
@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;
}
}/* ===== CLERK OVERRIDES */
.cl-userButtonBox {
display: flex;
flex-flow: row-reverse;
gap: 12px;
}.cl-userButtonOuterIdentifier {
font-size: 16px;
font-weight: 600;
color: #384262;
}
```
constants/index.ts
```typescript
export const navLinks = [
{
label: "Home",
route: "/",
icon: "/assets/icons/home.svg",
},
{
label: "Image Restore",
route: "/transformations/add/restore",
icon: "/assets/icons/image.svg",
},
{
label: "Generative Fill",
route: "/transformations/add/fill",
icon: "/assets/icons/stars.svg",
},
{
label: "Object Remove",
route: "/transformations/add/remove",
icon: "/assets/icons/scan.svg",
},
{
label: "Object Recolor",
route: "/transformations/add/recolor",
icon: "/assets/icons/filter.svg",
},
{
label: "Background Remove",
route: "/transformations/add/removeBackground",
icon: "/assets/icons/camera.svg",
},
{
label: "Profile",
route: "/profile",
icon: "/assets/icons/profile.svg",
},
{
label: "Buy Credits",
route: "/credits",
icon: "/assets/icons/bag.svg",
},
];export const plans = [
{
_id: 1,
name: "Free",
icon: "/assets/icons/free-plan.svg",
price: 0,
credits: 20,
inclusions: [
{
label: "20 Free Credits",
isIncluded: true,
},
{
label: "Basic Access to Services",
isIncluded: true,
},
{
label: "Priority Customer Support",
isIncluded: false,
},
{
label: "Priority Updates",
isIncluded: false,
},
],
},
{
_id: 2,
name: "Pro Package",
icon: "/assets/icons/free-plan.svg",
price: 40,
credits: 120,
inclusions: [
{
label: "120 Credits",
isIncluded: true,
},
{
label: "Full Access to Services",
isIncluded: true,
},
{
label: "Priority Customer Support",
isIncluded: true,
},
{
label: "Priority Updates",
isIncluded: false,
},
],
},
{
_id: 3,
name: "Premium Package",
icon: "/assets/icons/free-plan.svg",
price: 199,
credits: 2000,
inclusions: [
{
label: "2000 Credits",
isIncluded: true,
},
{
label: "Full Access to Services",
isIncluded: true,
},
{
label: "Priority Customer Support",
isIncluded: true,
},
{
label: "Priority Updates",
isIncluded: true,
},
],
},
];export const transformationTypes = {
restore: {
type: "restore",
title: "Restore Image",
subTitle: "Refining Images by Removing Noise & Imperfections",
config: { restore: true },
icon: "image.svg",
},
removeBackground: {
type: "removeBackground",
title: "Background Remove",
subTitle: "Removes The Background Of The Image Using AI",
config: { removeBackground: true },
icon: "camera.svg",
},
fill: {
type: "fill",
title: "Generative Fill",
subTitle: "Enhance An Image's Dimensions Using AI Outpainting",
config: { fillBackground: true },
icon: "stars.svg",
},
remove: {
type: "remove",
title: "Object Remove",
subTitle: "Identify and Eliminate Objects From Images",
config: {
remove: { prompt: "", removeShadow: true, multiple: true },
},
icon: "scan.svg",
},
recolor: {
type: "recolor",
title: "Object Recolor",
subTitle: "Identify & Recolor Objects From A Image",
config: {
recolor: { prompt: "", to: "", multiple: true },
},
icon: "filter.svg",
},
};export const aspectRatioOptions = {
"1:1": {
aspectRatio: "1:1",
label: "Square (1:1)",
width: 1000,
height: 1000,
},
"3:4": {
aspectRatio: "3:4",
label: "Standard Portrait (3:4)",
width: 1000,
height: 1334,
},
"9:16": {
aspectRatio: "9:16",
label: "Phone Portrait (9:16)",
width: 1000,
height: 1778,
},
};export const defaultValues = {
title: "",
aspectRatio: "",
color: "",
prompt: "",
publicId: "",
};export const creditFee = -1;
```
user.model.ts
```typescript
import { Schema, model, models } from "mongoose";const UserSchema = new Schema({
clerkId: {
type: String,
required: true,
unique: true,
},
email: {
type: String,
required: true,
unique: true,
},
username: {
type: String,
required: true,
unique: true,
},
photo: {
type: String,
required: true,
},
firstName: {
type: String,
},
lastName: {
type: String,
},
planId: {
type: Number,
default: 1,
},
creditBalance: {
type: Number,
default: 10,
},
});const User = models?.User || model("User", UserSchema);
export default User;
```
transaction.model.ts
```typescript
import { Schema, model, models } from "mongoose";const TransactionSchema = new Schema({
createdAt: {
type: Date,
default: Date.now,
},
stripeId: {
type: String,
required: true,
unique: true,
},
amount: {
type: Number,
required: true,
},
plan: {
type: String,
},
credits: {
type: Number,
},
buyer: {
type: Schema.Types.ObjectId,
ref: "User",
},
});const Transaction = models?.Transaction || model("Transaction", TransactionSchema);
export default Transaction;
```
InsufficientCreditsModal.tsx
```typescript
"use client";import Image from "next/image";
import { useRouter } from "next/navigation";import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";export const InsufficientCreditsModal = () => {
const router = useRouter();return (
Insufficient Credits
router.push("/profile")}
>
Oops.... Looks like you've run out of free credits!
No worries, though - you can keep enjoying our services by grabbing
more credits.
Cancel
router.push("/credits")}
>
Yes, Proceed
);
};
```
user.action.ts
```typescript
"use server";import { revalidatePath } from "next/cache";
import User from "../database/models/user.model";
import { connectToDatabase } from "../database/mongoose";
import { handleError } from "../utils";// CREATE
export async function createUser(user: CreateUserParams) {
try {
await connectToDatabase();const newUser = await User.create(user);
return JSON.parse(JSON.stringify(newUser));
} catch (error) {
handleError(error);
}
}// READ
export async function getUserById(userId: string) {
try {
await connectToDatabase();const user = await User.findOne({ clerkId: userId });
if (!user) throw new Error("User not found");
return JSON.parse(JSON.stringify(user));
} catch (error) {
handleError(error);
}
}// UPDATE
export async function updateUser(clerkId: string, user: UpdateUserParams) {
try {
await connectToDatabase();const updatedUser = await User.findOneAndUpdate({ clerkId }, user, {
new: true,
});if (!updatedUser) throw new Error("User update failed");
return JSON.parse(JSON.stringify(updatedUser));
} catch (error) {
handleError(error);
}
}// DELETE
export async function deleteUser(clerkId: string) {
try {
await connectToDatabase();// FIND USER TO DELETE
const userToDelete = await User.findOne({ clerkId });if (!userToDelete) {
throw new Error("User not found");
}// Delete user
const deletedUser = await User.findByIdAndDelete(userToDelete._id);
revalidatePath("/");return deletedUser ? JSON.parse(JSON.stringify(deletedUser)) : null;
} catch (error) {
handleError(error);
}
}// USE CREDITS
export async function updateCredits(userId: string, creditFee: number) {
try {
await connectToDatabase();const updatedUserCredits = await User.findOneAndUpdate(
{ _id: userId },
{ $inc: { creditBalance: creditFee }},
{ new: true }
)if(!updatedUserCredits) throw new Error("User credits update failed");
return JSON.parse(JSON.stringify(updatedUserCredits));
} catch (error) {
handleError(error);
}
}
```
utils.ts
```typescript
/* eslint-disable prefer-const */
/* eslint-disable no-prototype-builtins */
import { type ClassValue, clsx } from "clsx";
import qs from "qs";
import { twMerge } from "tailwind-merge";import { aspectRatioOptions } from "@/constants";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}// ERROR HANDLER
export const handleError = (error: unknown) => {
if (error instanceof Error) {
// This is a native JavaScript error (e.g., TypeError, RangeError)
console.error(error.message);
throw new Error(`Error: ${error.message}`);
} else if (typeof error === "string") {
// This is a string error message
console.error(error);
throw new Error(`Error: ${error}`);
} else {
// This is an unknown type of error
console.error(error);
throw new Error(`Unknown error: ${JSON.stringify(error)}`);
}
};// PLACEHOLDER LOADER - WHILE IMAGE IS TRANSFORMING
const shimmer = (w: number, h: number) => `
`;const toBase64 = (str: string) =>
typeof window === "undefined"
? Buffer.from(str).toString("base64")
: window.btoa(str);export const dataUrl = `data:image/svg+xml;base64,${toBase64(
shimmer(1000, 1000)
)}`;
// ==== END// FORM URL QUERY
export const formUrlQuery = ({
searchParams,
key,
value,
}: FormUrlQueryParams) => {
const params = { ...qs.parse(searchParams.toString()), [key]: value };return `${window.location.pathname}?${qs.stringify(params, {
skipNulls: true,
})}`;
};// REMOVE KEY FROM QUERY
export function removeKeysFromQuery({
searchParams,
keysToRemove,
}: RemoveUrlQueryParams) {
const currentUrl = qs.parse(searchParams);keysToRemove.forEach((key) => {
delete currentUrl[key];
});// REMOVE NULL OR UNDEFINED VALUES
Object.keys(currentUrl).forEach(
(key) => currentUrl[key] == null && delete currentUrl[key]
);return `${window.location.pathname}?${qs.stringify(currentUrl)}`;
}// DEBOUNCE
export const debounce = (func: (...args: any[]) => void, delay: number) => {
let timeoutId: NodeJS.Timeout | null;
return (...args: any[]) => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(null, args), delay);
};
};// GE IMAGE SIZE
export type AspectRatioKey = keyof typeof aspectRatioOptions;
export const getImageSize = (
type: string,
image: any,
dimension: "width" | "height"
): number => {
if (type === "fill") {
return (
aspectRatioOptions[image.aspectRatio as AspectRatioKey]?.[dimension] ||
1000
);
}
return image?.[dimension] || 1000;
};// DOWNLOAD IMAGE
export const download = (url: string, filename: string) => {
if (!url) {
throw new Error("Resource URL not provided! You need to provide one");
}fetch(url)
.then((response) => response.blob())
.then((blob) => {
const blobURL = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobURL;if (filename && filename.length)
a.download = `${filename.replace(" ", "_")}.png`;
document.body.appendChild(a);
a.click();
})
.catch((error) => console.log({ error }));
};// DEEP MERGE OBJECTS
export const deepMergeObjects = (obj1: any, obj2: any) => {
if(obj2 === null || obj2 === undefined) {
return obj1;
}let output = { ...obj2 };
for (let key in obj1) {
if (obj1.hasOwnProperty(key)) {
if (
obj1[key] &&
typeof obj1[key] === "object" &&
obj2[key] &&
typeof obj2[key] === "object"
) {
output[key] = deepMergeObjects(obj1[key], obj2[key]);
} else {
output[key] = obj1[key];
}
}
}return output;
};
```
types/index.d.ts
```typescript
/* eslint-disable no-unused-vars */// ====== USER PARAMS
declare type CreateUserParams = {
clerkId: string;
email: string;
username: string;
firstName: string;
lastName: string;
photo: string;
};declare type UpdateUserParams = {
firstName: string;
lastName: string;
username: string;
photo: string;
};// ====== IMAGE PARAMS
declare type AddImageParams = {
image: {
title: string;
publicId: string;
transformationType: string;
width: number;
height: number;
config: any;
secureURL: string;
transformationURL: string;
aspectRatio: string | undefined;
prompt: string | undefined;
color: string | undefined;
};
userId: string;
path: string;
};declare type UpdateImageParams = {
image: {
_id: string;
title: string;
publicId: string;
transformationType: string;
width: number;
height: number;
config: any;
secureURL: string;
transformationURL: string;
aspectRatio: string | undefined;
prompt: string | undefined;
color: string | undefined;
};
userId: string;
path: string;
};declare type Transformations = {
restore?: boolean;
fillBackground?: boolean;
remove?: {
prompt: string;
removeShadow?: boolean;
multiple?: boolean;
};
recolor?: {
prompt?: string;
to: string;
multiple?: boolean;
};
removeBackground?: boolean;
};// ====== TRANSACTION PARAMS
declare type CheckoutTransactionParams = {
plan: string;
credits: number;
amount: number;
buyerId: string;
};declare type CreateTransactionParams = {
stripeId: string;
amount: number;
credits: number;
plan: string;
buyerId: string;
createdAt: Date;
};declare type TransformationTypeKey =
| "restore"
| "fill"
| "remove"
| "recolor"
| "removeBackground";// ====== URL QUERY PARAMS
declare type FormUrlQueryParams = {
searchParams: string;
key: string;
value: string | number | null;
};declare type UrlQueryParams = {
params: string;
key: string;
value: string | null;
};declare type RemoveUrlQueryParams = {
searchParams: string;
keysToRemove: string[];
};declare type SearchParamProps = {
params: { id: string; type: TransformationTypeKey };
searchParams: { [key: string]: string | string[] | undefined };
};declare type TransformationFormProps = {
action: "Add" | "Update";
userId: string;
type: TransformationTypeKey;
creditBalance: number;
data?: IImage | null;
config?: Transformations | null;
};declare type TransformedImageProps = {
image: any;
type: string;
title: string;
transformationConfig: Transformations | null;
isTransforming: boolean;
hasDownload?: boolean;
setIsTransforming?: React.Dispatch>;
};
```
api/webhooks/clerk/route.ts
```typescript
/* eslint-disable camelcase */
import { clerkClient } from "@clerk/nextjs";
import { WebhookEvent } from "@clerk/nextjs/server";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { Webhook } from "svix";import { createUser, deleteUser, updateUser } from "@/lib/actions/user.actions";
export async function POST(req: Request) {
// You can find this in the Clerk Dashboard -> Webhooks -> choose the webhook
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;if (!WEBHOOK_SECRET) {
throw new Error(
"Please add WEBHOOK_SECRET from Clerk Dashboard to .env or .env.local"
);
}// GET THE HEADERS
const headerPayload = headers();
const svix_id = headerPayload.get("svix-id");
const svix_timestamp = headerPayload.get("svix-timestamp");
const svix_signature = headerPayload.get("svix-signature");// IF THERE ARE NO HEADERS, ERROR OUT
if (!svix_id || !svix_timestamp || !svix_signature) {
return new Response("Error occured -- no svix headers", {
status: 400,
});
}// GET THE BODY
const payload = await req.json();
const body = JSON.stringify(payload);// CREATE A NEW Svix INSTANCE WITH YOUR SECRETS.
const wh = new Webhook(WEBHOOK_SECRET);let evt: WebhookEvent;
// VERIFY THE PAYLOAD WITH THE HEADERS
try {
evt = wh.verify(body, {
"svix-id": svix_id,
"svix-timestamp": svix_timestamp,
"svix-signature": svix_signature,
}) as WebhookEvent;
} catch (err) {
console.error("Error verifying webhook:", err);
return new Response("Error occured", {
status: 400,
});
}// GET THE ID AND TYPE
const { id } = evt.data;
const eventType = evt.type;// CREATE
if (eventType === "user.created") {
const { id, email_addresses, image_url, first_name, last_name, username } = evt.data;const user = {
clerkId: id,
email: email_addresses[0].email_address,
username: username!,
firstName: first_name,
lastName: last_name,
photo: image_url,
};const newUser = await createUser(user);
// SET PUBLIC METADATA
if (newUser) {
await clerkClient.users.updateUserMetadata(id, {
publicMetadata: {
userId: newUser._id,
},
});
}return NextResponse.json({ message: "OK", user: newUser });
}// UPDATE
if (eventType === "user.updated") {
const { id, image_url, first_name, last_name, username } = evt.data;const user = {
firstName: first_name,
lastName: last_name,
username: username!,
photo: image_url,
};const updatedUser = await updateUser(id, user);
return NextResponse.json({ message: "OK", user: updatedUser });
}// DELETE
if (eventType === "user.deleted") {
const { id } = evt.data;const deletedUser = await deleteUser(id!);
return NextResponse.json({ message: "OK", user: deletedUser });
}console.log(`Webhook with and ID of ${id} and type of ${eventType}`);
console.log("Webhook body:", body);return new Response("", { status: 200 });
}
```
components/shared/CustomField.tsx
```typescript
import React from "react";
import { Control } from "react-hook-form";
import { z } from "zod";import {
FormField,
FormItem,
FormControl,
FormMessage,
FormLabel,
} from "../ui/form";import { formSchema } from "./TransformationForm";
type CustomFieldProps = {
control: Control> | undefined;
render: (props: { field: any }) => React.ReactNode;
name: keyof z.infer;
formLabel?: string;
className?: string;
};export const CustomField = ({
control,
render,
name,
formLabel,
className,
}: CustomFieldProps) => {
return (
(
{formLabel && {formLabel}}
{render({ field })}
)}
/>
);
};
```
components/shared/Collection.tsx
```typescript
"use client";import Image from "next/image";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { CldImage } from "next-cloudinary";import {
Pagination,
PaginationContent,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { transformationTypes } from "@/constants";
import { IImage } from "@/lib/database/models/image.model";
import { formUrlQuery } from "@/lib/utils";import { Button } from "../ui/button";
import { Search } from "./Search";
export const Collection = ({
hasSearch = false,
images,
totalPages = 1,
page,
}: {
images: IImage[];
totalPages?: number;
page: number;
hasSearch?: boolean;
}) => {
const router = useRouter();
const searchParams = useSearchParams();// PAGINATION HANDLER
const onPageChange = (action: string) => {
const pageValue = action === "next" ? Number(page) + 1 : Number(page) - 1;const newUrl = formUrlQuery({
searchParams: searchParams.toString(),
key: "page",
value: pageValue,
});router.push(newUrl, { scroll: false });
};return (
<>
Recent Edits
{hasSearch && }
{images.length > 0 ? (
{images.map((image) => (
))}
) : (
Empty List
)}
{totalPages > 1 && (
onPageChange("prev")}
>
{page} / {totalPages}
onPageChange("next")}
disabled={Number(page) >= totalPages}
>
)}
>
);
};
const Card = ({ image }: { image: IImage }) => {
return (
{image.title}
);
};
```
components/shared/Search.tsx
```typescript
"use client";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { formUrlQuery, removeKeysFromQuery } from "@/lib/utils";
export const Search = () => {
const router = useRouter();
const searchParams = useSearchParams();
const [query, setQuery] = useState("");
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
if (query) {
const newUrl = formUrlQuery({
searchParams: searchParams.toString(),
key: "query",
value: query,
});
router.push(newUrl, { scroll: false });
} else {
const newUrl = removeKeysFromQuery({
searchParams: searchParams.toString(),
keysToRemove: ["query"],
});
router.push(newUrl, { scroll: false });
}
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [router, searchParams, query]);
return (
setQuery(e.target.value)}
/>
);
};
```
image.actions.ts
```typescript
"use server";
import { revalidatePath } from "next/cache";
import { connectToDatabase } from "../database/mongoose";
import { handleError } from "../utils";
import User from "../database/models/user.model";
import Image from "../database/models/image.model";
import { redirect } from "next/navigation";
import { v2 as cloudinary } from 'cloudinary'
const populateUser = (query: any) => query.populate({
path: 'author',
model: User,
select: '_id firstName lastName clerkId'
})
// ADD IMAGE
export async function addImage({ image, userId, path }: AddImageParams) {
try {
await connectToDatabase();
const author = await User.findById(userId);
if (!author) {
throw new Error("User not found");
}
const newImage = await Image.create({
...image,
author: author._id,
})
revalidatePath(path);
return JSON.parse(JSON.stringify(newImage));
} catch (error) {
handleError(error)
}
}
// UPDATE IMAGE
export async function updateImage({ image, userId, path }: UpdateImageParams) {
try {
await connectToDatabase();
const imageToUpdate = await Image.findById(image._id);
if (!imageToUpdate || imageToUpdate.author.toHexString() !== userId) {
throw new Error("Unauthorized or image not found");
}
const updatedImage = await Image.findByIdAndUpdate(
imageToUpdate._id,
image,
{ new: true }
)
revalidatePath(path);
return JSON.parse(JSON.stringify(updatedImage));
} catch (error) {
handleError(error)
}
}
// DELETE IMAGE
export async function deleteImage(imageId: string) {
try {
await connectToDatabase();
await Image.findByIdAndDelete(imageId);
} catch (error) {
handleError(error)
} finally{
redirect('/')
}
}
// GET IMAGE
export async function getImageById(imageId: string) {
try {
await connectToDatabase();
const image = await populateUser(Image.findById(imageId));
if(!image) throw new Error("Image not found");
return JSON.parse(JSON.stringify(image));
} catch (error) {
handleError(error)
}
}
// GET IMAGES
export async function getAllImages({ limit = 9, page = 1, searchQuery = '' }: {
limit?: number;
page: number;
searchQuery?: string;
}) {
try {
await connectToDatabase();
cloudinary.config({
cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
api_key: process.env.NEXT_PUBLIC_CLOUDINARY_API_KEY,
api_secret: process.env.NEXT_PUBLIC_CLOUDINARY_API_SECRET,
secure: true,
})
let expression = 'folder=rupantor';
if (searchQuery) {
expression += ` AND ${searchQuery}`
}
const { resources } = await cloudinary.search
.expression(expression)
.execute();
const resourceIds = resources.map((resource: any) => resource.public_id);
let query = {};
if(searchQuery) {
query = {
publicId: {
$in: resourceIds
}
}
}
const skipAmount = (Number(page) -1) * limit;
const images = await populateUser(Image.find(query))
.sort({ updatedAt: -1 })
.skip(skipAmount)
.limit(limit);
const totalImages = await Image.find(query).countDocuments();
const savedImages = await Image.find().countDocuments();
return {
data: JSON.parse(JSON.stringify(images)),
totalPage: Math.ceil(totalImages / limit),
savedImages,
}
} catch (error) {
handleError(error)
}
}
// GET IMAGES BY USER
export async function getUserImages({
limit = 9,
page = 1,
userId,
}: {
limit?: number;
page: number;
userId: string;
}) {
try {
await connectToDatabase();
const skipAmount = (Number(page) - 1) * limit;
const images = await populateUser(Image.find({ author: userId }))
.sort({ updatedAt: -1 })
.skip(skipAmount)
.limit(limit);
const totalImages = await Image.find({ author: userId }).countDocuments();
return {
data: JSON.parse(JSON.stringify(images)),
totalPages: Math.ceil(totalImages / limit),
};
} catch (error) {
handleError(error);
}
}
```
transformations/[id]/page.tsx
```typescript
import { auth } from "@clerk/nextjs";
import Image from "next/image";
import Link from "next/link";
import Header from "@/components/shared/Header";
import TransformedImage from "@/components/shared/TransformedImage";
import { Button } from "@/components/ui/button";
import { getImageById } from "@/lib/actions/image.actions";
import { getImageSize } from "@/lib/utils";
import { DeleteConfirmation } from "@/components/shared/DeleteConfirmation";
const ImageDetails = async ({ params: { id } }: SearchParamProps) => {
const { userId } = auth();
const image = await getImageById(id);
return (
<>
Transformation:
{image.transformationType}
{image.prompt && (
<>
β
Prompt:
{image.prompt}
>
)}
{image.color && (
<>
β
Color:
{image.color}
>
)}
{image.aspectRatio && (
<>
β
Aspect Ratio:
{image.aspectRatio}
>
)}
{/* MEDIA UPLOADER */}
Original
{/* TRANSFORMED IMAGE */}
{userId === image.author.clerkId && (
Update Image
)}
>
);
};
export default ImageDetails;
```
transformations/[id]/update/page.tsx
```typescript
import { auth } from "@clerk/nextjs";
import { redirect } from "next/navigation";
import Header from "@/components/shared/Header";
import TransformationForm from "@/components/shared/TransformationForm";
import { transformationTypes } from "@/constants";
import { getUserById } from "@/lib/actions/user.actions";
import { getImageById } from "@/lib/actions/image.actions";
const Page = async ({ params: { id } }: SearchParamProps) => {
const { userId } = auth();
if (!userId) redirect("/sign-in");
const user = await getUserById(userId);
const image = await getImageById(id);
const transformation =
transformationTypes[image.transformationType as TransformationTypeKey];
return (
<>
>
);
};
export default Page;
```
components/shared/DeleteConfirmation.tsx
```typescript
"use client";
import { useTransition } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { deleteImage } from "@/lib/actions/image.actions";
import { Button } from "../ui/button";
export const DeleteConfirmation = ({ imageId }: { imageId: string }) => {
const [isPending, startTransition] = useTransition();
return (
Delete Image
Are you sure you want to delete this image?
This will permanently delete this image
Cancel
startTransition(async () => {
await deleteImage(imageId);
})
}
>
{isPending ? "Deleting..." : "Delete"}
);
};
```
api/webhooks/stripe/route.ts
```typescript
/* eslint-disable camelcase */
import { createTransaction } from "@/lib/actions/transaction.action";
import { NextResponse } from "next/server";
import stripe from "stripe";
export async function POST(request: Request) {
const body = await request.text();
const sig = request.headers.get("stripe-signature") as string;
const endpointSecret = process.env.NEXT_PUBLIC_STRIPE_WEBHOOK_SECRET!;
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, endpointSecret);
} catch (err) {
return NextResponse.json({ message: "Webhook error", error: err });
}
// GET THE ID AND TYPE
const eventType = event.type;
// CREATE
if (eventType === "checkout.session.completed") {
const { id, amount_total, metadata } = event.data.object;
const transaction = {
stripeId: id,
amount: amount_total ? amount_total / 100 : 0,
plan: metadata?.plan || "",
credits: Number(metadata?.credits) || 0,
buyerId: metadata?.buyerId || "",
createdAt: new Date(),
};
const newTransaction = await createTransaction(transaction);
return NextResponse.json({ message: "OK", transaction: newTransaction });
}
return new Response("", { status: 200 });
}
```
credits/page.tsx
```typescript
import { SignedIn, auth } from "@clerk/nextjs";
import Image from "next/image";
import { redirect } from "next/navigation";
import Header from "@/components/shared/Header";
import { Button } from "@/components/ui/button";
import { plans } from "@/constants";
import { getUserById } from "@/lib/actions/user.actions";
import Checkout from "@/components/shared/Checkout";
const Credits = async () => {
const { userId } = auth();
if (!userId) redirect("/sign-in");
const user = await getUserById(userId);
return (
<>
-
{plan.name}
${plan.price}
{plan.credits} Credits
{/* INCLUSIONS */}
-
{inclusion.label}
{plan.inclusions.map((inclusion) => (
))}
{plan.name === "Free" ? (
Free Consumable
) : (
)}
-
{plans.map((plan) => (
))}
>
);
};
export default Credits;
```
components/shared/Checkout.tsx
```typescript
"use client";
import { loadStripe } from "@stripe/stripe-js";
import { useEffect } from "react";
import { useToast } from "@/components/ui/use-toast";
import { checkoutCredits } from "@/lib/actions/transaction.action";
import { Button } from "../ui/button";
const Checkout = ({
plan,
amount,
credits,
buyerId,
}: {
plan: string;
amount: number;
credits: number;
buyerId: string;
}) => {
const { toast } = useToast();
useEffect(() => {
loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
}, []);
useEffect(() => {
// Check to see if this is a redirect back from Checkout
const query = new URLSearchParams(window.location.search);
if (query.get("success")) {
toast({
title: "Order placed!",
description: "You will receive an email confirmation",
duration: 5000,
className: "success-toast",
});
}
if (query.get("canceled")) {
toast({
title: "Order canceled!",
description: "Continue to shop around and checkout when you're ready",
duration: 5000,
className: "error-toast",
});
}
}, []);
const onCheckout = async () => {
const transaction = {
plan,
amount,
credits,
buyerId,
};
await checkoutCredits(transaction);
};
return (
Buy Credit
);
};
export default Checkout;
```
profile/page.tsx
```typescript
import { auth } from "@clerk/nextjs";
import Image from "next/image";
import { redirect } from "next/navigation";
import { Collection } from "@/components/shared/Collection";
import Header from "@/components/shared/Header";
import { getUserImages } from "@/lib/actions/image.actions";
import { getUserById } from "@/lib/actions/user.actions";
const Profile = async ({ searchParams }: SearchParamProps) => {
const page = Number(searchParams?.page) || 1;
const { userId } = auth();
if (!userId) redirect("/sign-in");
const user = await getUserById(userId);
const images = await getUserImages({ page, userId: user._id });
return (
<>
CREDITS AVAILABLE
{user.creditBalance}
IMAGE MANIPULATION DONE
{images?.data.length}
>
);
};
export default Profile;
```
nginx.conf
```conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
client_max_body_size 100M;
# Logging Settings
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# Optimization
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip Settings
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
server {
listen 80;
server_name rupantor.aknibir.dev;
root /usr/share/nginx/html;
index index.html;
# Next.js API and Dynamic Routes
location / {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_buffering off;
proxy_read_timeout 300;
proxy_connect_timeout 300;
# WebSocket Support
proxy_set_header Connection "Upgrade";
}
# Static Files
location /_next/static/ {
proxy_pass http://app:3000;
proxy_cache_bypass $http_upgrade;
proxy_set_header Host $host;
expires 365d;
access_log off;
}
# Next.js Public Files
location /public/ {
proxy_pass http://app:3000;
proxy_cache_bypass $http_upgrade;
proxy_set_header Host $host;
expires 365d;
access_log off;
}
# Error Pages
error_page 404 /404.html;
location = /404.html {
internal;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
internal;
}
# Additional Security Headers
location ~ /\. {
deny all;
}
location = /favicon.ico {
access_log off;
log_not_found off;
}
location = /robots.txt {
access_log off;
log_not_found off;
}
}
}
```
dockerfile
```dockerfile
# STAGE 1: BUILD IMAGE
FROM node:18-alpine AS builder
WORKDIR /app
# Copy Package Files First For Caching Dependencies
COPY package.json package-lock.json ./
# Install Dependencies
RUN npm cache clean --force && npm install --legacy-peer-deps
# Copy The Rest Of The Application Files
COPY . .
# Load Environment Variables From the .env File Created In The GitHub Actions Workflow
RUN if [ -f .env ]; then export $(grep -v '^#' .env | xargs); fi
# Build The Aplication
RUN npm run build:prod
# STAGE 2: RUN IMAGE
FROM node:18-alpine AS runner
WORKDIR /app
# Copy Built Files & Dependencies From The Builder Stage
COPY --from=builder /app/package.json package.json
COPY --from=builder /app/package-lock.json package-lock.json
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules
# Expose Application Port
EXPOSE 3000
# Load Environment Variables From The .env File
ENV PORT=3000
COPY .env .env
# Run The Application
CMD ["npm", "run", "start:prod"]
```
compose.yaml
```yaml
name: 'build-docker-image-container'
services:
app:
build:
context: .
dockerfile: Dockerfile
target: runner
ports:
- "3000:3000" # EXPOSING PORT FOR INTERNAL COMMUNICATIONS
env_file:
- .env
depends_on:
mongodb:
condition: service_healthy
networks:
- rupantor_network
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
mongodb:
image: mongo:6.0
container_name: mongodb
ports:
- "27017:27017"
volumes:
- mongodb_data:/data/db
networks:
- rupantor_network
restart: unless-stopped
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
nginx:
image: nginx:latest
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./public:/usr/share/nginx/html:ro
ports:
- "80:80"
depends_on:
app:
condition: service_healthy
networks:
- rupantor_network
restart: unless-stopped
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
rupantor_network:
driver: bridge
volumes:
mongodb_data:
driver: local
```
docker.img.build-ghcr.io.yaml
```yaml
name: docker.img.build-ghcr.io
on:
push:
branches: [ "main" ]
workflow_dispatch: {}
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
node-version: [18.12.0]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Authenticate GHCR For Dockerization
run: |
docker login --username ${{ github.actor }} --password ${{ secrets.AUTH_SECRET }} ghcr.io
- name: Createing Environment Variables
run: |
echo "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}" >> .env
echo "CLERK_SECRET_KEY=${{ secrets.CLERK_SECRET_KEY }}" >> .env
echo "NEXT_PUBLIC_CLERK_SIGN_IN_URL=${{ secrets.NEXT_PUBLIC_CLERK_SIGN_IN_URL }}" >> .env
echo "NEXT_PUBLIC_CLERK_SIGN_UP_URL=${{ secrets.NEXT_PUBLIC_CLERK_SIGN_UP_URL }}" >> .env
echo "NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=${{ secrets.NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL }}" >> .env
echo "NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=${{ secrets.NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL }}" >> .env
echo "MONGODB_URL=${{ secrets.MONGODB_URL }}" >> .env
echo "WEBHOOK_SECRET=${{ secrets.WEBHOOK_SECRET }}" >> .env
echo "NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=${{ secrets.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME }}" >> .env
echo "NEXT_PUBLIC_CLOUDINARY_API_KEY=${{ secrets.NEXT_PUBLIC_CLOUDINARY_API_KEY }}" >> .env
echo "NEXT_PUBLIC_CLOUDINARY_API_SECRET=${{ secrets.NEXT_PUBLIC_CLOUDINARY_API_SECRET }}" >> .env
echo "NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=${{ secrets.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET }}" >> .env
echo "NEXT_PUBLIC_CLOUDINARY_BUCKET_NAME=${{ secrets.NEXT_PUBLIC_CLOUDINARY_BUCKET_NAME }}" >> .env
echo "NEXT_PUBLIC_STRIPE_WEBHOOK_CHECKOUT_URL=${{ secrets.NEXT_PUBLIC_STRIPE_WEBHOOK_CHECKOUT_URL }}" >> .env
echo "NEXT_PUBLIC_STRIPE_SECRET_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_SECRET_KEY }}" >> .env
echo "NEXT_PUBLIC_STRIPE_WEBHOOK_SECRET=${{ secrets.NEXT_PUBLIC_STRIPE_WEBHOOK_SECRET }}" >> .env
echo "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}" >> .env
- name: Adding Commit Hash To Docker Image
id: commit_hash
run: echo "IMAGE_TAG=$(echo $GITHUB_SHA | cut -c1-7)" >> $GITHUB_OUTPUT
- name: Building Docker Image
run: |
docker build --no-cache . --tag ghcr.io/aknibircse/rupantor-ai-saas:${{ steps.commit_hash.outputs.IMAGE_TAG }}
- name: Push Docker Image To GHCR
run: docker push ghcr.io/aknibircse/rupantor-ai-saas:${{ steps.commit_hash.outputs.IMAGE_TAG }}
```
## π COPYRIGHTS
*Β©* ALL RIGHTS OF THIS PROJECT IS RESERVED BY ***[AK NIBIR](https://aknibir.dev)*** -- π UNDER ***[MIT](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/licensing-a-repository)*** LISENCE.