├── .env.example
├── .eslintrc.json
├── .github
└── images
│ ├── features.png
│ └── homepage.png
├── .gitignore
├── README.md
├── components.json
├── drizzle.config.ts
├── drizzle
├── 0000_sweet_zeigeist.sql
├── 0001_volatile_gressill.sql
├── 0002_lovely_jocasta.sql
└── meta
│ ├── 0000_snapshot.json
│ ├── 0001_snapshot.json
│ ├── 0002_snapshot.json
│ └── _journal.json
├── next.config.mjs
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
├── public
├── bg.jpg
└── logo.svg
├── src
├── app
│ ├── (auth)
│ │ ├── layout.tsx
│ │ ├── sign-in
│ │ │ └── page.tsx
│ │ └── sign-up
│ │ │ └── page.tsx
│ ├── (dashboard)
│ │ ├── banner.tsx
│ │ ├── layout.tsx
│ │ ├── logo.tsx
│ │ ├── navbar.tsx
│ │ ├── page.tsx
│ │ ├── projects-section.tsx
│ │ ├── sidebar-item.tsx
│ │ ├── sidebar-routes.tsx
│ │ ├── sidebar.tsx
│ │ ├── template-card.tsx
│ │ └── templates-section.tsx
│ ├── api
│ │ ├── [[...route]]
│ │ │ ├── ai.ts
│ │ │ ├── images.ts
│ │ │ ├── projects.ts
│ │ │ ├── route.ts
│ │ │ └── users.ts
│ │ ├── auth
│ │ │ └── [...nextauth]
│ │ │ │ └── route.ts
│ │ └── uploadthing
│ │ │ ├── core.ts
│ │ │ └── route.ts
│ ├── editor
│ │ └── [projectId]
│ │ │ └── page.tsx
│ ├── favicon.ico
│ ├── fonts
│ │ ├── GeistMonoVF.woff
│ │ └── GeistVF.woff
│ ├── globals.css
│ └── layout.tsx
├── auth.config.ts
├── auth.ts
├── components
│ ├── hint.tsx
│ ├── modals.tsx
│ ├── provides.tsx
│ ├── query-provider.tsx
│ └── ui
│ │ ├── avatar.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── dialog.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── scroll-area.tsx
│ │ ├── separator.tsx
│ │ ├── slider.tsx
│ │ ├── sonner.tsx
│ │ ├── table.tsx
│ │ ├── textarea.tsx
│ │ └── tooltip.tsx
├── db
│ ├── drizzle.ts
│ └── schema.ts
├── features
│ ├── ai
│ │ └── api
│ │ │ ├── use-generate-image.ts
│ │ │ └── use-remove-background.ts
│ ├── auth
│ │ ├── components
│ │ │ ├── sign-in-card.tsx
│ │ │ ├── sign-up-card.tsx
│ │ │ └── user-button.tsx
│ │ ├── hooks
│ │ │ └── use-sign-up.ts
│ │ └── utils.ts
│ ├── editor
│ │ ├── components
│ │ │ ├── ai-sidebar.tsx
│ │ │ ├── color-picker.tsx
│ │ │ ├── draw-sidebar.tsx
│ │ │ ├── editor.tsx
│ │ │ ├── fill-color-sidebar.tsx
│ │ │ ├── filter-sidebar.tsx
│ │ │ ├── font-sidebar.tsx
│ │ │ ├── font-size-input.tsx
│ │ │ ├── footer.tsx
│ │ │ ├── image-sidebar.tsx
│ │ │ ├── logo.tsx
│ │ │ ├── navbar.tsx
│ │ │ ├── opacity-sidebar.tsx
│ │ │ ├── remove-bg-sidebar.tsx
│ │ │ ├── settings-sidebar.tsx
│ │ │ ├── shape-sidebar.tsx
│ │ │ ├── shape-tool.tsx
│ │ │ ├── sidebar-item.tsx
│ │ │ ├── sidebar.tsx
│ │ │ ├── stroke-color-sidebar.tsx
│ │ │ ├── stroke-width-sidebar.tsx
│ │ │ ├── template-sidebar.tsx
│ │ │ ├── text-sidebar.tsx
│ │ │ ├── tool-sidebar-close.tsx
│ │ │ ├── tool-sidebar-header.tsx
│ │ │ └── toolbar.tsx
│ │ ├── hooks
│ │ │ ├── use-auto-resize.ts
│ │ │ ├── use-canvas-events.ts
│ │ │ ├── use-clipboard.ts
│ │ │ ├── use-editor.ts
│ │ │ ├── use-history.ts
│ │ │ ├── use-hotkeys.ts
│ │ │ ├── use-load-state.ts
│ │ │ └── use-window-events.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── images
│ │ └── api
│ │ │ └── use-get-imgaes.ts
│ ├── projects
│ │ └── api
│ │ │ ├── use-create-project.ts
│ │ │ ├── use-delete-project.ts
│ │ │ ├── use-duplicate-project.ts
│ │ │ ├── use-get-project.ts
│ │ │ ├── use-get-projects.ts
│ │ │ ├── use-get-templates.ts
│ │ │ └── use-update-project.ts
│ └── subscriptions
│ │ ├── components
│ │ └── subscription-modal.tsx
│ │ ├── hooks
│ │ └── use-paywall.ts
│ │ └── store
│ │ └── use-subscription-modal.ts
├── hooks
│ └── use-confirm.tsx
├── lib
│ ├── hono.ts
│ ├── replicate.ts
│ ├── unsplash.ts
│ ├── uploadthing.ts
│ └── utils.ts
└── middleware.ts
├── tailwind.config.ts
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_APP_URL=
2 |
3 | NEXT_PUBLIC_UNSPLASH_ACCESS_KEY=
4 |
5 | UPLOADTHING_TOKEN=
6 |
7 | UPLOADTHING_SECRET=
8 | UPLOADTHING_APP_ID=
9 |
10 | REPLICATE_API_TOKEN=
11 | AUTH_SECRET=
12 |
13 | AUTH_GOOGLE_ID=
14 | AUTH_GOOGLE_SECRET=
15 |
16 | AUTH_GITHUB_ID=
17 | AUTH_GITHUB_SECRET=
18 |
19 | DATABASE_URL=
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "next/typescript"],
3 | "rules": {
4 | "no-unused-vars": "off",
5 | "@typescript-eslint/no-explicit-any": "off",
6 | "@typescript-eslint/ban-ts-comment": "off",
7 | "@typescript-eslint/no-unused-vars": "off",
8 | "@typescript-eslint/no-non-null-asserted-optional-chain": "off"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/.github/images/features.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zhengchchen/image-ai/26305374f6b9d912d478700e3f6827bf8c27c5a4/.github/images/features.png
--------------------------------------------------------------------------------
/.github/images/homepage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zhengchchen/image-ai/26305374f6b9d912d478700e3f6827bf8c27c5a4/.github/images/homepage.png
--------------------------------------------------------------------------------
/.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 | # Image AI - AI 驱动的图片编辑器
2 |
3 | Image AI 是一个功能强大的在线图片编辑器,集成了 AI 图片生成、背景移除等功能。它使用 Next.js 构建,提供直观的用户界面和丰富的编辑工具。
4 |
5 | 
6 |
7 | ## ✨ 主要功能
8 |
9 | - 🎨 完整的图片编辑功能
10 | - 添加文字、形状
11 | - 调整颜色、大小
12 | - 图层管理
13 | - 绘图工具
14 | - 🤖 AI 功能
15 | - AI 图片生成
16 | - 智能背景移除
17 | - 📦 模板系统
18 | - 内置多种设计模板
19 | - 支持自定义模板
20 | - 💾 多种导出格式
21 | - PNG
22 | - JPG
23 | - SVG
24 | - JSON(用于后续编辑)
25 |
26 | 
27 |
28 | ## 🚀 快速开始
29 |
30 | ### 在线使用
31 |
32 | 访问 [Image AI](https://www.imagegicai.com/) 即可开始使用。
33 |
34 | ### 本地开发
35 |
36 | 1. 克隆项目:
37 | ```bash
38 | git clone https://github.com/zhengchchen/image-ai.git
39 | cd image-ai
40 | ```
41 | 2. 安装依赖:
42 | ```bash
43 | pnpm install
44 | ```
45 |
46 | 3. 配置环境变量:
47 |
48 | 复制 `.env.example` 为 `.env.local` 并填写必要的环境变量:
49 | ```bash
50 | cp .env.example .env.local
51 | ```
52 | 需要配置的环境变量包括:
53 |
54 | - `DATABASE_URL`: 数据库连接地址
55 | - `NEXTAUTH_SECRET`: NextAuth 密钥
56 | - `UPLOADTHING_SECRET`: UploadThing 密钥
57 | - `UPLOADTHING_APP_ID`: UploadThing 应用 ID
58 | - `REPLICATE_API_TOKEN`: Replicate API 密钥
59 |
60 | 4. 启动开发服务器:
61 | ```bash
62 | pnpm dev
63 | ```
64 | 访问 [http://localhost:3000](http://localhost:3000) 查看结果。
65 |
66 | ## 🛠 技术栈
67 |
68 | - [Next.js 14](https://nextjs.org/) - React 框架
69 | - [TypeScript](https://www.typescriptlang.org/) - 类型检查
70 | - [Tailwind CSS](https://tailwindcss.com/) - 样式
71 | - [Fabric.js](http://fabricjs.com/) - 画布操作
72 | - [Drizzle ORM](https://orm.drizzle.team/) - 数据库 ORM
73 | - [NextAuth.js](https://next-auth.js.org/) - 身份认证
74 | - [Replicate](https://replicate.com/) - AI 功能
75 | - [UploadThing](https://uploadthing.com/) - 文件上传
76 | - [Neon Database](https://neon.tech/) - PostgreSQL 数据库
77 |
78 | ## 📦 项目结构
79 | src/
80 | ├── app/ # Next.js 应用路由
81 | ├── components/ # 通用组件
82 | ├── db/ # 数据库 schema 定义
83 | ├── hooks/ # 自定义 hooks
84 | ├── features/ # 功能模块
85 | │ ├── editor/ # 编辑器核心功能
86 | │ ├── ai/ # AI 相关功能
87 | │ ├── auth/ # 认证相关
88 | │ └── projects/ # 项目管理
89 | └── lib/ # 工具函数和配置
90 |
91 | ## 📄 License
92 |
93 | MIT License - 查看 [LICENSE](LICENSE) 文件了解详情
94 |
95 | ## 🤝 贡献指南
96 |
97 | 欢迎提交 Issue 和 Pull Request!
98 |
99 | 1. Fork 项目
100 | 2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
101 | 3. 提交改动 (`git commit -m 'Add some AmazingFeature'`)
102 | 4. 推送到分支 (`git push origin feature/AmazingFeature`)
103 | 5. 提交 Pull Request
104 |
105 | ## 📧 联系方式
106 |
107 | [275781239@qq.com](mailto:275781239@qq.com)
108 |
109 | ## 🙏 致谢
110 |
111 | 感谢以下开源项目:
112 |
113 | - [Next.js](https://nextjs.org/)
114 | - [Fabric.js](http://fabricjs.com/)
115 | - [shadcn/ui](https://ui.shadcn.com/)
116 | - [Replicate](https://replicate.com/)
--------------------------------------------------------------------------------
/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": "src/app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | }
20 | }
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "drizzle-kit";
2 | import { config } from "dotenv";
3 |
4 | config({path:".env.local"})
5 |
6 | export default defineConfig({
7 | dialect: "postgresql",
8 | schema: "./src/db/schema.ts",
9 | dbCredentials:{
10 | url:process.env.DATABASE_URL!
11 | },
12 | verbose:true,
13 | strict:true
14 | });
15 |
--------------------------------------------------------------------------------
/drizzle/0000_sweet_zeigeist.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "account" (
2 | "userId" text NOT NULL,
3 | "type" text NOT NULL,
4 | "provider" text NOT NULL,
5 | "providerAccountId" text NOT NULL,
6 | "refresh_token" text,
7 | "access_token" text,
8 | "expires_at" integer,
9 | "token_type" text,
10 | "scope" text,
11 | "id_token" text,
12 | "session_state" text,
13 | CONSTRAINT "account_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId")
14 | );
15 | --> statement-breakpoint
16 | CREATE TABLE IF NOT EXISTS "authenticator" (
17 | "credentialID" text NOT NULL,
18 | "userId" text NOT NULL,
19 | "providerAccountId" text NOT NULL,
20 | "credentialPublicKey" text NOT NULL,
21 | "counter" integer NOT NULL,
22 | "credentialDeviceType" text NOT NULL,
23 | "credentialBackedUp" boolean NOT NULL,
24 | "transports" text,
25 | CONSTRAINT "authenticator_userId_credentialID_pk" PRIMARY KEY("userId","credentialID"),
26 | CONSTRAINT "authenticator_credentialID_unique" UNIQUE("credentialID")
27 | );
28 | --> statement-breakpoint
29 | CREATE TABLE IF NOT EXISTS "session" (
30 | "sessionToken" text PRIMARY KEY NOT NULL,
31 | "userId" text NOT NULL,
32 | "expires" timestamp NOT NULL
33 | );
34 | --> statement-breakpoint
35 | CREATE TABLE IF NOT EXISTS "user" (
36 | "id" text PRIMARY KEY NOT NULL,
37 | "name" text,
38 | "email" text,
39 | "emailVerified" timestamp,
40 | "image" text,
41 | CONSTRAINT "user_email_unique" UNIQUE("email")
42 | );
43 | --> statement-breakpoint
44 | CREATE TABLE IF NOT EXISTS "verificationToken" (
45 | "identifier" text NOT NULL,
46 | "token" text NOT NULL,
47 | "expires" timestamp NOT NULL,
48 | CONSTRAINT "verificationToken_identifier_token_pk" PRIMARY KEY("identifier","token")
49 | );
50 | --> statement-breakpoint
51 | DO $$ BEGIN
52 | ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
53 | EXCEPTION
54 | WHEN duplicate_object THEN null;
55 | END $$;
56 | --> statement-breakpoint
57 | DO $$ BEGIN
58 | ALTER TABLE "authenticator" ADD CONSTRAINT "authenticator_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
59 | EXCEPTION
60 | WHEN duplicate_object THEN null;
61 | END $$;
62 | --> statement-breakpoint
63 | DO $$ BEGIN
64 | ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
65 | EXCEPTION
66 | WHEN duplicate_object THEN null;
67 | END $$;
68 |
--------------------------------------------------------------------------------
/drizzle/0001_volatile_gressill.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "user" ADD COLUMN "password" text;
--------------------------------------------------------------------------------
/drizzle/0002_lovely_jocasta.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "project" (
2 | "id" text PRIMARY KEY NOT NULL,
3 | "name" text NOT NULL,
4 | "userId" text NOT NULL,
5 | "json" text NOT NULL,
6 | "height" integer NOT NULL,
7 | "width" integer NOT NULL,
8 | "thumbnailUrl" text,
9 | "isTemplate" boolean,
10 | "isPro" boolean,
11 | "createdAt" timestamp NOT NULL,
12 | "updatedAt" timestamp NOT NULL
13 | );
14 | --> statement-breakpoint
15 | DO $$ BEGIN
16 | ALTER TABLE "project" ADD CONSTRAINT "project_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
17 | EXCEPTION
18 | WHEN duplicate_object THEN null;
19 | END $$;
20 |
--------------------------------------------------------------------------------
/drizzle/meta/0000_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "72be38dc-461c-4537-a8f9-a0e01f5bc469",
3 | "prevId": "00000000-0000-0000-0000-000000000000",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.account": {
8 | "name": "account",
9 | "schema": "",
10 | "columns": {
11 | "userId": {
12 | "name": "userId",
13 | "type": "text",
14 | "primaryKey": false,
15 | "notNull": true
16 | },
17 | "type": {
18 | "name": "type",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": true
22 | },
23 | "provider": {
24 | "name": "provider",
25 | "type": "text",
26 | "primaryKey": false,
27 | "notNull": true
28 | },
29 | "providerAccountId": {
30 | "name": "providerAccountId",
31 | "type": "text",
32 | "primaryKey": false,
33 | "notNull": true
34 | },
35 | "refresh_token": {
36 | "name": "refresh_token",
37 | "type": "text",
38 | "primaryKey": false,
39 | "notNull": false
40 | },
41 | "access_token": {
42 | "name": "access_token",
43 | "type": "text",
44 | "primaryKey": false,
45 | "notNull": false
46 | },
47 | "expires_at": {
48 | "name": "expires_at",
49 | "type": "integer",
50 | "primaryKey": false,
51 | "notNull": false
52 | },
53 | "token_type": {
54 | "name": "token_type",
55 | "type": "text",
56 | "primaryKey": false,
57 | "notNull": false
58 | },
59 | "scope": {
60 | "name": "scope",
61 | "type": "text",
62 | "primaryKey": false,
63 | "notNull": false
64 | },
65 | "id_token": {
66 | "name": "id_token",
67 | "type": "text",
68 | "primaryKey": false,
69 | "notNull": false
70 | },
71 | "session_state": {
72 | "name": "session_state",
73 | "type": "text",
74 | "primaryKey": false,
75 | "notNull": false
76 | }
77 | },
78 | "indexes": {},
79 | "foreignKeys": {
80 | "account_userId_user_id_fk": {
81 | "name": "account_userId_user_id_fk",
82 | "tableFrom": "account",
83 | "tableTo": "user",
84 | "columnsFrom": [
85 | "userId"
86 | ],
87 | "columnsTo": [
88 | "id"
89 | ],
90 | "onDelete": "cascade",
91 | "onUpdate": "no action"
92 | }
93 | },
94 | "compositePrimaryKeys": {
95 | "account_provider_providerAccountId_pk": {
96 | "name": "account_provider_providerAccountId_pk",
97 | "columns": [
98 | "provider",
99 | "providerAccountId"
100 | ]
101 | }
102 | },
103 | "uniqueConstraints": {},
104 | "policies": {},
105 | "checkConstraints": {},
106 | "isRLSEnabled": false
107 | },
108 | "public.authenticator": {
109 | "name": "authenticator",
110 | "schema": "",
111 | "columns": {
112 | "credentialID": {
113 | "name": "credentialID",
114 | "type": "text",
115 | "primaryKey": false,
116 | "notNull": true
117 | },
118 | "userId": {
119 | "name": "userId",
120 | "type": "text",
121 | "primaryKey": false,
122 | "notNull": true
123 | },
124 | "providerAccountId": {
125 | "name": "providerAccountId",
126 | "type": "text",
127 | "primaryKey": false,
128 | "notNull": true
129 | },
130 | "credentialPublicKey": {
131 | "name": "credentialPublicKey",
132 | "type": "text",
133 | "primaryKey": false,
134 | "notNull": true
135 | },
136 | "counter": {
137 | "name": "counter",
138 | "type": "integer",
139 | "primaryKey": false,
140 | "notNull": true
141 | },
142 | "credentialDeviceType": {
143 | "name": "credentialDeviceType",
144 | "type": "text",
145 | "primaryKey": false,
146 | "notNull": true
147 | },
148 | "credentialBackedUp": {
149 | "name": "credentialBackedUp",
150 | "type": "boolean",
151 | "primaryKey": false,
152 | "notNull": true
153 | },
154 | "transports": {
155 | "name": "transports",
156 | "type": "text",
157 | "primaryKey": false,
158 | "notNull": false
159 | }
160 | },
161 | "indexes": {},
162 | "foreignKeys": {
163 | "authenticator_userId_user_id_fk": {
164 | "name": "authenticator_userId_user_id_fk",
165 | "tableFrom": "authenticator",
166 | "tableTo": "user",
167 | "columnsFrom": [
168 | "userId"
169 | ],
170 | "columnsTo": [
171 | "id"
172 | ],
173 | "onDelete": "cascade",
174 | "onUpdate": "no action"
175 | }
176 | },
177 | "compositePrimaryKeys": {
178 | "authenticator_userId_credentialID_pk": {
179 | "name": "authenticator_userId_credentialID_pk",
180 | "columns": [
181 | "userId",
182 | "credentialID"
183 | ]
184 | }
185 | },
186 | "uniqueConstraints": {
187 | "authenticator_credentialID_unique": {
188 | "name": "authenticator_credentialID_unique",
189 | "nullsNotDistinct": false,
190 | "columns": [
191 | "credentialID"
192 | ]
193 | }
194 | },
195 | "policies": {},
196 | "checkConstraints": {},
197 | "isRLSEnabled": false
198 | },
199 | "public.session": {
200 | "name": "session",
201 | "schema": "",
202 | "columns": {
203 | "sessionToken": {
204 | "name": "sessionToken",
205 | "type": "text",
206 | "primaryKey": true,
207 | "notNull": true
208 | },
209 | "userId": {
210 | "name": "userId",
211 | "type": "text",
212 | "primaryKey": false,
213 | "notNull": true
214 | },
215 | "expires": {
216 | "name": "expires",
217 | "type": "timestamp",
218 | "primaryKey": false,
219 | "notNull": true
220 | }
221 | },
222 | "indexes": {},
223 | "foreignKeys": {
224 | "session_userId_user_id_fk": {
225 | "name": "session_userId_user_id_fk",
226 | "tableFrom": "session",
227 | "tableTo": "user",
228 | "columnsFrom": [
229 | "userId"
230 | ],
231 | "columnsTo": [
232 | "id"
233 | ],
234 | "onDelete": "cascade",
235 | "onUpdate": "no action"
236 | }
237 | },
238 | "compositePrimaryKeys": {},
239 | "uniqueConstraints": {},
240 | "policies": {},
241 | "checkConstraints": {},
242 | "isRLSEnabled": false
243 | },
244 | "public.user": {
245 | "name": "user",
246 | "schema": "",
247 | "columns": {
248 | "id": {
249 | "name": "id",
250 | "type": "text",
251 | "primaryKey": true,
252 | "notNull": true
253 | },
254 | "name": {
255 | "name": "name",
256 | "type": "text",
257 | "primaryKey": false,
258 | "notNull": false
259 | },
260 | "email": {
261 | "name": "email",
262 | "type": "text",
263 | "primaryKey": false,
264 | "notNull": false
265 | },
266 | "emailVerified": {
267 | "name": "emailVerified",
268 | "type": "timestamp",
269 | "primaryKey": false,
270 | "notNull": false
271 | },
272 | "image": {
273 | "name": "image",
274 | "type": "text",
275 | "primaryKey": false,
276 | "notNull": false
277 | }
278 | },
279 | "indexes": {},
280 | "foreignKeys": {},
281 | "compositePrimaryKeys": {},
282 | "uniqueConstraints": {
283 | "user_email_unique": {
284 | "name": "user_email_unique",
285 | "nullsNotDistinct": false,
286 | "columns": [
287 | "email"
288 | ]
289 | }
290 | },
291 | "policies": {},
292 | "checkConstraints": {},
293 | "isRLSEnabled": false
294 | },
295 | "public.verificationToken": {
296 | "name": "verificationToken",
297 | "schema": "",
298 | "columns": {
299 | "identifier": {
300 | "name": "identifier",
301 | "type": "text",
302 | "primaryKey": false,
303 | "notNull": true
304 | },
305 | "token": {
306 | "name": "token",
307 | "type": "text",
308 | "primaryKey": false,
309 | "notNull": true
310 | },
311 | "expires": {
312 | "name": "expires",
313 | "type": "timestamp",
314 | "primaryKey": false,
315 | "notNull": true
316 | }
317 | },
318 | "indexes": {},
319 | "foreignKeys": {},
320 | "compositePrimaryKeys": {
321 | "verificationToken_identifier_token_pk": {
322 | "name": "verificationToken_identifier_token_pk",
323 | "columns": [
324 | "identifier",
325 | "token"
326 | ]
327 | }
328 | },
329 | "uniqueConstraints": {},
330 | "policies": {},
331 | "checkConstraints": {},
332 | "isRLSEnabled": false
333 | }
334 | },
335 | "enums": {},
336 | "schemas": {},
337 | "sequences": {},
338 | "roles": {},
339 | "policies": {},
340 | "views": {},
341 | "_meta": {
342 | "columns": {},
343 | "schemas": {},
344 | "tables": {}
345 | }
346 | }
--------------------------------------------------------------------------------
/drizzle/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "postgresql",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "7",
8 | "when": 1732175650722,
9 | "tag": "0000_sweet_zeigeist",
10 | "breakpoints": true
11 | },
12 | {
13 | "idx": 1,
14 | "version": "7",
15 | "when": 1732242076926,
16 | "tag": "0001_volatile_gressill",
17 | "breakpoints": true
18 | },
19 | {
20 | "idx": 2,
21 | "version": "7",
22 | "when": 1732449402899,
23 | "tag": "0002_lovely_jocasta",
24 | "breakpoints": true
25 | }
26 | ]
27 | }
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: "https",
7 | hostname: "images.unsplash.com",
8 | },
9 | {
10 | protocol: "https",
11 | hostname: "utfs.io",
12 | },
13 | {
14 | protocol: "https",
15 | hostname: "replicate.delivery",
16 | },
17 | ],
18 | },
19 | };
20 |
21 | export default nextConfig;
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "image-ai",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "db:generate": "npx drizzle-kit generate",
11 | "db:migrate": "npx drizzle-kit migrate",
12 | "db:studio": "npx drizzle-kit studio"
13 | },
14 | "dependencies": {
15 | "@auth/core": "^0.37.4",
16 | "@auth/drizzle-adapter": "^1.7.4",
17 | "@hono/auth-js": "^1.0.15",
18 | "@hono/zod-validator": "^0.4.1",
19 | "@neondatabase/serverless": "^0.10.3",
20 | "@radix-ui/react-avatar": "^1.1.1",
21 | "@radix-ui/react-dialog": "^1.1.2",
22 | "@radix-ui/react-dropdown-menu": "^2.1.2",
23 | "@radix-ui/react-label": "^2.1.0",
24 | "@radix-ui/react-scroll-area": "^1.2.0",
25 | "@radix-ui/react-separator": "^1.1.0",
26 | "@radix-ui/react-slider": "^1.2.1",
27 | "@radix-ui/react-slot": "^1.1.0",
28 | "@radix-ui/react-tooltip": "^1.1.3",
29 | "@tanstack/react-query": "^5.60.2",
30 | "@types/material-colors": "^1.2.3",
31 | "@types/react-color": "^3.0.12",
32 | "@uploadthing/react": "^7.1.1",
33 | "@vercel/analytics": "^1.4.1",
34 | "bcryptjs": "^2.4.3",
35 | "class-variance-authority": "^0.7.0",
36 | "clsx": "^2.1.1",
37 | "date-fns": "^4.1.0",
38 | "drizzle-orm": "^0.36.3",
39 | "drizzle-zod": "^0.5.1",
40 | "fabric": "5.3.0-browser",
41 | "hono": "^4.6.11",
42 | "jsdom": "^25.0.0",
43 | "lodash.debounce": "^4.0.8",
44 | "lucide-react": "^0.440.0",
45 | "material-colors": "^1.2.6",
46 | "next": "14.2.10",
47 | "next-auth": "5.0.0-beta.25",
48 | "next-themes": "^0.4.3",
49 | "react": "^18",
50 | "react-color": "^2.19.3",
51 | "react-dom": "^18",
52 | "react-icons": "^5.3.0",
53 | "react-use": "^17.5.1",
54 | "replicate": "^1.0.1",
55 | "sonner": "^1.7.0",
56 | "tailwind-merge": "^2.5.2",
57 | "tailwindcss-animate": "^1.0.7",
58 | "unsplash-js": "^7.0.19",
59 | "uploadthing": "^7.3.0",
60 | "use-file-picker": "^2.1.2",
61 | "uuidv4": "^6.2.13",
62 | "zod": "^3.23.8",
63 | "zustand": "^5.0.1"
64 | },
65 | "devDependencies": {
66 | "@types/bcryptjs": "^2.4.6",
67 | "@types/fabric": "^5.3.0",
68 | "@types/lodash.debounce": "^4.0.9",
69 | "@types/node": "^20",
70 | "@types/react": "^18",
71 | "@types/react-dom": "^18",
72 | "dotenv": "^16.4.5",
73 | "drizzle-kit": "^0.28.1",
74 | "eslint": "^8",
75 | "eslint-config-next": "14.2.10",
76 | "postcss": "^8",
77 | "tailwindcss": "^3.4.1",
78 | "typescript": "^5"
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/public/bg.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zhengchchen/image-ai/26305374f6b9d912d478700e3f6827bf8c27c5a4/public/bg.jpg
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/(auth)/layout.tsx:
--------------------------------------------------------------------------------
1 | interface AuthLayoutProps {
2 | children: React.ReactNode;
3 | }
4 |
5 | const AuthLayout = ({ children }: AuthLayoutProps) => {
6 | return (
7 |
13 | );
14 | };
15 |
16 | export default AuthLayout;
17 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 | import { SignInCard } from "@/features/auth/components/sign-in-card";
3 |
4 | import { auth } from "@/auth";
5 |
6 | const SignInPage = async () => {
7 | const session = await auth();
8 | if (session) {
9 | redirect("/");
10 | }
11 | return ;
12 | };
13 |
14 | export default SignInPage;
15 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-up/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 |
3 | import { auth } from "@/auth";
4 | import { SignUpCard } from "@/features/auth/components/sign-up-card";
5 |
6 | const SignUpPage = async () => {
7 | const session = await auth();
8 | if (session) {
9 | redirect("/");
10 | }
11 | return ;
12 | };
13 |
14 | export default SignUpPage;
15 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/banner.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { ArrowRight, Sparkles } from "lucide-react";
5 | import { useCreateProject } from "@/features/projects/api/use-create-project";
6 | import { useRouter } from "next/navigation";
7 |
8 | export const Banner = () => {
9 | const router = useRouter();
10 |
11 | const mutation = useCreateProject();
12 |
13 | const onClick = () => {
14 | mutation.mutate(
15 | {
16 | name: "Untitled project",
17 | json: "",
18 | height: 900,
19 | width: 1200,
20 | },
21 | {
22 | onSuccess: ({ data }) => {
23 | router.push(`/editor/${data.id}`);
24 | },
25 | }
26 | );
27 | };
28 |
29 | return (
30 |
31 |
36 |
37 |
Visualize your ideas with Image AI
38 |
39 | Turn inspiration into design in no time. Simply upload a image and let AI do the rest.
40 |
41 |
45 |
46 |
47 | );
48 | };
49 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Navbar } from "./navbar";
2 | import { Sidebar } from "./sidebar";
3 |
4 | interface DashboardLayoutProps {
5 | children: React.ReactNode;
6 | }
7 |
8 | const DashboardLayout = ({ children }: DashboardLayoutProps) => {
9 | return (
10 |
11 |
12 |
13 |
14 | {children}
15 |
16 |
17 | );
18 | };
19 |
20 | export default DashboardLayout;
21 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/logo.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import Image from "next/image";
3 | import { Space_Grotesk } from "next/font/google";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const font = Space_Grotesk({
8 | subsets: ["latin"],
9 | weight: ["700"],
10 | });
11 |
12 | export const Logo = () => {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
Image AI
20 |
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/navbar.tsx:
--------------------------------------------------------------------------------
1 | import { UserButton } from "@/features/auth/components/user-button";
2 |
3 | export const Navbar = () => {
4 | return (
5 |
10 | );
11 | };
12 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/page.tsx:
--------------------------------------------------------------------------------
1 | import { protectServer } from "@/features/auth/utils";
2 | import { Banner } from "./banner";
3 | import { ProjectsSection } from "./projects-section";
4 | import { TemplatesSection } from "./templates-section";
5 |
6 | export default async function Home() {
7 | await protectServer();
8 |
9 | return (
10 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/projects-section.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import { useRouter } from "next/navigation";
5 | import { formatDistanceToNow } from "date-fns";
6 |
7 | import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
8 |
9 | import {
10 | DropdownMenu,
11 | DropdownMenuContent,
12 | DropdownMenuItem,
13 | DropdownMenuTrigger,
14 | } from "@/components/ui/dropdown-menu";
15 |
16 | import { useGetProjects } from "@/features/projects/api/use-get-projects";
17 | import { Search, AlertTriangle, Loader, FileIcon, MoreHorizontal, CopyIcon, Trash, TrashIcon } from "lucide-react";
18 | import { Button } from "@/components/ui/button";
19 | import { useDuplicateProject } from "@/features/projects/api/use-duplicate-project";
20 | import { useDeleteProject } from "@/features/projects/api/use-delete-project";
21 | import { useConfirm } from "@/hooks/use-confirm";
22 |
23 | export const ProjectsSection = () => {
24 | const [ConfirmationDialog, confirm] = useConfirm("Are you sure?", "You are about to delete this project.");
25 | const router = useRouter();
26 |
27 | const duplicateMutation = useDuplicateProject();
28 | const removeMutation = useDeleteProject();
29 |
30 | const onCopy = (id: string) => {
31 | duplicateMutation.mutate({ id });
32 | };
33 |
34 | const onDelete = async (id: string) => {
35 | const ok = await confirm();
36 | if (ok) removeMutation.mutate({ id });
37 | };
38 |
39 | const { data, status, fetchNextPage, isFetchingNextPage, hasNextPage } = useGetProjects();
40 |
41 | if (status === "pending") {
42 | return (
43 |
44 |
Recent Projects
45 |
46 |
47 |
48 |
49 | );
50 | }
51 |
52 | if (status === "error") {
53 | return (
54 |
55 |
Recent Projects
56 |
57 |
58 |
Failed to load projects
59 |
60 |
61 | );
62 | }
63 |
64 | if (!data.pages.length || !data.pages[0].data.length) {
65 | return (
66 |
67 |
Recent Projects
68 |
69 |
70 |
No projects found
71 |
72 |
73 | );
74 | }
75 |
76 | return (
77 |
78 |
79 |
Recent Projects
80 |
81 |
82 | {data.pages.map((group, i) => (
83 |
84 | {group.data.map((project) => (
85 |
86 | router.push(`/editor/${project.id}`)}
88 | className="font-medium flex items-center gap-x-2 cursor-pointer"
89 | >
90 |
91 | {project.name}
92 |
93 | router.push(`/editor/${project.id}`)}
95 | className="hidden md:table-cell cursor-pointer"
96 | >
97 | {project.width} x {project.height} px
98 |
99 | router.push(`/editor/${project.id}`)}
101 | className="hidden md:table-cell cursor-pointer"
102 | >
103 | {formatDistanceToNow(new Date(project.updatedAt), { addSuffix: true })}
104 |
105 |
106 |
107 |
108 |
111 |
112 |
113 | onCopy(project.id)}
117 | >
118 |
119 | Make a copy
120 |
121 |
122 | onDelete(project.id)}
126 | >
127 |
128 | Delete
129 |
130 |
131 |
132 |
133 |
134 | ))}
135 |
136 | ))}
137 |
138 |
139 | {hasNextPage && (
140 |
141 |
144 |
145 | )}
146 |
147 | );
148 | };
149 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/sidebar-item.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import type { LucideIcon } from "lucide-react";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | interface SidebarItemProps {
7 | href: string;
8 | icon: LucideIcon;
9 | label: string;
10 | isActive?: boolean;
11 | onClick?: () => void;
12 | }
13 |
14 | export const SidebarItem = ({ href, icon: Icon, label, isActive, onClick }: SidebarItemProps) => {
15 | return (
16 |
17 |
23 |
24 | {label}
25 |
26 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/sidebar-routes.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { CreditCard, Crown, Home, MessageCircleQuestion } from "lucide-react";
4 | import { usePathname } from "next/navigation";
5 |
6 | import { Button } from "@/components/ui/button";
7 | import { Separator } from "@/components/ui/separator";
8 |
9 | import { SidebarItem } from "./sidebar-item";
10 | import { usePaywall } from "@/features/subscriptions/hooks/use-paywall";
11 |
12 | export const SidebarRoutes = () => {
13 | const pathname = usePathname();
14 |
15 | const { shouldBlock, triggerPaywall } = usePaywall();
16 |
17 | const onClick = () => {
18 | if (shouldBlock) {
19 | triggerPaywall();
20 | return;
21 | }
22 | };
23 |
24 | return (
25 |
26 |
27 |
36 |
37 |
38 |
39 |
40 |
43 |
44 |
45 |
46 |
50 |
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { Logo } from "./logo";
2 | import { SidebarRoutes } from "./sidebar-routes";
3 |
4 | export const Sidebar = () => {
5 | return (
6 |
10 | );
11 | };
12 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/template-card.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | import { cn } from "@/lib/utils";
4 | import { Crown } from "lucide-react";
5 |
6 | interface TemplateCardProps {
7 | imageSrc: string;
8 | title: string;
9 | onClick: (title: string) => void;
10 | description: string;
11 | width: number;
12 | height: number;
13 | isPro: boolean | null;
14 | disabled?: boolean;
15 | }
16 |
17 | export const TemplateCard = ({
18 | imageSrc,
19 | title,
20 | onClick,
21 | description,
22 | width,
23 | height,
24 | isPro,
25 | disabled,
26 | }: TemplateCardProps) => {
27 | return (
28 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/templates-section.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ResponseType, useGetTemplates } from "@/features/projects/api/use-get-templates";
4 | import { Loader, TriangleAlert } from "lucide-react";
5 | import { TemplateCard } from "./template-card";
6 | import { useCreateProject } from "@/features/projects/api/use-create-project";
7 | import { useRouter } from "next/navigation";
8 | import { usePaywall } from "@/features/subscriptions/hooks/use-paywall";
9 |
10 | export const TemplatesSection = () => {
11 | const { shouldBlock, triggerPaywall } = usePaywall();
12 | const router = useRouter();
13 | const mutation = useCreateProject();
14 | const { data, isLoading, isError } = useGetTemplates({ page: "1", limit: "4" });
15 |
16 | const onClick = (template: ResponseType["data"][0]) => {
17 | if (template.isPro && shouldBlock) {
18 | triggerPaywall();
19 | return;
20 | }
21 | mutation.mutate(
22 | {
23 | name: `${template.name} project`,
24 | json: template.json,
25 | height: template.height,
26 | width: template.width,
27 | },
28 | {
29 | onSuccess: ({ data }) => {
30 | router.push(`/editor/${data.id}`);
31 | },
32 | }
33 | );
34 | };
35 |
36 | if (isLoading) {
37 | return (
38 |
39 |
Start from a template
40 |
41 |
42 |
43 |
44 | );
45 | }
46 |
47 | if (isError) {
48 | return (
49 |
50 |
Start from a template
51 |
52 |
53 |
Failed to load templates
54 |
55 |
56 | );
57 | }
58 |
59 | if (!data?.length) {
60 | return null;
61 | }
62 |
63 | return (
64 |
65 |
Start from a template
66 |
67 | {data.map((template) => (
68 | onClick(template)}
77 | description={`${template.width} x ${template.height} px`}
78 | />
79 | ))}
80 |
81 |
82 | );
83 | };
84 |
--------------------------------------------------------------------------------
/src/app/api/[[...route]]/ai.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { Hono } from "hono";
3 | import { zValidator } from "@hono/zod-validator";
4 | import { verifyAuth } from "@hono/auth-js";
5 |
6 | const app = new Hono()
7 | .post(
8 | "/remove-bg",
9 | verifyAuth(),
10 | zValidator(
11 | "json",
12 | z.object({
13 | image: z.string(),
14 | })
15 | ),
16 | async (c) => {
17 | const { image } = c.req.valid("json");
18 |
19 | try {
20 | const input = {
21 | image: image,
22 | };
23 |
24 | const response = await fetch("https://api.replicate.com/v1/predictions", {
25 | method: "POST",
26 | headers: {
27 | Authorization: `Token ${process.env.REPLICATE_API_TOKEN}`,
28 | "Content-Type": "application/json",
29 | },
30 | body: JSON.stringify({
31 | version: "e89200fbc08c5c5e9314e246db83a79d43f16c552dc4005e46cd7896800a989e",
32 | input: input,
33 | }),
34 | });
35 |
36 | const prediction = await response.json();
37 |
38 | const getResult = async () => {
39 | const resultResponse = await fetch(prediction.urls.get, {
40 | headers: {
41 | Authorization: `Token ${process.env.REPLICATE_API_TOKEN}`,
42 | },
43 | });
44 | return await resultResponse.json();
45 | };
46 |
47 | let result;
48 | while (true) {
49 | result = await getResult();
50 | if (result.status === "succeeded") {
51 | return c.json({ data: result.output });
52 | }
53 | if (result.status === "failed") {
54 | throw new Error("Background removal failed");
55 | }
56 | await new Promise((resolve) => setTimeout(resolve, 1000));
57 | }
58 | } catch (error) {
59 | return c.json({ error: "Failed to remove background" }, 500);
60 | }
61 | }
62 | )
63 | .post(
64 | "/generate-image",
65 | verifyAuth(),
66 | zValidator(
67 | "json",
68 | z.object({
69 | prompt: z.string(),
70 | })
71 | ),
72 | async (c) => {
73 | const { prompt } = c.req.valid("json");
74 |
75 | try {
76 | const input = {
77 | cfg: 3.5,
78 | steps: 28,
79 | prompt,
80 | aspect_ratio: "3:2",
81 | output_format: "webp",
82 | output_quality: 90,
83 | negative_prompt: "",
84 | prompt_strength: 0.85,
85 | };
86 |
87 | const response = await fetch("https://api.replicate.com/v1/predictions", {
88 | method: "POST",
89 | headers: {
90 | Authorization: `Token ${process.env.REPLICATE_API_TOKEN}`,
91 | "Content-Type": "application/json",
92 | },
93 | body: JSON.stringify({
94 | version: "72c05df2daf615fb5cc07c28b662a2a58feb6a4d0a652e67e5a9959d914a9ed2",
95 | input: input,
96 | }),
97 | });
98 |
99 | const prediction = await response.json();
100 |
101 | const getResult = async () => {
102 | const resultResponse = await fetch(prediction.urls.get, {
103 | headers: {
104 | Authorization: `Token ${process.env.REPLICATE_API_TOKEN}`,
105 | },
106 | });
107 | return await resultResponse.json();
108 | };
109 |
110 | let result;
111 | while (true) {
112 | result = await getResult();
113 | if (result.status === "succeeded") {
114 | return c.json({ data: result.output[0] });
115 | }
116 | if (result.status === "failed") {
117 | throw new Error("Image generation failed");
118 | }
119 | await new Promise((resolve) => setTimeout(resolve, 1000));
120 | }
121 | } catch (error) {
122 | console.error("Error:", error);
123 | return c.json({ error: "Failed to generate image" }, 500);
124 | }
125 | }
126 | );
127 |
128 | export default app;
129 |
--------------------------------------------------------------------------------
/src/app/api/[[...route]]/images.ts:
--------------------------------------------------------------------------------
1 | import { unsplash } from "@/lib/unsplash";
2 | import { verifyAuth } from "@hono/auth-js";
3 | import { Hono } from "hono";
4 |
5 | const DEFAULT_COUNT = 50;
6 | const DEFAULT_COLLECTION_IDS = ["317099"];
7 |
8 | const app = new Hono().get("/", verifyAuth(), async (c) => {
9 | const images = await unsplash.photos.getRandom({
10 | count: DEFAULT_COUNT,
11 | collectionIds: DEFAULT_COLLECTION_IDS,
12 | });
13 |
14 | if (images.errors) {
15 | return c.json(
16 | {
17 | error: "Something went wrong",
18 | },
19 | 401
20 | );
21 | }
22 |
23 | let response = images.response;
24 |
25 | if (!Array.isArray(response)) {
26 | response = [response];
27 | }
28 |
29 | return c.json({
30 | data: response,
31 | });
32 | });
33 |
34 | export default app;
35 |
--------------------------------------------------------------------------------
/src/app/api/[[...route]]/projects.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/db/drizzle";
2 | import { eq, and, desc, asc } from "drizzle-orm";
3 | import { projectInsertSchema, projects } from "@/db/schema";
4 | import { verifyAuth } from "@hono/auth-js";
5 | import { zValidator } from "@hono/zod-validator";
6 | import { Hono } from "hono";
7 | import { z } from "zod";
8 |
9 | const app = new Hono()
10 | .get(
11 | "/templates",
12 | verifyAuth(),
13 | zValidator(
14 | "query",
15 | z.object({
16 | page: z.coerce.number(),
17 | limit: z.coerce.number()
18 | })
19 | ),
20 | async (c)=>{
21 | const { page, limit } = c.req.valid("query")
22 |
23 | const data = await db
24 | .select()
25 | .from(projects)
26 | .where(eq(projects.isTemplate, true))
27 | .limit(limit)
28 | .offset((page - 1) * limit)
29 | .orderBy(
30 | asc(projects.isPro),
31 | desc(projects.updatedAt)
32 | );
33 |
34 | return c.json({ data });
35 | }
36 | )
37 | .delete(
38 | "/:id",
39 | verifyAuth(),
40 | zValidator("param", z.object({ id: z.string() })),
41 | async (c) => {
42 | const auth = c.get("authUser");
43 | const { id } = c.req.valid("param");
44 |
45 | if (!auth.token?.id) {
46 | return c.json({ error: "Unauthorized" }, 401);
47 | }
48 |
49 | const data = await db
50 | .delete(projects)
51 | .where(and(eq(projects.id, id), eq(projects.userId, auth.token.id.toString())))
52 | .returning();
53 |
54 | if(!data[0]){
55 | return c.json({ error: "Not found" }, 404);
56 | }
57 |
58 | return c.json({ data: { id } });
59 | }
60 | )
61 | .post(
62 | "/:id/duplicate",
63 | verifyAuth(),
64 | zValidator(
65 | "param",
66 | z.object({
67 | id: z.string(),
68 | })
69 | ),
70 | async (c) => {
71 | const auth = c.get("authUser");
72 | const { id } = c.req.valid("param");
73 |
74 | if (!auth.token?.id) {
75 | return c.json({ error: "Unauthorized" }, 401);
76 | }
77 |
78 | const data = await db
79 | .select()
80 | .from(projects)
81 | .where(
82 | and(
83 | eq(projects.id, id),
84 | eq(projects.userId, auth.token.id.toString())
85 | )
86 | );
87 |
88 | if(!data[0]){
89 | return c.json({ error: "Not found" }, 404);
90 | }
91 |
92 | const project = data[0];
93 |
94 | const duplicateData = await db.insert(projects).values({
95 | name: `Copy of ${project.name}`,
96 | json: project.json,
97 | height: project.height,
98 | width: project.width,
99 | userId: project.userId,
100 | createdAt: new Date(),
101 | updatedAt: new Date(),
102 | }).returning();
103 |
104 | return c.json({ data: duplicateData[0] });
105 | }
106 | )
107 | .get(
108 | "/",
109 | verifyAuth(),
110 | zValidator(
111 | "query",
112 | z.object({
113 | limit: z.coerce.number(),
114 | page: z.coerce.number(),
115 | })
116 | ),
117 | async (c) => {
118 | const auth = c.get("authUser");
119 | const { limit, page } = c.req.valid("query");
120 |
121 | if (!auth.token?.id) {
122 | return c.json({ error: "Unauthorized" }, 401);
123 | }
124 |
125 | const data = await db
126 | .select()
127 | .from(projects)
128 | .where(eq(projects.userId, auth.token.id.toString()))
129 | .limit(limit)
130 | .offset((page - 1) * limit)
131 | .orderBy(desc(projects.updatedAt));
132 |
133 | return c.json({ data, nextPage: data.length === limit ? page + 1 : null });
134 | }
135 | )
136 | .patch(
137 | "/:id",
138 | verifyAuth(),
139 | zValidator(
140 | "param",
141 | z.object({
142 | id: z.string(),
143 | })
144 | ),
145 | zValidator(
146 | "json",
147 | projectInsertSchema
148 | .omit({
149 | id:true,
150 | userId: true,
151 | createdAt: true,
152 | updatedAt: true,
153 | })
154 | .partial()
155 | ),
156 | async (c) => {
157 | const auth = c.get("authUser");
158 |
159 | const { id } = c.req.valid("param");
160 | const values = c.req.valid("json");
161 |
162 | if(!auth.token?.id){
163 | return c.json({ error: "Unauthorized" }, 401);
164 | }
165 |
166 | const data = await db
167 | .update(projects)
168 | .set({
169 | ...values,
170 | updatedAt: new Date(),
171 | })
172 | .where(and(eq(projects.id, id), eq(projects.userId, auth.token.id.toString())))
173 | .returning();
174 |
175 | if (!data[0]) {
176 | return c.json({ error: "Unauthorized" }, 401);
177 | }
178 | return c.json({ data: data[0] });
179 | }
180 | )
181 | .get(
182 | "/:id",
183 | verifyAuth(),
184 | zValidator(
185 | "param",
186 | z.object({
187 | id: z.string(),
188 | })
189 | ),
190 | async (c) => {
191 | const auth = c.get("authUser");
192 | const { id } = c.req.valid("param");
193 |
194 | if (!auth.token?.id) {
195 | return c.json({ error: "Unauthorized" }, 401);
196 | }
197 |
198 | const data = await db
199 | .select()
200 | .from(projects)
201 | // @ts-ignore
202 | .where(and(eq(projects.id, id), eq(projects.userId, auth.token.id)));
203 |
204 | if (!data[0]) {
205 | return c.json({ error: "Not found" }, 404);
206 | }
207 | return c.json({ data: data[0] });
208 | }
209 | )
210 | .post(
211 | "/",
212 | verifyAuth(),
213 | zValidator(
214 | "json",
215 | projectInsertSchema.pick({
216 | name: true,
217 | json: true,
218 | height: true,
219 | width: true,
220 | })
221 | ),
222 | async (c) => {
223 | const auth = c.get("authUser");
224 | const { name, json, height, width } = c.req.valid("json");
225 | if (!auth.token?.id) {
226 | return c.json({ error: "Unauthorized" }, 401);
227 | }
228 | const data = await db
229 | .insert(projects)
230 | // @ts-ignore
231 | .values({
232 | name,
233 | json,
234 | height,
235 | width,
236 | userId: auth.token.id,
237 | createdAt: new Date(),
238 | updatedAt: new Date(),
239 | })
240 | .returning();
241 |
242 | if (!data[0]) {
243 | return c.json({ error: "Something went wrong" }, 400);
244 | }
245 | return c.json({ data: data[0] });
246 | }
247 | );
248 |
249 | export default app;
250 |
--------------------------------------------------------------------------------
/src/app/api/[[...route]]/route.ts:
--------------------------------------------------------------------------------
1 | import { Context, Hono } from "hono";
2 | import { handle } from "hono/vercel";
3 |
4 | import { AuthConfig, initAuthConfig } from "@hono/auth-js";
5 |
6 | import ai from "./ai";
7 | import users from "./users";
8 | import images from "./images";
9 | import projects from "./projects";
10 | import authConfig from "@/auth.config";
11 | // Revert to "edge" if planning on running on the edge
12 | export const runtime = "nodejs";
13 |
14 | function getAuthConfig(c: Context): AuthConfig {
15 | return {
16 | secret: process.env.AUTH_SECRET,
17 | ...(authConfig as any),
18 | };
19 | }
20 |
21 | const app = new Hono().basePath("/api");
22 |
23 | app.use("*", initAuthConfig(getAuthConfig));
24 |
25 | const routes = app.route("/ai", ai).route("/users", users).route("/images", images).route("/projects", projects);
26 |
27 | export const GET = handle(app);
28 | export const POST = handle(app);
29 | export const PATCH = handle(app);
30 | export const DELETE = handle(app);
31 |
32 | export type AppType = typeof routes;
33 |
--------------------------------------------------------------------------------
/src/app/api/[[...route]]/users.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import bcrypt from "bcryptjs";
3 | import { Hono } from "hono";
4 | import { zValidator } from "@hono/zod-validator";
5 | import { db } from "@/db/drizzle";
6 | import { users } from "@/db/schema";
7 | import { eq } from "drizzle-orm";
8 |
9 | const app = new Hono().post(
10 | "/",
11 | zValidator(
12 | "json",
13 | z.object({
14 | name: z.string(),
15 | email: z.string().email(),
16 | password: z.string().min(3).max(20),
17 | })
18 | ),
19 | async (c) => {
20 | const { name, email, password } = c.req.valid("json");
21 |
22 | const hashedPassword = await bcrypt.hash(password, 12);
23 |
24 | const query = await db.select().from(users).where(eq(users.email, email));
25 |
26 | if (query.length > 0) {
27 | return c.json({
28 | error: "Email already in use",
29 | }, 400);
30 | }
31 |
32 | await db.insert(users).values({
33 | name,
34 | email,
35 | password: hashedPassword,
36 | });
37 |
38 | return c.json(null, 200);
39 | }
40 | );
41 |
42 | export default app;
43 |
--------------------------------------------------------------------------------
/src/app/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | import { handlers } from "@/auth"
2 | export const { GET, POST } = handlers
--------------------------------------------------------------------------------
/src/app/api/uploadthing/core.ts:
--------------------------------------------------------------------------------
1 | import { auth } from "@/auth";
2 | import { createUploadthing, type FileRouter } from "uploadthing/next";
3 | import { UploadThingError } from "uploadthing/server";
4 |
5 | const f = createUploadthing();
6 |
7 | export const ourFileRouter = {
8 | imageUploader: f({ image: { maxFileSize: "4MB" } })
9 | .middleware(async ({ req }) => {
10 | // TODO: replace with next auth
11 | const session = await auth()
12 |
13 | if (!session) throw new UploadThingError("Unauthorized");
14 |
15 | return { userId: session.user?.id };
16 | })
17 | .onUploadComplete(async ({ metadata, file }) => {
18 | return { url: file.url };
19 | }),
20 | } satisfies FileRouter;
21 |
22 | export type OurFileRouter = typeof ourFileRouter;
23 |
--------------------------------------------------------------------------------
/src/app/api/uploadthing/route.ts:
--------------------------------------------------------------------------------
1 | import { createRouteHandler } from "uploadthing/next";
2 |
3 | import { ourFileRouter } from "./core";
4 |
5 | // Export routes for Next App Router
6 | export const { GET, POST } = createRouteHandler({
7 | router: ourFileRouter,
8 |
9 | // Apply an (optional) custom config:
10 | // config: { ... },
11 | });
12 |
--------------------------------------------------------------------------------
/src/app/editor/[projectId]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useGetProject } from "@/features/projects/api/use-get-project";
4 | import { Editor } from "@/features/editor/components/editor";
5 | import { Loader, TriangleAlert } from "lucide-react";
6 | import { Button } from "@/components/ui/button";
7 | import Link from "next/link";
8 |
9 | interface EditorProjectIdPageProps {
10 | params: {
11 | projectId: string;
12 | };
13 | }
14 |
15 | const EditorProjectIdPage = ({ params }: EditorProjectIdPageProps) => {
16 | const { data, isLoading, isError } = useGetProject(params.projectId);
17 |
18 | if (isLoading || !data)
19 | return (
20 |
21 |
22 |
23 | );
24 | if (isError)
25 | return (
26 |
27 |
28 |
Failed to fetch project
29 |
32 |
33 | );
34 |
35 | return ;
36 | };
37 |
38 | export default EditorProjectIdPage;
39 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zhengchchen/image-ai/26305374f6b9d912d478700e3f6827bf8c27c5a4/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/fonts/GeistMonoVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zhengchchen/image-ai/26305374f6b9d912d478700e3f6827bf8c27c5a4/src/app/fonts/GeistMonoVF.woff
--------------------------------------------------------------------------------
/src/app/fonts/GeistVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zhengchchen/image-ai/26305374f6b9d912d478700e3f6827bf8c27c5a4/src/app/fonts/GeistVF.woff
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body {
7 | height: 100%;
8 | }
9 |
10 | :root {
11 | --background: #ffffff;
12 | --foreground: #171717;
13 | }
14 |
15 | @media (prefers-color-scheme: dark) {
16 | :root {
17 | --background: #0a0a0a;
18 | --foreground: #ededed;
19 | }
20 | }
21 |
22 | body {
23 | color: var(--foreground);
24 | background: var(--background);
25 | font-family: Arial, Helvetica, sans-serif;
26 | }
27 |
28 | @layer utilities {
29 | .text-balance {
30 | text-wrap: balance;
31 | }
32 | }
33 |
34 | @layer base {
35 | :root {
36 | --background: 0 0% 100%;
37 | --foreground: 222.2 84% 4.9%;
38 | --card: 0 0% 100%;
39 | --card-foreground: 222.2 84% 4.9%;
40 | --popover: 0 0% 100%;
41 | --popover-foreground: 222.2 84% 4.9%;
42 | --primary: 222.2 47.4% 11.2%;
43 | --primary-foreground: 210 40% 98%;
44 | --secondary: 210 40% 96.1%;
45 | --secondary-foreground: 222.2 47.4% 11.2%;
46 | --muted: 210 40% 96.1%;
47 | --muted-foreground: 215.4 16.3% 46.9%;
48 | --accent: 210 40% 96.1%;
49 | --accent-foreground: 222.2 47.4% 11.2%;
50 | --destructive: 0 84.2% 60.2%;
51 | --destructive-foreground: 210 40% 98%;
52 | --border: 214.3 31.8% 91.4%;
53 | --input: 214.3 31.8% 91.4%;
54 | --ring: 222.2 84% 4.9%;
55 | --chart-1: 12 76% 61%;
56 | --chart-2: 173 58% 39%;
57 | --chart-3: 197 37% 24%;
58 | --chart-4: 43 74% 66%;
59 | --chart-5: 27 87% 67%;
60 | --radius: 0.5rem;
61 | }
62 | .dark {
63 | --background: 222.2 84% 4.9%;
64 | --foreground: 210 40% 98%;
65 | --card: 222.2 84% 4.9%;
66 | --card-foreground: 210 40% 98%;
67 | --popover: 222.2 84% 4.9%;
68 | --popover-foreground: 210 40% 98%;
69 | --primary: 210 40% 98%;
70 | --primary-foreground: 222.2 47.4% 11.2%;
71 | --secondary: 217.2 32.6% 17.5%;
72 | --secondary-foreground: 210 40% 98%;
73 | --muted: 217.2 32.6% 17.5%;
74 | --muted-foreground: 215 20.2% 65.1%;
75 | --accent: 217.2 32.6% 17.5%;
76 | --accent-foreground: 210 40% 98%;
77 | --destructive: 0 62.8% 30.6%;
78 | --destructive-foreground: 210 40% 98%;
79 | --border: 217.2 32.6% 17.5%;
80 | --input: 217.2 32.6% 17.5%;
81 | --ring: 212.7 26.8% 83.9%;
82 | --chart-1: 220 70% 50%;
83 | --chart-2: 160 60% 45%;
84 | --chart-3: 30 80% 55%;
85 | --chart-4: 280 65% 60%;
86 | --chart-5: 340 75% 55%;
87 | }
88 | }
89 |
90 | @layer base {
91 | * {
92 | @apply border-border;
93 | }
94 | body {
95 | @apply bg-background text-foreground;
96 | }
97 | }
98 |
99 | .circle-picker {
100 | width: auto !important;
101 | }
102 |
103 | .chrome-picker {
104 | width: auto !important;
105 | box-shadow: none !important;
106 | border-radius: 0.5rem !important;
107 | overflow: hidden !important;
108 | }
109 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Toaster } from "sonner";
3 | import "./globals.css";
4 |
5 | import { Inter } from "next/font/google";
6 |
7 | import { Providers } from "@/components/provides";
8 | import { auth } from "@/auth";
9 | import { SessionProvider } from "next-auth/react";
10 | import { Modals } from "@/components/modals";
11 | import { Analytics } from "@vercel/analytics/react"
12 |
13 | const inter = Inter({
14 | subsets: ["latin"],
15 | });
16 |
17 | export const metadata: Metadata = {
18 | title: "Image AI - AI-Powered Image Editor & Generator",
19 | description:
20 | "Transform your images with AI magic. Create, edit, and enhance photos with advanced AI filters, one-click beautification, and AI image generation.",
21 | keywords: "AI image editor, online photo editor, AI filters, image generation, photo enhancement",
22 | openGraph: {
23 | title: "ImageGicAI - AI-Powered Image Editor & Generator",
24 | description: "Transform your images with AI magic",
25 | images: ["/og-image.png"],
26 | },
27 | twitter: {
28 | card: "summary_large_image",
29 | title: "ImageGicAI - AI-Powered Image Editor",
30 | description: "Transform your images with AI magic",
31 | },
32 | };
33 |
34 | export default async function RootLayout({
35 | children,
36 | }: Readonly<{
37 | children: React.ReactNode;
38 | }>) {
39 | const session = await auth();
40 | return (
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | {children}
49 |
50 |
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/src/auth.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextAuthConfig } from "next-auth";
2 |
3 | import { JWT } from "next-auth/jwt";
4 |
5 | import Credentials from "next-auth/providers/credentials";
6 | import Github from "next-auth/providers/github";
7 | import Google from "next-auth/providers/google";
8 | import { DrizzleAdapter } from "@auth/drizzle-adapter";
9 | import { db } from "@/db/drizzle";
10 | import bcrypt from "bcryptjs";
11 | import { eq } from "drizzle-orm";
12 |
13 | import { users, accounts } from "@/db/schema";
14 | import { z } from "zod";
15 |
16 | declare module "next-auth/jwt" {
17 | interface JWT {
18 | id: string | undefined;
19 | }
20 | }
21 |
22 | const CredentialsSchema = z.object({
23 | email: z.string().email(),
24 | password: z.string(),
25 | });
26 |
27 | export default {
28 | providers: [
29 | Credentials({
30 | credentials: {
31 | email: { label: "Email", type: "email" },
32 | password: { label: "Password", type: "password" },
33 | },
34 | async authorize(credentials) {
35 | const validatedFields = CredentialsSchema.safeParse(credentials);
36 | if (!validatedFields.success) {
37 | return null;
38 | }
39 |
40 | const { email, password } = validatedFields.data
41 |
42 | const query = await db.select().from(users).where(eq(users.email,email))
43 |
44 | const user = query[0]
45 |
46 | if(!user || !user.password){
47 | return null
48 | }
49 |
50 | const passwordsMatch = await bcrypt.compare(password, user.password)
51 |
52 | if(!passwordsMatch){
53 | return null
54 | }
55 |
56 | return user
57 | },
58 | }),
59 | Github,
60 | Google],
61 | adapter: DrizzleAdapter(db, {
62 | usersTable: users,
63 | accountsTable: accounts,
64 | }),
65 | pages: {
66 | signIn: "/sign-in",
67 | error: "/sign-in",
68 | },
69 | session: {
70 | strategy: "jwt",
71 | },
72 | callbacks:{
73 | jwt({ token, user }) {
74 | if (user) {
75 | token.id = user.id;
76 | }
77 | return token;
78 | },
79 | session({ session, token }) {
80 | if (token.id) {
81 | session.user.id = token.id;
82 | }
83 | return session;
84 | },
85 | },
86 | } satisfies NextAuthConfig;
87 |
--------------------------------------------------------------------------------
/src/auth.ts:
--------------------------------------------------------------------------------
1 | import NextAuth from "next-auth";
2 |
3 | import authConfig from "@/auth.config";
4 |
5 | export const { handlers, signIn, signOut, auth } = NextAuth(authConfig);
6 |
--------------------------------------------------------------------------------
/src/components/hint.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
2 |
3 | export interface HintProps {
4 | label: string;
5 | children: React.ReactNode;
6 | side?: "top" | "bottom" | "left" | "right";
7 | align?: "start" | "center" | "end";
8 | sideOffset?: number;
9 | alignOffset?: number;
10 | }
11 |
12 | export const Hint = ({ label, children, side, align, sideOffset, alignOffset }: HintProps) => {
13 | return (
14 |
15 |
16 | {children}
17 |
24 | {label}
25 |
26 |
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/src/components/modals.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect } from "react";
4 |
5 | import { SubscriptionModal } from "@/features/subscriptions/components/subscription-modal";
6 |
7 | export const Modals = () => {
8 | const [isMounted, setIsMounted] = useState(false);
9 |
10 | useEffect(() => {
11 | setIsMounted(true);
12 | }, []);
13 |
14 | if (!isMounted) return null;
15 |
16 | return (
17 | <>
18 |
19 | >
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/src/components/provides.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { QueryProviders } from "./query-provider";
4 |
5 | interface ProvidersProps {
6 | children: React.ReactNode;
7 | }
8 |
9 | export function Providers({ children }: ProvidersProps) {
10 | return {children};
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/query-provider.tsx:
--------------------------------------------------------------------------------
1 | // In Next.js, this file would be called: app/providers.tsx
2 | "use client";
3 |
4 | // Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top
5 | import { isServer, QueryClient, QueryClientProvider } from "@tanstack/react-query";
6 |
7 | function makeQueryClient() {
8 | return new QueryClient({
9 | defaultOptions: {
10 | queries: {
11 | // With SSR, we usually want to set some default staleTime
12 | // above 0 to avoid refetching immediately on the client
13 | staleTime: 60 * 1000,
14 | },
15 | },
16 | });
17 | }
18 |
19 | let browserQueryClient: QueryClient | undefined = undefined;
20 |
21 | function getQueryClient() {
22 | if (isServer) {
23 | // Server: always make a new query client
24 | return makeQueryClient();
25 | } else {
26 | // Browser: make a new query client if we don't already have one
27 | // This is very important, so we don't re-make a new client if React
28 | // suspends during the initial render. This may not be needed if we
29 | // have a suspense boundary BELOW the creation of the query client
30 | if (!browserQueryClient) browserQueryClient = makeQueryClient();
31 | return browserQueryClient;
32 | }
33 | }
34 |
35 | interface QueryProvidersProps {
36 | children: React.ReactNode;
37 | }
38 |
39 | export function QueryProviders({ children }: QueryProvidersProps) {
40 | // NOTE: Avoid useState when initializing the query client if you don't
41 | // have a suspense boundary between this and the code that may
42 | // suspend because React will throw away the client on the initial
43 | // render if it suspends and there is no boundary
44 | const queryClient = getQueryClient();
45 |
46 | return {children};
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/src/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: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
14 | outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
15 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
16 | ghost: "hover:bg-accent hover:text-accent-foreground",
17 | link: "text-primary underline-offset-4 hover:underline",
18 | },
19 | size: {
20 | default: "h-10 px-4 py-2",
21 | sm: "h-9 rounded-md px-3",
22 | lg: "h-11 rounded-md px-8",
23 | icon: "h-8 w-8",
24 | },
25 | },
26 | defaultVariants: {
27 | variant: "default",
28 | size: "default",
29 | },
30 | }
31 | );
32 |
33 | export interface ButtonProps
34 | extends React.ButtonHTMLAttributes,
35 | VariantProps {
36 | asChild?: boolean;
37 | }
38 |
39 | const Button = React.forwardRef(
40 | ({ className, variant, size, asChild = false, ...props }, ref) => {
41 | const Comp = asChild ? Slot : "button";
42 | return ;
43 | }
44 | );
45 | Button.displayName = "Button";
46 |
47 | export { Button, buttonVariants };
48 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLDivElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLDivElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
5 | import { Check, ChevronRight, Circle } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root;
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group;
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub;
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean;
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ));
40 | DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
41 |
42 | const DropdownMenuSubContent = React.forwardRef<
43 | React.ElementRef,
44 | React.ComponentPropsWithoutRef
45 | >(({ className, ...props }, ref) => (
46 |
54 | ));
55 | DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
56 |
57 | const DropdownMenuContent = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, sideOffset = 4, ...props }, ref) => (
61 |
62 |
71 |
72 | ));
73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
74 |
75 | const DropdownMenuItem = React.forwardRef<
76 | React.ElementRef,
77 | React.ComponentPropsWithoutRef & {
78 | inset?: boolean;
79 | }
80 | >(({ className, inset, ...props }, ref) => (
81 |
90 | ));
91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
92 |
93 | const DropdownMenuCheckboxItem = React.forwardRef<
94 | React.ElementRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, children, checked, ...props }, ref) => (
97 |
106 |
107 |
108 |
109 |
110 |
111 | {children}
112 |
113 | ));
114 | DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
115 |
116 | const DropdownMenuRadioItem = React.forwardRef<
117 | React.ElementRef,
118 | React.ComponentPropsWithoutRef
119 | >(({ className, children, ...props }, ref) => (
120 |
128 |
129 |
130 |
131 |
132 |
133 | {children}
134 |
135 | ));
136 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
137 |
138 | const DropdownMenuLabel = React.forwardRef<
139 | React.ElementRef,
140 | React.ComponentPropsWithoutRef & {
141 | inset?: boolean;
142 | }
143 | >(({ className, inset, ...props }, ref) => (
144 |
149 | ));
150 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
151 |
152 | const DropdownMenuSeparator = React.forwardRef<
153 | React.ElementRef,
154 | React.ComponentPropsWithoutRef
155 | >(({ className, ...props }, ref) => (
156 |
157 | ));
158 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
159 |
160 | const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => {
161 | return ;
162 | };
163 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
164 |
165 | export {
166 | DropdownMenu,
167 | DropdownMenuTrigger,
168 | DropdownMenuContent,
169 | DropdownMenuItem,
170 | DropdownMenuCheckboxItem,
171 | DropdownMenuRadioItem,
172 | DropdownMenuLabel,
173 | DropdownMenuSeparator,
174 | DropdownMenuShortcut,
175 | DropdownMenuGroup,
176 | DropdownMenuPortal,
177 | DropdownMenuSub,
178 | DropdownMenuSubContent,
179 | DropdownMenuSubTrigger,
180 | DropdownMenuRadioGroup,
181 | };
182 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | )
18 | }
19 | )
20 | Input.displayName = "Input"
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/src/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 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 | ))
24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
25 |
26 | const ScrollBar = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, orientation = "vertical", ...props }, ref) => (
30 |
43 |
44 |
45 | ))
46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
47 |
48 | export { ScrollArea, ScrollBar }
49 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = "horizontal", decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | )
29 | Separator.displayName = SeparatorPrimitive.Root.displayName
30 |
31 | export { Separator }
32 |
--------------------------------------------------------------------------------
/src/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SliderPrimitive from "@radix-ui/react-slider"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Slider = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
21 |
22 |
23 |
24 |
25 | ))
26 | Slider.displayName = SliderPrimitive.Root.displayName
27 |
28 | export { Slider }
29 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTheme } from "next-themes"
4 | import { Toaster as Sonner } from "sonner"
5 |
6 | type ToasterProps = React.ComponentProps
7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme = "system" } = useTheme()
10 |
11 | return (
12 |
28 | )
29 | }
30 |
31 | export { Toaster }
32 |
--------------------------------------------------------------------------------
/src/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Table = React.forwardRef<
6 | HTMLTableElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
16 | ))
17 | Table.displayName = "Table"
18 |
19 | const TableHeader = React.forwardRef<
20 | HTMLTableSectionElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
24 | ))
25 | TableHeader.displayName = "TableHeader"
26 |
27 | const TableBody = React.forwardRef<
28 | HTMLTableSectionElement,
29 | React.HTMLAttributes
30 | >(({ className, ...props }, ref) => (
31 |
36 | ))
37 | TableBody.displayName = "TableBody"
38 |
39 | const TableFooter = React.forwardRef<
40 | HTMLTableSectionElement,
41 | React.HTMLAttributes
42 | >(({ className, ...props }, ref) => (
43 | tr]:last:border-b-0",
47 | className
48 | )}
49 | {...props}
50 | />
51 | ))
52 | TableFooter.displayName = "TableFooter"
53 |
54 | const TableRow = React.forwardRef<
55 | HTMLTableRowElement,
56 | React.HTMLAttributes
57 | >(({ className, ...props }, ref) => (
58 |
66 | ))
67 | TableRow.displayName = "TableRow"
68 |
69 | const TableHead = React.forwardRef<
70 | HTMLTableCellElement,
71 | React.ThHTMLAttributes
72 | >(({ className, ...props }, ref) => (
73 | |
81 | ))
82 | TableHead.displayName = "TableHead"
83 |
84 | const TableCell = React.forwardRef<
85 | HTMLTableCellElement,
86 | React.TdHTMLAttributes
87 | >(({ className, ...props }, ref) => (
88 | |
93 | ))
94 | TableCell.displayName = "TableCell"
95 |
96 | const TableCaption = React.forwardRef<
97 | HTMLTableCaptionElement,
98 | React.HTMLAttributes
99 | >(({ className, ...props }, ref) => (
100 |
105 | ))
106 | TableCaption.displayName = "TableCaption"
107 |
108 | export {
109 | Table,
110 | TableHeader,
111 | TableBody,
112 | TableFooter,
113 | TableHead,
114 | TableRow,
115 | TableCell,
116 | TableCaption,
117 | }
118 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<"textarea">
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | )
19 | })
20 | Textarea.displayName = "Textarea"
21 |
22 | export { Textarea }
23 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/src/db/drizzle.ts:
--------------------------------------------------------------------------------
1 | import { neon } from '@neondatabase/serverless';
2 | import { drizzle } from 'drizzle-orm/neon-http';
3 |
4 | const sql = neon(process.env.DATABASE_URL!);
5 | export const db = drizzle({ client: sql });
6 |
--------------------------------------------------------------------------------
/src/db/schema.ts:
--------------------------------------------------------------------------------
1 | import { relations } from "drizzle-orm";
2 | import { createInsertSchema } from "drizzle-zod";
3 | import { boolean, timestamp, pgTable, text, primaryKey, integer } from "drizzle-orm/pg-core";
4 | import type { AdapterAccountType } from "next-auth/adapters";
5 |
6 | export const users = pgTable("user", {
7 | id: text("id")
8 | .primaryKey()
9 | .$defaultFn(() => crypto.randomUUID()),
10 | name: text("name"),
11 | email: text("email").unique(),
12 | emailVerified: timestamp("emailVerified", { mode: "date" }),
13 | image: text("image"),
14 | password: text("password"),
15 | });
16 |
17 | export const userRelations = relations(users, ({ many }) => ({
18 | projects: many(projects),
19 | }));
20 |
21 | export const accounts = pgTable(
22 | "account",
23 | {
24 | userId: text("userId")
25 | .notNull()
26 | .references(() => users.id, { onDelete: "cascade" }),
27 | type: text("type").$type().notNull(),
28 | provider: text("provider").notNull(),
29 | providerAccountId: text("providerAccountId").notNull(),
30 | refresh_token: text("refresh_token"),
31 | access_token: text("access_token"),
32 | expires_at: integer("expires_at"),
33 | token_type: text("token_type"),
34 | scope: text("scope"),
35 | id_token: text("id_token"),
36 | session_state: text("session_state"),
37 | },
38 | (account) => ({
39 | compoundKey: primaryKey({
40 | columns: [account.provider, account.providerAccountId],
41 | }),
42 | })
43 | );
44 |
45 | export const sessions = pgTable("session", {
46 | sessionToken: text("sessionToken").primaryKey(),
47 | userId: text("userId")
48 | .notNull()
49 | .references(() => users.id, { onDelete: "cascade" }),
50 | expires: timestamp("expires", { mode: "date" }).notNull(),
51 | });
52 |
53 | export const verificationTokens = pgTable(
54 | "verificationToken",
55 | {
56 | identifier: text("identifier").notNull(),
57 | token: text("token").notNull(),
58 | expires: timestamp("expires", { mode: "date" }).notNull(),
59 | },
60 | (verificationToken) => ({
61 | compositePk: primaryKey({
62 | columns: [verificationToken.identifier, verificationToken.token],
63 | }),
64 | })
65 | );
66 |
67 | export const authenticators = pgTable(
68 | "authenticator",
69 | {
70 | credentialID: text("credentialID").notNull().unique(),
71 | userId: text("userId")
72 | .notNull()
73 | .references(() => users.id, { onDelete: "cascade" }),
74 | providerAccountId: text("providerAccountId").notNull(),
75 | credentialPublicKey: text("credentialPublicKey").notNull(),
76 | counter: integer("counter").notNull(),
77 | credentialDeviceType: text("credentialDeviceType").notNull(),
78 | credentialBackedUp: boolean("credentialBackedUp").notNull(),
79 | transports: text("transports"),
80 | },
81 | (authenticator) => ({
82 | compositePK: primaryKey({
83 | columns: [authenticator.userId, authenticator.credentialID],
84 | }),
85 | })
86 | );
87 |
88 | export const projects = pgTable("project", {
89 | id: text("id")
90 | .primaryKey()
91 | .$defaultFn(() => crypto.randomUUID()),
92 | name: text("name").notNull(),
93 | userId: text("userId")
94 | .notNull()
95 | .references(() => users.id, { onDelete: "cascade" }),
96 | json: text("json").notNull(),
97 | height: integer("height").notNull(),
98 | width: integer("width").notNull(),
99 | thumbnailUrl: text("thumbnailUrl"),
100 | isTemplate: boolean("isTemplate"),
101 | isPro: boolean("isPro"),
102 | createdAt: timestamp("createdAt", { mode: "date" }).notNull(),
103 | updatedAt: timestamp("updatedAt", { mode: "date" }).notNull(),
104 | });
105 |
106 | export const projectRelations = relations(projects, ({ one }) => ({
107 | user: one(users, {
108 | fields: [projects.userId],
109 | references: [users.id],
110 | }),
111 | }));
112 |
113 | export const projectInsertSchema = createInsertSchema(projects);
114 |
--------------------------------------------------------------------------------
/src/features/ai/api/use-generate-image.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from "@tanstack/react-query";
2 | import { InferRequestType, InferResponseType } from "hono";
3 |
4 | import { client } from "@/lib/hono";
5 |
6 | type ResponseType = InferResponseType<(typeof client.api.ai)["generate-image"]["$post"]>;
7 | type RequestType = InferRequestType<(typeof client.api.ai)["generate-image"]["$post"]>["json"];
8 |
9 | export const useGenerateImage = () => {
10 | const mutation = useMutation({
11 | mutationFn: async (json) => {
12 | const response = await client.api.ai["generate-image"].$post({ json });
13 | return await response.json();
14 | },
15 | });
16 |
17 | return mutation;
18 | };
19 |
--------------------------------------------------------------------------------
/src/features/ai/api/use-remove-background.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from "@tanstack/react-query";
2 | import { InferRequestType, InferResponseType } from "hono";
3 |
4 | import { client } from "@/lib/hono";
5 |
6 | type ResponseType = InferResponseType<(typeof client.api.ai)["remove-bg"]["$post"]>;
7 | type RequestType = InferRequestType<(typeof client.api.ai)["remove-bg"]["$post"]>["json"];
8 |
9 | export const useRemoveBackground = () => {
10 | const mutation = useMutation({
11 | mutationFn: async (json) => {
12 | const response = await client.api.ai["remove-bg"].$post({ json });
13 | return await response.json();
14 | },
15 | });
16 |
17 | return mutation;
18 | };
19 |
--------------------------------------------------------------------------------
/src/features/auth/components/sign-in-card.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import Link from "next/link"
4 | import { signIn } from "next-auth/react"
5 | import { FaGithub } from "react-icons/fa"
6 | import { FcGoogle } from "react-icons/fc"
7 |
8 | import { Button } from "@/components/ui/button"
9 | import {
10 | Card,
11 | CardTitle,
12 | CardHeader,
13 | CardContent,
14 | CardDescription
15 | } from "@/components/ui/card"
16 | import { useState } from "react"
17 | import { Input } from "@/components/ui/input"
18 | import { Separator } from "@/components/ui/separator"
19 | import { useSearchParams } from "next/navigation"
20 | import { TriangleAlert } from "lucide-react"
21 |
22 |
23 | export const SignInCard = () => {
24 |
25 | const [email, setEmail] = useState("");
26 | const [password, setPassword] = useState("");
27 |
28 | const params = useSearchParams()
29 | const error = params.get("error")
30 |
31 | const onCredentialsSignin = (e: React.FormEvent) => {
32 | e.preventDefault();
33 | signIn("credentials", { email, password, redirectTo: "/" });
34 | }
35 |
36 | const onProviderSignin = (provider:"github" | "google") => {
37 | signIn(provider,{ redirectTo:"/" })
38 | }
39 |
40 | return
41 |
42 | Login to continue
43 | Use your email or another service to login
44 |
45 | {
46 | !!error &&
47 |
48 |
Invalid email or password
49 |
50 | }
51 |
52 |
57 |
58 |
59 |
62 |
65 |
66 | Don't have an account? Sign up
67 |
68 |
69 | }
--------------------------------------------------------------------------------
/src/features/auth/components/sign-up-card.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "next/link";
4 | import { signIn } from "next-auth/react";
5 | import { FaGithub } from "react-icons/fa";
6 | import { FcGoogle } from "react-icons/fc";
7 |
8 | import { Button } from "@/components/ui/button";
9 | import { Card, CardTitle, CardHeader, CardContent, CardDescription } from "@/components/ui/card";
10 | import { Input } from "@/components/ui/input";
11 | import { Separator } from "@/components/ui/separator";
12 | import { useState } from "react";
13 | import { useSignUp } from "@/features/auth/hooks/use-sign-up";
14 | import { TriangleAlert } from "lucide-react";
15 |
16 | export const SignUpCard = () => {
17 | const mutation = useSignUp();
18 |
19 | const [name, setName] = useState("");
20 | const [email, setEmail] = useState("");
21 | const [password, setPassword] = useState("");
22 |
23 | const onProviderSignUp = (provider: "github" | "google") => {
24 | signIn(provider, { redirectTo: "/" });
25 | };
26 |
27 | const onCredentialsSignup = (e: React.FormEvent) => {
28 | e.preventDefault();
29 |
30 | mutation.mutate({
31 | name,
32 | email,
33 | password,
34 | },
35 | {
36 | onSuccess: () => {
37 | signIn("credentials", { email, password, redirectTo: "/" });
38 | },
39 | }
40 | );
41 | };
42 |
43 | return (
44 |
45 |
46 | Create an account
47 | Use your email or another service to continue
48 |
49 | {
50 | !!mutation.error &&
51 |
52 |
Something went wrong
53 |
54 | }
55 |
56 |
62 |
63 |
64 |
68 |
72 |
73 |
74 | Already have an account?{" "}
75 |
76 | Sign in
77 |
78 |
79 |
80 |
81 | );
82 | };
83 |
--------------------------------------------------------------------------------
/src/features/auth/components/user-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Avatar,
5 | AvatarFallback,
6 | AvatarImage
7 | } from "@/components/ui/avatar"
8 |
9 | import {
10 | DropdownMenu,
11 | DropdownMenuContent,
12 | DropdownMenuItem,
13 | DropdownMenuLabel,
14 | DropdownMenuSeparator,
15 | DropdownMenuTrigger
16 | } from "@/components/ui/dropdown-menu"
17 | import { Loader, LogOutIcon, CreditCard } from "lucide-react";
18 |
19 | import { signOut, useSession } from "next-auth/react";
20 | import { redirect } from "next/navigation";
21 |
22 | export const UserButton = () => {
23 | const session = useSession()
24 |
25 | if (session.status === "loading") {
26 | return
27 | }
28 |
29 | if(session.status === "unauthenticated" || !session.data){
30 | redirect("/sign-in")
31 | }
32 |
33 | const name = session.data.user?.name!
34 | const imageUrl = session.data.user?.image!
35 |
36 | return (
37 |
38 |
39 |
40 |
41 |
42 | {name.charAt(0).toUpperCase()}
43 |
44 |
45 |
46 |
47 |
48 |
49 | Billing
50 |
51 |
52 | signOut()}>
53 |
54 | Log out
55 |
56 |
57 |
58 | )
59 | }
60 |
--------------------------------------------------------------------------------
/src/features/auth/hooks/use-sign-up.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from "@tanstack/react-query";
2 | import { toast } from "sonner";
3 | import { InferRequestType, InferResponseType } from "hono";
4 |
5 | import { client } from "@/lib/hono";
6 |
7 | type ResponseType = InferResponseType<(typeof client.api.users)["$post"]>;
8 | type RequestType = InferRequestType<(typeof client.api.users)["$post"]>["json"];
9 |
10 | export const useSignUp = () => {
11 | const mutation = useMutation({
12 | mutationFn: async (json) => {
13 | const response = await client.api.users.$post({ json });
14 |
15 | if (!response.ok) {
16 | throw new Error("Something went wrong");
17 | }
18 |
19 | return await response.json();
20 | },
21 | onSuccess: () => {
22 | toast.success("User created successfully");
23 | },
24 | });
25 |
26 | return mutation;
27 | };
28 |
--------------------------------------------------------------------------------
/src/features/auth/utils.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 | import { auth } from "@/auth";
3 |
4 | export const protectServer = async () => {
5 | const session = await auth()
6 |
7 | if(!session){
8 | redirect("/api/auth/signin")
9 | }
10 | }
--------------------------------------------------------------------------------
/src/features/editor/components/ai-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { ActiveTool, Editor } from "@/features/editor/types";
2 | import { cn } from "@/lib/utils";
3 | import { ToolSidebarHeader } from "@/features/editor/components/tool-sidebar-header";
4 | import { ToolSidebarClose } from "@/features/editor/components/tool-sidebar-close";
5 | import { Textarea } from "@/components/ui/textarea";
6 | import { Button } from "@/components/ui/button";
7 |
8 | import { ScrollArea } from "@/components/ui/scroll-area";
9 | import { useGenerateImage } from "@/features/ai/api/use-generate-image";
10 | import { useState } from "react";
11 | import { usePaywall } from "@/features/subscriptions/hooks/use-paywall";
12 |
13 | interface AiSidebarProps {
14 | editor: Editor | undefined;
15 | activeTool: ActiveTool;
16 | onChangeActiveTool: (tool: ActiveTool) => void;
17 | }
18 |
19 | export const AiSidebar = ({ editor, activeTool, onChangeActiveTool }: AiSidebarProps) => {
20 | const [value, setValue] = useState("");
21 |
22 | const mutation = useGenerateImage();
23 |
24 | const { shouldBlock, triggerPaywall } = usePaywall();
25 |
26 | const onSubmit = (e: React.FormEvent) => {
27 | e.preventDefault();
28 |
29 | if (shouldBlock) {
30 | triggerPaywall();
31 | return;
32 | }
33 |
34 | mutation.mutate(
35 | { prompt: value },
36 | {
37 | // @ts-ignore
38 | onSuccess: ({ data }) => {
39 | editor?.addImage(data);
40 | },
41 | }
42 | );
43 | };
44 |
45 | const onClose = () => {
46 | onChangeActiveTool("select");
47 | };
48 |
49 | return (
50 |
78 | );
79 | };
80 |
--------------------------------------------------------------------------------
/src/features/editor/components/color-picker.tsx:
--------------------------------------------------------------------------------
1 | import { ChromePicker, CirclePicker } from "react-color";
2 |
3 | import { colors } from "@/features/editor/types";
4 | import { rgbaObjectToString } from "@/features/editor/utils";
5 |
6 | interface ColorPickerProps {
7 | value: string;
8 | onChange: (value: string) => void;
9 | }
10 |
11 | export const ColorPicker = ({ value, onChange }: ColorPickerProps) => {
12 | return (
13 |
14 | {
17 | const formattedValue = rgbaObjectToString(color.rgb);
18 | onChange(formattedValue);
19 | }}
20 | className="border rounded-lg"
21 | />
22 | {
26 | const formattedValue = rgbaObjectToString(color.rgb);
27 | onChange(formattedValue);
28 | }}
29 | />
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/src/features/editor/components/draw-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { ActiveTool, Editor, STROKE_COLOR, STROKE_WIDTH } from "@/features/editor/types";
2 | import { ToolSidebarClose } from "@/features/editor/components/tool-sidebar-close";
3 | import { ToolSidebarHeader } from "@/features/editor/components/tool-sidebar-header";
4 | import { ColorPicker } from "@/features/editor/components/color-picker";
5 |
6 | import { cn } from "@/lib/utils";
7 | import { Label } from "@/components/ui/label";
8 | import { Slider } from "@/components/ui/slider";
9 | import { ScrollArea } from "@/components/ui/scroll-area";
10 |
11 | interface DrawSidebarProps {
12 | editor: Editor | undefined;
13 | activeTool: ActiveTool;
14 | onChangeActiveTool: (tool: ActiveTool) => void;
15 | }
16 |
17 | export const DrawSidebar = ({ editor, activeTool, onChangeActiveTool }: DrawSidebarProps) => {
18 | const colorValue = editor?.getActiveStrokeColor() || STROKE_COLOR;
19 | const widthValue = editor?.getActiveStrokeWidth() || STROKE_WIDTH;
20 |
21 | const onClose = () => {
22 | editor?.disableDrawingMode();
23 | onChangeActiveTool("select");
24 | };
25 |
26 | const onColorChange = (value: string) => {
27 | editor?.changeStrokeColor(value);
28 | };
29 |
30 | const onWidthChange = (value: number) => {
31 | editor?.changeStrokeWidth(value);
32 | };
33 |
34 | return (
35 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/src/features/editor/components/editor.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useCallback, useEffect, useRef, useState } from "react";
4 | import { useEditor } from "@/features/editor/hooks/use-editor";
5 | import debounce from "lodash.debounce";
6 | import { Navbar } from "./navbar";
7 | import { fabric } from "fabric";
8 | import { Sidebar } from "./sidebar";
9 | import { Toolbar } from "./toolbar";
10 | import { Footer } from "./footer";
11 | import { ActiveTool, selectionDependentTools } from "@/features/editor/types";
12 | import { ShapeSidebar } from "./shape-sidebar";
13 | import { FillColorSidebar } from "./fill-color-sidebar";
14 | import { StrokeColorSidebar } from "./stroke-color-sidebar";
15 | import { StrokeWidthSidebar } from "./stroke-width-sidebar";
16 | import { OpacitySidebar } from "./opacity-sidebar";
17 | import { TextSidebar } from "./text-sidebar";
18 | import { FontSidebar } from "./font-sidebar";
19 | import { ImageSidebar } from "./image-sidebar";
20 | import { FilterSidebar } from "./filter-sidebar";
21 | import { AiSidebar } from "./ai-sidebar";
22 | import { RemoveBgSidebar } from "./remove-bg-sidebar";
23 | import { DrawSidebar } from "./draw-sidebar";
24 | import { SettingsSidebar } from "./settings-sidebar";
25 | import { ResponseType } from "@/features/projects/api/use-get-project";
26 | import { useUpdateProject } from "@/features/projects/api/use-update-project";
27 | import { TemplateSidebar } from "./template-sidebar";
28 |
29 | interface EditorProps {
30 | initialData: ResponseType["data"];
31 | }
32 |
33 | export const Editor = ({ initialData }: EditorProps) => {
34 | const { mutate } = useUpdateProject(initialData.id);
35 |
36 | // eslint-disable-next-line react-hooks/exhaustive-deps
37 | const debouncedSave = useCallback(
38 | debounce((values: { json: string; height: number; width: number }) => {
39 | mutate(values);
40 | }, 500),
41 | [mutate]
42 | );
43 |
44 | const [activeTool, setActiveTool] = useState("select");
45 |
46 | const onClearSelection = useCallback(() => {
47 | if (selectionDependentTools.includes(activeTool)) {
48 | setActiveTool("select");
49 | }
50 | }, [activeTool]);
51 |
52 | const { init, editor } = useEditor({
53 | defaultState: initialData.json,
54 | defaultHeight: initialData.height,
55 | defaultWidth: initialData.width,
56 | clearSelectionCallback: onClearSelection,
57 | saveCallback: debouncedSave,
58 | });
59 |
60 | const onChangeActiveTool = useCallback(
61 | (tool: ActiveTool) => {
62 | if (tool === "draw") {
63 | editor?.enableDrawingMode();
64 | }
65 |
66 | if (activeTool === "draw") {
67 | editor?.disableDrawingMode();
68 | }
69 |
70 | if (tool === activeTool) {
71 | return setActiveTool("select");
72 | }
73 |
74 | setActiveTool(tool);
75 | },
76 | [activeTool, editor]
77 | );
78 |
79 | const canvasRef = useRef(null);
80 | const containerRef = useRef(null);
81 |
82 | useEffect(() => {
83 | const canvas = new fabric.Canvas(canvasRef.current, {
84 | controlsAboveOverlay: true,
85 | preserveObjectStacking: true,
86 | });
87 |
88 | init({
89 | initialCanvas: canvas,
90 | initialContainer: containerRef.current!,
91 | });
92 |
93 | return () => {
94 | canvas.dispose();
95 | };
96 | }, [init]);
97 |
98 | return (
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 | );
132 | };
133 |
--------------------------------------------------------------------------------
/src/features/editor/components/fill-color-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { ActiveTool, Editor, FILL_COLOR } from "@/features/editor/types";
2 | import { cn } from "@/lib/utils";
3 | import { ToolSidebarHeader } from "@/features/editor/components/tool-sidebar-header";
4 | import { ToolSidebarClose } from "@/features/editor/components/tool-sidebar-close";
5 | import { ColorPicker } from "@/features/editor/components/color-picker";
6 |
7 | import { ScrollArea } from "@/components/ui/scroll-area";
8 |
9 | interface FillColorSidebarProps {
10 | editor: Editor | undefined;
11 | activeTool: ActiveTool;
12 | onChangeActiveTool: (tool: ActiveTool) => void;
13 | }
14 |
15 | export const FillColorSidebar = ({ editor, activeTool, onChangeActiveTool }: FillColorSidebarProps) => {
16 | const value = editor?.getActiveFillColor() || FILL_COLOR;
17 |
18 | const onClose = () => {
19 | onChangeActiveTool("select");
20 | };
21 |
22 | const onChange = (color: string) => {
23 | editor?.changeFillColor(color);
24 | };
25 |
26 | return (
27 |
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/src/features/editor/components/filter-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { ActiveTool, Editor, filters } from "@/features/editor/types";
2 | import { cn } from "@/lib/utils";
3 | import { ToolSidebarHeader } from "@/features/editor/components/tool-sidebar-header";
4 | import { ToolSidebarClose } from "@/features/editor/components/tool-sidebar-close";
5 |
6 | import { ScrollArea } from "@/components/ui/scroll-area";
7 | import { Button } from "@/components/ui/button";
8 |
9 | interface FilterSidebarProps {
10 | editor: Editor | undefined;
11 | activeTool: ActiveTool;
12 | onChangeActiveTool: (tool: ActiveTool) => void;
13 | }
14 |
15 | export const FilterSidebar = ({ editor, activeTool, onChangeActiveTool }: FilterSidebarProps) => {
16 | const onClose = () => {
17 | onChangeActiveTool("select");
18 | };
19 |
20 | return (
21 |
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/src/features/editor/components/font-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { ActiveTool, Editor, fonts } from "@/features/editor/types";
2 | import { cn } from "@/lib/utils";
3 | import { ToolSidebarHeader } from "@/features/editor/components/tool-sidebar-header";
4 | import { ToolSidebarClose } from "@/features/editor/components/tool-sidebar-close";
5 |
6 | import { ScrollArea } from "@/components/ui/scroll-area";
7 | import { Button } from "@/components/ui/button";
8 |
9 | interface FontSidebarProps {
10 | editor: Editor | undefined;
11 | activeTool: ActiveTool;
12 | onChangeActiveTool: (tool: ActiveTool) => void;
13 | }
14 |
15 | export const FontSidebar = ({ editor, activeTool, onChangeActiveTool }: FontSidebarProps) => {
16 | const value = editor?.getActiveFontFamily();
17 |
18 | const onClose = () => {
19 | onChangeActiveTool("select");
20 | };
21 |
22 | return (
23 |
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/src/features/editor/components/font-size-input.tsx:
--------------------------------------------------------------------------------
1 | import { Minus, Plus } from "lucide-react";
2 |
3 | import { Input } from "@/components/ui/input";
4 | import { Button } from "@/components/ui/button";
5 |
6 | interface FontSizeInputProps {
7 | value: number;
8 | onChange: (value: number) => void;
9 | }
10 |
11 | export const FontSizeInput = ({ value, onChange }: FontSizeInputProps) => {
12 | const increment = () => onChange(value + 1);
13 | const decrement = () => onChange(value - 1);
14 |
15 | const handleChange = (e: React.ChangeEvent) => {
16 | const value = Number(e.target.value);
17 | onChange(value);
18 | };
19 |
20 | return (
21 |
22 |
25 |
26 |
29 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/src/features/editor/components/footer.tsx:
--------------------------------------------------------------------------------
1 | import { Hint } from "@/components/hint";
2 | import { Button } from "@/components/ui/button";
3 | import { Minimize, ZoomIn, ZoomOut } from "lucide-react";
4 | import { Editor } from "@/features/editor/types";
5 |
6 | interface FooterProps {
7 | editor: Editor | undefined;
8 | }
9 |
10 | export const Footer = ({ editor }: FooterProps) => {
11 | return (
12 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/src/features/editor/components/image-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import Link from "next/link";
3 | import { AlertTriangle, Loader } from "lucide-react";
4 |
5 | import { ActiveTool, Editor } from "@/features/editor/types";
6 | import { ToolSidebarClose } from "@/features/editor/components/tool-sidebar-close";
7 |
8 | import { cn } from "@/lib/utils";
9 | import { ScrollArea } from "@/components/ui/scroll-area";
10 |
11 | import { useGetImages } from "@/features/images/api/use-get-imgaes";
12 | import { ToolSidebarHeader } from "./tool-sidebar-header";
13 | import { UploadButton } from "@/lib/uploadthing";
14 |
15 | interface ImageSidebarProps {
16 | editor: Editor | undefined;
17 | activeTool: ActiveTool;
18 | onChangeActiveTool: (tool: ActiveTool) => void;
19 | }
20 |
21 | export const ImageSidebar = ({ editor, activeTool, onChangeActiveTool }: ImageSidebarProps) => {
22 | const { data, isLoading, isError } = useGetImages();
23 | const onClose = () => {
24 | onChangeActiveTool("select");
25 | };
26 |
27 | return (
28 |
94 | );
95 | };
96 |
--------------------------------------------------------------------------------
/src/features/editor/components/logo.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import Image from "next/image";
3 |
4 | export const Logo = () => {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/src/features/editor/components/navbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useFilePicker } from 'use-file-picker'
4 |
5 | import { ChevronDown, Download, Loader, MousePointerClick, Redo2, Undo2 } from "lucide-react";
6 | import { CiFileOn } from "react-icons/ci";
7 |
8 | import { Logo } from "./logo";
9 |
10 | import { ActiveTool, Editor } from "@/features/editor/types";
11 |
12 | import { Hint } from "@/components/hint";
13 | import { Button } from "@/components/ui/button";
14 | import { Separator } from "@/components/ui/separator";
15 |
16 | import {
17 | DropdownMenu,
18 | DropdownMenuItem,
19 | DropdownMenuContent,
20 | DropdownMenuTrigger,
21 | } from "@/components/ui/dropdown-menu";
22 | import { BsCloudCheck, BsCloudSlash } from "react-icons/bs";
23 | import { cn } from "@/lib/utils";
24 | import { UserButton } from '@/features/auth/components/user-button';
25 | import { useMutationState } from '@tanstack/react-query';
26 |
27 | interface NavbarProps {
28 | id: string;
29 | editor: Editor | undefined;
30 | activeTool: ActiveTool;
31 | onChangeActiveTool: (tool: ActiveTool) => void;
32 | }
33 |
34 | export const Navbar = ({ id, editor, activeTool, onChangeActiveTool }: NavbarProps) => {
35 | const data = useMutationState({
36 | filters: {
37 | mutationKey: ["project",{id}],
38 | exact: true
39 | },
40 | select: (mutation) => mutation.state.status
41 | })
42 |
43 | const currentStatus = data[data.length - 1]
44 |
45 | const isError = currentStatus === "error"
46 | const isPending = currentStatus === "pending"
47 |
48 | const { openFilePicker } = useFilePicker({
49 | accept:".json",
50 | onFilesSuccessfullySelected:({plainFiles}:any)=>{
51 | if(plainFiles && plainFiles.length > 0){
52 | const file = plainFiles[0]
53 | const reader = new FileReader()
54 | reader.readAsText(file,"UTF-8")
55 | reader.onload = ()=>{
56 | editor?.loadFromJson(reader.result as string)
57 | }
58 | }
59 | }
60 | })
61 |
62 | return (
63 |
165 | );
166 | };
167 |
--------------------------------------------------------------------------------
/src/features/editor/components/opacity-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { ActiveTool, Editor } from "@/features/editor/types";
2 | import { cn } from "@/lib/utils";
3 | import { ToolSidebarHeader } from "@/features/editor/components/tool-sidebar-header";
4 | import { ToolSidebarClose } from "@/features/editor/components/tool-sidebar-close";
5 |
6 | import { ScrollArea } from "@/components/ui/scroll-area";
7 | import { Slider } from "@/components/ui/slider";
8 | import { useEffect, useMemo, useState } from "react";
9 |
10 | interface OpacitySidebarProps {
11 | editor: Editor | undefined;
12 | activeTool: ActiveTool;
13 | onChangeActiveTool: (tool: ActiveTool) => void;
14 | }
15 |
16 | export const OpacitySidebar = ({ editor, activeTool, onChangeActiveTool }: OpacitySidebarProps) => {
17 | const initialValue = editor?.getActiveOpacity() || 1;
18 |
19 | const selectedObject = useMemo(() => editor?.selectedObjects[0], [editor?.selectedObjects]);
20 |
21 | useEffect(() => {
22 | if (selectedObject) {
23 | setOpacity(selectedObject.get("opacity") || 1);
24 | }
25 | }, [selectedObject]);
26 |
27 | const [opacity, setOpacity] = useState(initialValue);
28 |
29 | const onClose = () => {
30 | onChangeActiveTool("select");
31 | };
32 |
33 | const onChange = (value: number) => {
34 | editor?.changeOpacity(value);
35 | setOpacity(value);
36 | };
37 |
38 | return (
39 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/src/features/editor/components/remove-bg-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { ActiveTool, Editor } from "@/features/editor/types";
2 | import { cn } from "@/lib/utils";
3 | import { ToolSidebarHeader } from "@/features/editor/components/tool-sidebar-header";
4 | import { ToolSidebarClose } from "@/features/editor/components/tool-sidebar-close";
5 | import { Button } from "@/components/ui/button";
6 |
7 | import Image from "next/image";
8 |
9 | import { ScrollArea } from "@/components/ui/scroll-area";
10 | import { useGenerateImage } from "@/features/ai/api/use-generate-image";
11 | import { useState } from "react";
12 | import { AlertTriangle } from "lucide-react";
13 | import { useRemoveBackground } from "@/features/ai/api/use-remove-background";
14 | import { usePaywall } from "@/features/subscriptions/hooks/use-paywall";
15 |
16 | interface RemoveBgSidebarProps {
17 | editor: Editor | undefined;
18 | activeTool: ActiveTool;
19 | onChangeActiveTool: (tool: ActiveTool) => void;
20 | }
21 |
22 | export const RemoveBgSidebar = ({ editor, activeTool, onChangeActiveTool }: RemoveBgSidebarProps) => {
23 | const mutation = useRemoveBackground();
24 |
25 | const { shouldBlock, triggerPaywall } = usePaywall();
26 |
27 | const selectedObject = editor?.selectedObjects[0];
28 | // @ts-ignore
29 | const imageSrc = selectedObject?._originalElement?.currentSrc;
30 |
31 | const onClose = () => {
32 | onChangeActiveTool("select");
33 | };
34 |
35 | const onClick = () => {
36 | if (shouldBlock) {
37 | triggerPaywall();
38 | return;
39 | }
40 | mutation.mutate(
41 | {
42 | image: imageSrc,
43 | },
44 | {
45 | // @ts-ignore
46 | onSuccess: ({ data }) => {
47 | editor?.addImage(data);
48 | },
49 | }
50 | );
51 | };
52 |
53 | return (
54 |
86 | );
87 | };
88 |
--------------------------------------------------------------------------------
/src/features/editor/components/settings-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { ActiveTool, Editor, FILL_COLOR } from "@/features/editor/types";
2 | import { cn } from "@/lib/utils";
3 | import { ToolSidebarHeader } from "@/features/editor/components/tool-sidebar-header";
4 | import { ToolSidebarClose } from "@/features/editor/components/tool-sidebar-close";
5 | import { ColorPicker } from "@/features/editor/components/color-picker";
6 |
7 | import { ScrollArea } from "@/components/ui/scroll-area";
8 | import { useEffect, useMemo, useState } from "react";
9 | import { Label } from "@/components/ui/label";
10 | import { Input } from "@/components/ui/input";
11 | import { Button } from "@/components/ui/button";
12 |
13 | interface SettingsSidebarProps {
14 | editor: Editor | undefined;
15 | activeTool: ActiveTool;
16 | onChangeActiveTool: (tool: ActiveTool) => void;
17 | }
18 |
19 | export const SettingsSidebar = ({ editor, activeTool, onChangeActiveTool }: SettingsSidebarProps) => {
20 | const workspace = editor?.getWorkspace();
21 |
22 | const initialWidth = useMemo(() => `${workspace?.width || 0}`, [workspace]);
23 | const initialHeight = useMemo(() => `${workspace?.height || 0}`, [workspace]);
24 | const initialBackground = useMemo(() => workspace?.fill || "#ffffff", [workspace]);
25 |
26 | const [width, setWidth] = useState(initialWidth);
27 | const [height, setHeight] = useState(initialHeight);
28 | const [background, setBackground] = useState(initialBackground);
29 |
30 | useEffect(() => {
31 | setWidth(initialWidth);
32 | setHeight(initialHeight);
33 | setBackground(initialBackground);
34 | }, [initialWidth, initialHeight, initialBackground]);
35 |
36 | const onClose = () => {
37 | onChangeActiveTool("select");
38 | };
39 |
40 | const changeBackground = (value: string) => {
41 | setBackground(value);
42 | editor?.changeBackground(value);
43 | };
44 |
45 | const onSubmit = (e: React.FormEvent) => {
46 | e.preventDefault();
47 | editor?.changeSize({ width: parseInt(width), height: parseInt(height) });
48 | };
49 |
50 | return (
51 |
78 | );
79 | };
80 |
--------------------------------------------------------------------------------
/src/features/editor/components/shape-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { ActiveTool, Editor } from "@/features/editor/types";
2 | import { cn } from "@/lib/utils";
3 | import { ToolSidebarHeader } from "@/features/editor/components/tool-sidebar-header";
4 | import { ToolSidebarClose } from "@/features/editor/components/tool-sidebar-close";
5 | import { ScrollArea } from "@/components/ui/scroll-area";
6 | import { ShapeTool } from "./shape-tool";
7 | import { FaCircle, FaSquare, FaSquareFull } from "react-icons/fa";
8 | import { IoTriangle } from "react-icons/io5";
9 | import { FaDiamond } from "react-icons/fa6";
10 |
11 | interface ShapeSidebarProps {
12 | editor: Editor | undefined;
13 | activeTool: ActiveTool;
14 | onChangeActiveTool: (tool: ActiveTool) => void;
15 | }
16 |
17 | export const ShapeSidebar = ({ editor, activeTool, onChangeActiveTool }: ShapeSidebarProps) => {
18 | const onClose = () => {
19 | onChangeActiveTool("select");
20 | };
21 |
22 | return (
23 |
42 | );
43 | };
44 |
--------------------------------------------------------------------------------
/src/features/editor/components/shape-tool.tsx:
--------------------------------------------------------------------------------
1 | import type { IconType } from "react-icons";
2 | import type { LucideIcon } from "lucide-react";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | interface ShapeToolProps {
7 | onClick: () => void;
8 | icon: IconType | LucideIcon;
9 | iconClassName?: string;
10 | }
11 |
12 | export const ShapeTool = ({ onClick, icon: Icon, iconClassName }: ShapeToolProps) => {
13 | return (
14 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/features/editor/components/sidebar-item.tsx:
--------------------------------------------------------------------------------
1 | import type { LucideIcon } from "lucide-react";
2 |
3 | import { cn } from "@/lib/utils";
4 | import { Button } from "@/components/ui/button";
5 |
6 | interface SidebarItemProps {
7 | icon: LucideIcon;
8 | label: string;
9 | isActive?: boolean;
10 | onClick?: () => void;
11 | }
12 |
13 | export const SidebarItem = ({ icon: Icon, label, isActive, onClick }: SidebarItemProps) => {
14 | return (
15 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/src/features/editor/components/sidebar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { LayoutTemplate, ImageIcon, Settings, Shapes, Sparkles, Type, Pencil } from "lucide-react";
4 | import { SidebarItem } from "./sidebar-item";
5 | import { ActiveTool } from "@/features/editor/types";
6 |
7 | interface SidebarProps {
8 | activeTool: ActiveTool;
9 | onChangeActiveTool: (tool: ActiveTool) => void;
10 | }
11 |
12 | export const Sidebar = ({ activeTool, onChangeActiveTool }: SidebarProps) => {
13 | return (
14 |
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/src/features/editor/components/stroke-color-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { ActiveTool, Editor, STROKE_COLOR } from "@/features/editor/types";
2 | import { cn } from "@/lib/utils";
3 | import { ToolSidebarHeader } from "@/features/editor/components/tool-sidebar-header";
4 | import { ToolSidebarClose } from "@/features/editor/components/tool-sidebar-close";
5 | import { ColorPicker } from "@/features/editor/components/color-picker";
6 |
7 | import { ScrollArea } from "@/components/ui/scroll-area";
8 |
9 | interface StrokeColorSidebarProps {
10 | editor: Editor | undefined;
11 | activeTool: ActiveTool;
12 | onChangeActiveTool: (tool: ActiveTool) => void;
13 | }
14 |
15 | export const StrokeColorSidebar = ({ editor, activeTool, onChangeActiveTool }: StrokeColorSidebarProps) => {
16 | const value = editor?.getActiveStrokeColor() || STROKE_COLOR;
17 |
18 | const onClose = () => {
19 | onChangeActiveTool("select");
20 | };
21 |
22 | const onChange = (color: string) => {
23 | editor?.changeStrokeColor(color);
24 | };
25 |
26 | return (
27 |
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/src/features/editor/components/stroke-width-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { ActiveTool, Editor, STROKE_DASH_ARRAY, STROKE_WIDTH } from "@/features/editor/types";
2 | import { cn } from "@/lib/utils";
3 | import { ToolSidebarHeader } from "@/features/editor/components/tool-sidebar-header";
4 | import { ToolSidebarClose } from "@/features/editor/components/tool-sidebar-close";
5 |
6 | import { ScrollArea } from "@/components/ui/scroll-area";
7 | import { Label } from "@/components/ui/label";
8 | import { Slider } from "@/components/ui/slider";
9 | import { Button } from "@/components/ui/button";
10 |
11 | interface StrokeWidthSidebarProps {
12 | editor: Editor | undefined;
13 | activeTool: ActiveTool;
14 | onChangeActiveTool: (tool: ActiveTool) => void;
15 | }
16 |
17 | export const StrokeWidthSidebar = ({ editor, activeTool, onChangeActiveTool }: StrokeWidthSidebarProps) => {
18 | const widthValue = editor?.getActiveStrokeWidth() || STROKE_WIDTH;
19 | const typeValue = editor?.getActiveStrokeDashArray() || STROKE_DASH_ARRAY;
20 |
21 | const onClose = () => {
22 | onChangeActiveTool("select");
23 | };
24 |
25 | const onChangeStrokeWidth = (width: number) => {
26 | editor?.changeStrokeWidth(width);
27 | };
28 |
29 | const onChangeStrokeType = (value: number[]) => {
30 | editor?.changeStrokeDashArray(value);
31 | };
32 |
33 | return (
34 |
76 | );
77 | };
78 |
--------------------------------------------------------------------------------
/src/features/editor/components/template-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | import { AlertTriangle, Crown, Loader } from "lucide-react";
4 |
5 | import { ActiveTool, Editor } from "@/features/editor/types";
6 | import { ToolSidebarClose } from "@/features/editor/components/tool-sidebar-close";
7 |
8 | import { cn } from "@/lib/utils";
9 | import { ScrollArea } from "@/components/ui/scroll-area";
10 |
11 | import { ResponseType, useGetTemplates } from "@/features/projects/api/use-get-templates";
12 | import { ToolSidebarHeader } from "./tool-sidebar-header";
13 | import { useConfirm } from "@/hooks/use-confirm";
14 | import { usePaywall } from "@/features/subscriptions/hooks/use-paywall";
15 |
16 | interface TemplateSidebarProps {
17 | editor: Editor | undefined;
18 | activeTool: ActiveTool;
19 | onChangeActiveTool: (tool: ActiveTool) => void;
20 | }
21 |
22 | export const TemplateSidebar = ({ editor, activeTool, onChangeActiveTool }: TemplateSidebarProps) => {
23 | const [ConfirmDialog, confirm] = useConfirm(
24 | "Are you sure",
25 | "You are about to replace the current project with this template."
26 | );
27 |
28 | const { shouldBlock, triggerPaywall } = usePaywall();
29 |
30 | const { data, isLoading, isError } = useGetTemplates({
31 | limit: "20",
32 | page: "1",
33 | });
34 | const onClose = () => {
35 | onChangeActiveTool("select");
36 | };
37 |
38 | const onClick = async (template: ResponseType["data"][0]) => {
39 | if (template.isPro && shouldBlock) {
40 | triggerPaywall();
41 | return;
42 | }
43 | const ok = await confirm();
44 | if (ok) {
45 | editor?.loadFromJson(template.json);
46 | }
47 | };
48 | return (
49 |
105 | );
106 | };
107 |
--------------------------------------------------------------------------------
/src/features/editor/components/text-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { ActiveTool, Editor } from "@/features/editor/types";
2 | import { cn } from "@/lib/utils";
3 | import { ToolSidebarHeader } from "@/features/editor/components/tool-sidebar-header";
4 | import { ToolSidebarClose } from "@/features/editor/components/tool-sidebar-close";
5 |
6 | import { ScrollArea } from "@/components/ui/scroll-area";
7 | import { Button } from "@/components/ui/button";
8 |
9 | interface TextSidebarProps {
10 | editor: Editor | undefined;
11 | activeTool: ActiveTool;
12 | onChangeActiveTool: (tool: ActiveTool) => void;
13 | }
14 |
15 | export const TextSidebar = ({ editor, activeTool, onChangeActiveTool }: TextSidebarProps) => {
16 | const onClose = () => {
17 | onChangeActiveTool("select");
18 | };
19 |
20 | return (
21 |
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/src/features/editor/components/tool-sidebar-close.tsx:
--------------------------------------------------------------------------------
1 | import { ChevronsLeft } from "lucide-react";
2 |
3 | interface ToolSidebarCloseProps {
4 | onClick: () => void;
5 | }
6 |
7 | export const ToolSidebarClose = ({ onClick }: ToolSidebarCloseProps) => {
8 | return (
9 |
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/src/features/editor/components/tool-sidebar-header.tsx:
--------------------------------------------------------------------------------
1 | interface ToolSidebarHeaderProps {
2 | title: string;
3 | description?: string;
4 | }
5 |
6 | export const ToolSidebarHeader = ({ title, description }: ToolSidebarHeaderProps) => {
7 | return (
8 |
9 |
{title}
10 | {description &&
{description}
}
11 |
12 | );
13 | };
14 |
--------------------------------------------------------------------------------
/src/features/editor/hooks/use-auto-resize.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from "fabric";
2 | import { useCallback, useEffect } from "react";
3 |
4 | interface UseAutoResize {
5 | canvas: fabric.Canvas | null;
6 | container: HTMLDivElement | null;
7 | }
8 |
9 | export const useAutoResize = ({ canvas, container }: UseAutoResize) => {
10 | const autoZoom = useCallback(() => {
11 | if (!canvas || !container) return;
12 |
13 | const width = container.offsetWidth;
14 | const height = container.offsetHeight;
15 |
16 | canvas.setWidth(width);
17 | canvas.setHeight(height);
18 |
19 | const center = canvas.getCenter();
20 | const zoomRatio = 0.85;
21 |
22 | const localWorkspace = canvas.getObjects().find((obj) => obj.name === "clip");
23 |
24 | // @ts-expect-error TODO
25 | const scale = fabric.util.findScaleToFit(localWorkspace, {
26 | width,
27 | height,
28 | });
29 |
30 | const zoom = scale * zoomRatio;
31 | canvas.setViewportTransform(fabric.iMatrix.concat());
32 | canvas.zoomToPoint(new fabric.Point(center.left, center.top), zoom);
33 | if (!localWorkspace) return;
34 |
35 | const workspaceCenter = localWorkspace.getCenterPoint();
36 | const viewTransform = canvas.viewportTransform;
37 |
38 | if (canvas.width === undefined || canvas.height === undefined || !viewTransform) {
39 | return;
40 | }
41 |
42 | viewTransform[4] = canvas.width / 2 - workspaceCenter.x * viewTransform[0];
43 | viewTransform[5] = canvas.height / 2 - workspaceCenter.y * viewTransform[3];
44 |
45 | canvas.setViewportTransform(viewTransform);
46 |
47 | localWorkspace.clone((cloned: fabric.Rect) => {
48 | canvas.clipPath = cloned;
49 | canvas.requestRenderAll();
50 | });
51 | }, [canvas, container]);
52 |
53 | useEffect(() => {
54 | let resizeObserver: ResizeObserver | null = null;
55 | if (canvas && container) {
56 | resizeObserver = new ResizeObserver(() => {
57 | autoZoom();
58 | });
59 | resizeObserver.observe(container);
60 | }
61 | return () => {
62 | if (resizeObserver) {
63 | resizeObserver.disconnect();
64 | }
65 | };
66 | }, [canvas, container, autoZoom]);
67 |
68 | return { autoZoom };
69 | };
70 |
--------------------------------------------------------------------------------
/src/features/editor/hooks/use-canvas-events.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from "fabric";
2 | import { useEffect } from "react";
3 |
4 | interface UseCanvasEventsProps {
5 | save: () => void;
6 | canvas: fabric.Canvas | null;
7 | setSelectedObjects: (objects: fabric.Object[]) => void;
8 | clearSelectionCallback?: () => void;
9 | }
10 |
11 | export const useCanvasEvents = ({ save, canvas, setSelectedObjects, clearSelectionCallback }: UseCanvasEventsProps) => {
12 | useEffect(() => {
13 | if (canvas) {
14 | canvas.on("object:added", () => save());
15 | canvas.on("object:removed", () => save());
16 | canvas.on("object:modified", () => save());
17 | canvas.on("selection:created", (e) => {
18 | setSelectedObjects(e.selected || []);
19 | });
20 | canvas.on("selection:updated", (e) => {
21 | setSelectedObjects(e.selected || []);
22 | });
23 | canvas.on("selection:cleared", () => {
24 | setSelectedObjects([]);
25 | clearSelectionCallback?.();
26 | });
27 | }
28 | return () => {
29 | if (canvas) {
30 | canvas.off("object:added");
31 | canvas.off("object:modified");
32 | canvas.off("object:removed");
33 | canvas.off("selection:created");
34 | canvas.off("selection:updated");
35 | canvas.off("selection:cleared");
36 | }
37 | };
38 | }, [save, canvas, clearSelectionCallback, setSelectedObjects]);
39 | };
40 |
--------------------------------------------------------------------------------
/src/features/editor/hooks/use-clipboard.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from "fabric";
2 | import { useCallback, useRef } from "react";
3 |
4 | interface UseClipboardProps {
5 | canvas: fabric.Canvas | null;
6 | }
7 |
8 | export const useClipboard = ({ canvas }: UseClipboardProps) => {
9 | const clipboard = useRef(null);
10 |
11 | const copy = useCallback(() => {
12 | canvas?.getActiveObject()?.clone((cloned: any) => {
13 | clipboard.current = cloned;
14 | });
15 | }, [canvas]);
16 |
17 | const paste = useCallback(() => {
18 | if (!clipboard.current) return;
19 |
20 | clipboard.current.clone((cloned: any) => {
21 | canvas?.discardActiveObject();
22 | cloned.set({
23 | left: cloned.left + 10,
24 | top: cloned.top + 10,
25 | evented: true,
26 | });
27 | if (cloned.type === "activeSelection") {
28 | cloned.canvas = canvas;
29 | cloned.forEachObject((object: any) => {
30 | canvas?.add(object);
31 | });
32 | cloned.setCoords();
33 | } else {
34 | canvas?.add(cloned);
35 | }
36 |
37 | clipboard.current.top += 10;
38 | clipboard.current.left += 10;
39 | canvas?.setActiveObject(cloned);
40 | canvas?.requestRenderAll();
41 | });
42 | }, [canvas]);
43 |
44 | return { copy, paste };
45 | };
46 |
--------------------------------------------------------------------------------
/src/features/editor/hooks/use-history.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from "fabric";
2 | import { useCallback, useRef, useState } from "react";
3 | import { JSON_KEYS } from "@/features/editor/types";
4 |
5 | interface UseHistoryProps {
6 | canvas: fabric.Canvas | null;
7 | saveCallback?: (values: { json: string; height: number; width: number }) => void;
8 | }
9 |
10 | export const useHistory = ({ canvas, saveCallback }: UseHistoryProps) => {
11 | const [historyIndex, setHistoryIndex] = useState(0);
12 | const canvasHistory = useRef([]);
13 | const skipSave = useRef(false);
14 |
15 | const canUndo = useCallback(() => historyIndex > 0, [historyIndex]);
16 | const canRedo = useCallback(() => historyIndex < canvasHistory.current.length - 1, [historyIndex]);
17 |
18 | const save = useCallback(
19 | (skip = false) => {
20 | if (!canvas) return;
21 | const currentState = canvas.toJSON(JSON_KEYS);
22 | const json = JSON.stringify(currentState);
23 |
24 | if (!skip && !skipSave.current) {
25 | canvasHistory.current.push(json);
26 | setHistoryIndex(canvasHistory.current.length - 1);
27 | }
28 |
29 | const workspace = canvas.getObjects().find((object) => object.name === "clip");
30 |
31 | const height = workspace?.height || 0;
32 | const width = workspace?.width || 0;
33 |
34 | saveCallback?.({ json, height, width });
35 | },
36 | [canvas, saveCallback]
37 | );
38 |
39 | const undo = useCallback(() => {
40 | if (canUndo()) {
41 | skipSave.current = true;
42 | canvas?.clear().renderAll();
43 |
44 | const previousIndex = historyIndex - 1;
45 | const previousState = JSON.parse(canvasHistory.current[previousIndex]);
46 | canvas?.loadFromJSON(previousState, () => {
47 | canvas?.renderAll();
48 | setHistoryIndex(previousIndex);
49 | skipSave.current = false;
50 | });
51 | }
52 | return;
53 | }, [canvas, canUndo, historyIndex]);
54 |
55 | const redo = useCallback(() => {
56 | if (canRedo()) {
57 | skipSave.current = true;
58 | canvas?.clear().renderAll();
59 |
60 | const nextIndex = historyIndex + 1;
61 | const nextState = JSON.parse(canvasHistory.current[nextIndex]);
62 | canvas?.loadFromJSON(nextState, () => {
63 | canvas?.renderAll();
64 | setHistoryIndex(nextIndex);
65 | skipSave.current = false;
66 | });
67 | }
68 | return;
69 | }, [canvas, canRedo, historyIndex]);
70 |
71 | return { save, canUndo, canRedo, undo, redo, setHistoryIndex, canvasHistory };
72 | };
73 |
--------------------------------------------------------------------------------
/src/features/editor/hooks/use-hotkeys.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from "fabric";
2 | import { useEvent } from "react-use";
3 |
4 | interface UseHotkeysProps {
5 | canvas: fabric.Canvas | null;
6 | undo: () => void;
7 | redo: () => void;
8 | save: (skip?: boolean) => void;
9 | copy: () => void;
10 | paste: () => void;
11 | }
12 |
13 | export const useHotkeys = ({ canvas, undo, redo, save, copy, paste }: UseHotkeysProps) => {
14 | useEvent("keydown", (e) => {
15 | const isCtrlKey = e.ctrlKey || e.metaKey;
16 | const isBackspace = e.key === "Backspace";
17 | const isInput = ["INPUT", "TEXTAREA"].includes(e.target?.tagName);
18 |
19 | if (isInput) return;
20 |
21 | if (isBackspace) {
22 | canvas?.remove(...canvas.getActiveObjects());
23 | canvas?.discardActiveObject();
24 | }
25 |
26 | if (isCtrlKey && e.key === "z") {
27 | e.preventDefault();
28 | undo();
29 | }
30 | if (isCtrlKey && e.key === "y") {
31 | e.preventDefault();
32 | redo();
33 | }
34 | if (isCtrlKey && e.key === "s") {
35 | e.preventDefault();
36 | save(true);
37 | }
38 | if (isCtrlKey && e.key === "c") {
39 | e.preventDefault();
40 | copy();
41 | }
42 | if (isCtrlKey && e.key === "v") {
43 | e.preventDefault();
44 | paste();
45 | }
46 | if (isCtrlKey && e.key === "a") {
47 | e.preventDefault();
48 | canvas?.discardActiveObject();
49 |
50 | const allObjects = canvas?.getObjects().filter((obj) => obj.selectable);
51 |
52 | canvas?.setActiveObject(new fabric.ActiveSelection(allObjects, { canvas }));
53 | canvas?.renderAll();
54 | }
55 | });
56 | };
57 |
--------------------------------------------------------------------------------
/src/features/editor/hooks/use-load-state.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 | import { fabric } from "fabric";
3 |
4 | import { JSON_KEYS } from "@/features/editor/types";
5 |
6 | interface UseLoadStateProps {
7 | autoZoom: () => void;
8 | canvas: fabric.Canvas | null;
9 | initialState: React.MutableRefObject;
10 | canvasHistory: React.MutableRefObject;
11 | setHistoryIndex: React.Dispatch>;
12 | }
13 |
14 | export const useLoadState = ({ autoZoom, canvas, initialState, canvasHistory, setHistoryIndex }: UseLoadStateProps) => {
15 | const initialized = useRef(false);
16 |
17 | useEffect(() => {
18 | if (!initialized.current && initialState?.current && canvas) {
19 | const data = JSON.parse(initialState.current)
20 |
21 | canvas.loadFromJSON(data, () => {
22 | const currentState = JSON.stringify(canvas.toJSON(JSON_KEYS ));
23 | canvasHistory.current = [currentState];
24 | setHistoryIndex(0);
25 | autoZoom();
26 | });
27 | initialized.current = true;
28 | }
29 | }, [autoZoom, canvas, canvasHistory, initialState, setHistoryIndex]);
30 | };
31 |
--------------------------------------------------------------------------------
/src/features/editor/hooks/use-window-events.ts:
--------------------------------------------------------------------------------
1 | import { useEvent } from "react-use";
2 |
3 | export const useWindowEvents = ()=>{
4 | useEvent("beforeunload",(event)=>{
5 | (event || window.event).returnValue = "Are you sure you want to leave?";
6 | })
7 | }
--------------------------------------------------------------------------------
/src/features/editor/types.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from "fabric";
2 | import { ITextboxOptions } from "fabric/fabric-impl";
3 | import * as material from "material-colors";
4 |
5 | export const JSON_KEYS = [
6 | "name",
7 | "gradientAngle",
8 | "selectable",
9 | "hasControls",
10 | "linkData",
11 | "editable",
12 | "extensionType",
13 | "extension",
14 | ];
15 |
16 | export const selectionDependentTools = [
17 | "fill",
18 | "font",
19 | "filter",
20 | "opacity",
21 | "remove-bg",
22 | "stroke-color",
23 | "stroke-width",
24 | ];
25 |
26 | export const filters = [
27 | "none",
28 | "polaroid",
29 | "sepia",
30 | "kodachrome",
31 | "contrast",
32 | "brightness",
33 | "greyscale",
34 | "brownie",
35 | "vintage",
36 | "technicolor",
37 | "pixelate",
38 | "invert",
39 | "blur",
40 | "sharpen",
41 | "emboss",
42 | "removecolor",
43 | "blacknwhite",
44 | "vibrance",
45 | "blendcolor",
46 | "huerotate",
47 | "resize",
48 | "saturation",
49 | "gamma",
50 | ];
51 |
52 | export const fonts = [
53 | "Arial",
54 | "Arial Black",
55 | "Verdana",
56 | "Helvetica",
57 | "Tahoma",
58 | "Trebuchet MS",
59 | "Times New Roman",
60 | "Georgia",
61 | "Garamond",
62 | "Courier New",
63 | "Brush Script MT",
64 | "Palatino",
65 | "Bookman",
66 | "Comic Sans MS",
67 | "Impact",
68 | "Lucida Sans Unicode",
69 | "Geneva",
70 | "Lucida Console",
71 | ];
72 |
73 | export const colors = [
74 | material.red["500"],
75 | material.pink["500"],
76 | material.purple["500"],
77 | material.deepPurple["500"],
78 | material.indigo["500"],
79 | material.blue["500"],
80 | material.lightBlue["500"],
81 | material.cyan["500"],
82 | material.teal["500"],
83 | material.green["500"],
84 | material.lightGreen["500"],
85 | material.lime["500"],
86 | material.yellow["500"],
87 | material.amber["500"],
88 | material.orange["500"],
89 | material.deepOrange["500"],
90 | material.brown["500"],
91 | material.blueGrey["500"],
92 | "transparent",
93 | ];
94 |
95 | export type ActiveTool =
96 | | "select"
97 | | "shapes"
98 | | "text"
99 | | "images"
100 | | "draw"
101 | | "fill"
102 | | "stroke-color"
103 | | "stroke-width"
104 | | "font"
105 | | "opacity"
106 | | "filter"
107 | | "settings"
108 | | "ai"
109 | | "remove-bg"
110 | | "templates";
111 |
112 | export const FILL_COLOR = "rgba(0, 0, 0, 1)";
113 | export const STROKE_COLOR = "rgba(0, 0, 0, 1)";
114 | export const STROKE_WIDTH = 2;
115 | export const STROKE_DASH_ARRAY = [];
116 | export const FONT_SIZE = 32;
117 | export const FONT_FAMILY = "Arial";
118 | export const FONT_WEIGHT = 400;
119 | export const FONT_STYLE = "normal";
120 | export const CIRCLE_OPTIONS = {
121 | radius: 225,
122 | left: 100,
123 | top: 100,
124 | fill: FILL_COLOR,
125 | stroke: STROKE_COLOR,
126 | strokeWidth: STROKE_WIDTH,
127 | };
128 |
129 | export const RECTANGLE_OPTIONS = {
130 | left: 100,
131 | top: 100,
132 | fill: FILL_COLOR,
133 | stroke: STROKE_COLOR,
134 | strokeWidth: STROKE_WIDTH,
135 | width: 400,
136 | height: 400,
137 | angle: 0,
138 | };
139 |
140 | export const TRIANGLE_OPTIONS = {
141 | left: 100,
142 | top: 100,
143 | fill: FILL_COLOR,
144 | stroke: STROKE_COLOR,
145 | strokeWidth: STROKE_WIDTH,
146 | width: 400,
147 | height: 400,
148 | angle: 0,
149 | };
150 |
151 | export const DIAMOND_OPTIONS = {
152 | left: 100,
153 | top: 100,
154 | fill: FILL_COLOR,
155 | stroke: STROKE_COLOR,
156 | strokeWidth: STROKE_WIDTH,
157 | width: 600,
158 | height: 600,
159 | angle: 0,
160 | };
161 |
162 | export const TEXT_OPTIONS = {
163 | type: "textbox",
164 | left: 100,
165 | top: 100,
166 | fill: FILL_COLOR,
167 | fontSize: FONT_SIZE,
168 | fontFamily: FONT_FAMILY,
169 | };
170 |
171 | export interface EditorHookProps {
172 | defaultState: string;
173 | defaultHeight: number;
174 | defaultWidth: number;
175 | clearSelectionCallback?: () => void;
176 | saveCallback?: (values: { json: string; height: number; width: number }) => void;
177 | }
178 |
179 | export type BuildEditorProps = {
180 | save: (skip?: boolean) => void;
181 | canUndo: () => boolean;
182 | canRedo: () => boolean;
183 | undo: () => void;
184 | redo: () => void;
185 | autoZoom: () => void;
186 | copy: () => void;
187 | paste: () => void;
188 | canvas: fabric.Canvas;
189 | fillColor: string;
190 | strokeColor: string;
191 | strokeWidth: number;
192 | strokeDashArray: number[];
193 | fontFamily: string;
194 | setFillColor: (color: string) => void;
195 | setStrokeColor: (color: string) => void;
196 | setStrokeWidth: (width: number) => void;
197 | setStrokeDashArray: (array: number[]) => void;
198 | setFontFamily: (family: string) => void;
199 | selectedObjects: fabric.Object[];
200 | };
201 |
202 | export interface Editor {
203 | savePng: () => void;
204 | saveSvg: () => void;
205 | saveJpg: () => void;
206 | saveJson: () => void;
207 | loadFromJson: (json: string) => void;
208 | canUndo: () => boolean;
209 | canRedo: () => boolean;
210 | onUndo: () => void;
211 | onRedo: () => void;
212 | zoomIn: () => void;
213 | zoomOut: () => void;
214 | autoZoom: () => void;
215 | getWorkspace: () => fabric.Rect | undefined;
216 | changeSize: (size: { width: number; height: number }) => void;
217 | changeBackground: (color: string) => void;
218 | enableDrawingMode: () => void;
219 | disableDrawingMode: () => void;
220 | onCopy: () => void;
221 | onPaste: () => void;
222 | changeImageFilter: (value: string) => void;
223 | addImage: (value: string) => void;
224 | delete: () => void;
225 | addText: (value: string, options?: ITextboxOptions) => void;
226 | bringForward: () => void;
227 | sendBackwards: () => void;
228 | changeOpacity: (value: number) => void;
229 | changeFontSize: (value: number) => void;
230 | changeFontStyle: (value: string) => void;
231 | changeFontLinethrough: (value: boolean) => void;
232 | changeFontUnderline: (value: boolean) => void;
233 | changeFontWeight: (value: number) => void;
234 | changeFontFamily: (value: string) => void;
235 | changeTextAlign: (value: string) => void;
236 | changeFillColor: (color: string) => void;
237 | changeStrokeColor: (color: string) => void;
238 | changeStrokeWidth: (width: number) => void;
239 | changeStrokeDashArray: (array: number[]) => void;
240 | addCircle: () => void;
241 | addSoftRectangle: () => void;
242 | addRectangle: () => void;
243 | addTriangle: () => void;
244 | addInverseTriangle: () => void;
245 | addDiamond: () => void;
246 | canvas: fabric.Canvas;
247 | getActiveOpacity: () => number;
248 | getActiveFontSize: () => number;
249 | getActiveFontStyle: () => string;
250 | getActiveFontWeight: () => number;
251 | getActiveFontLinethrough: () => boolean;
252 | getActiveFontUnderline: () => boolean;
253 | getActiveTextAlign: () => string;
254 | getActiveFontFamily: () => string;
255 | getActiveFillColor: () => string;
256 | getActiveStrokeColor: () => string;
257 | getActiveStrokeWidth: () => number;
258 | getActiveStrokeDashArray: () => number[];
259 | selectedObjects: fabric.Object[];
260 | }
261 |
--------------------------------------------------------------------------------
/src/features/editor/utils.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from "fabric";
2 | import { RGBColor } from "react-color";
3 | import { uuid } from "uuidv4";
4 |
5 | export function transformText(objects:any){
6 | if(!objects) return;
7 | objects.forEach((item:any)=>{
8 | if(item.objects){
9 | transformText(item.objects);
10 | }else{
11 | item.type === "text" && (item.type ==='textbox');
12 | }
13 | })
14 | }
15 |
16 | export function downloadFile(file:string,type:string){
17 | const archorElement = document.createElement("a");
18 | archorElement.href = file;
19 | archorElement.download = `${uuid()}.${type}`;
20 | document.body.appendChild(archorElement);
21 | archorElement.click();
22 | document.body.removeChild(archorElement);
23 | }
24 |
25 |
26 | export function isTextType(type: string | undefined) {
27 | return type === "text" || type === "itext" || type === "textbox";
28 | }
29 |
30 | export const rgbaObjectToString = (rgba: RGBColor) => `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})`;
31 |
32 | export const createFilter = (value: string) => {
33 | let effect;
34 |
35 | switch (value) {
36 | case "greyscale":
37 | effect = new fabric.Image.filters.Grayscale();
38 | break;
39 | case "polaroid":
40 | // @ts-ignore
41 | effect = new fabric.Image.filters.Polaroid();
42 | break;
43 | case "sepia":
44 | effect = new fabric.Image.filters.Sepia();
45 | break;
46 | case "kodachrome":
47 | // @ts-ignore
48 | effect = new fabric.Image.filters.Kodachrome();
49 | break;
50 | case "contrast":
51 | effect = new fabric.Image.filters.Contrast({ contrast: 0.3 });
52 | break;
53 | case "brightness":
54 | effect = new fabric.Image.filters.Brightness({ brightness: 0.8 });
55 | break;
56 | case "brownie":
57 | // @ts-ignore
58 | effect = new fabric.Image.filters.Brownie();
59 | break;
60 | case "vintage":
61 | // @ts-ignore
62 | effect = new fabric.Image.filters.Vintage();
63 | break;
64 | case "technicolor":
65 | // @ts-ignore
66 | effect = new fabric.Image.filters.Technicolor();
67 | break;
68 | case "pixelate":
69 | effect = new fabric.Image.filters.Pixelate();
70 | break;
71 | case "invert":
72 | effect = new fabric.Image.filters.Invert();
73 | break;
74 | case "blur":
75 | effect = new fabric.Image.filters.Blur();
76 | break;
77 | case "sharpen":
78 | effect = new fabric.Image.filters.Convolute({
79 | matrix: [0, -1, 0, -1, 5, -1, 0, -1, 0],
80 | });
81 | break;
82 | case "emboss":
83 | effect = new fabric.Image.filters.Convolute({
84 | matrix: [1, 1, 1, 1, 0.7, -1, -1, -1, -1],
85 | });
86 | break;
87 | case "removecolor":
88 | // @ts-ignore
89 | effect = new fabric.Image.filters.RemoveColor({
90 | threshold: 0.2,
91 | distance: 0.5,
92 | });
93 | break;
94 | case "blacknwhite":
95 | // @ts-ignore
96 | effect = new fabric.Image.filters.BlackWhite();
97 | break;
98 | case "vibrance":
99 | // @ts-ignore
100 | effect = new fabric.Image.filters.Vibrance({
101 | vibrance: 1,
102 | });
103 | break;
104 | case "blendcolor":
105 | effect = new fabric.Image.filters.BlendColor({
106 | color: "#00ff00",
107 | mode: "multiply",
108 | });
109 | break;
110 | case "huerotate":
111 | effect = new fabric.Image.filters.HueRotation({
112 | rotation: 0.5,
113 | });
114 | break;
115 | case "resize":
116 | effect = new fabric.Image.filters.Resize();
117 | break;
118 | case "gamma":
119 | // @ts-ignore
120 | effect = new fabric.Image.filters.Gamma({
121 | gamma: [1, 0.5, 2.1],
122 | });
123 | case "saturation":
124 | effect = new fabric.Image.filters.Saturation({
125 | saturation: 0.7,
126 | });
127 | break;
128 | default:
129 | effect = null;
130 | return;
131 | }
132 |
133 | return effect;
134 | };
135 |
--------------------------------------------------------------------------------
/src/features/images/api/use-get-imgaes.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 |
3 | import { client } from "@/lib/hono";
4 |
5 | export const useGetImages = () => {
6 | const query = useQuery({
7 | queryKey: ["images"],
8 | queryFn: async () => {
9 | const response = await client.api.images.$get();
10 |
11 | if (!response.ok) {
12 | throw new Error("Failed to fetch images");
13 | }
14 |
15 | const { data } = await response.json();
16 | return data;
17 | },
18 | });
19 |
20 | return query;
21 | };
22 |
--------------------------------------------------------------------------------
/src/features/projects/api/use-create-project.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { InferRequestType, InferResponseType } from "hono";
3 |
4 | import { client } from "@/lib/hono";
5 | import { toast } from "sonner";
6 |
7 | type ResponseType = InferResponseType<(typeof client.api.projects)["$post"], 200>;
8 | type RequestType = InferRequestType<(typeof client.api.projects)["$post"]>["json"];
9 |
10 | export const useCreateProject = () => {
11 |
12 | const queryClient = useQueryClient();
13 | const mutation = useMutation({
14 | mutationFn: async (json) => {
15 | const response = await client.api.projects.$post({ json });
16 | if (!response.ok) {
17 | throw new Error("Failed to create project");
18 | }
19 | return await response.json();
20 | },
21 | onSuccess: (data) => {
22 | toast.success("Project created successfully");
23 | queryClient.invalidateQueries({ queryKey: ["projects"] });
24 | },
25 | onError: () => {
26 | toast.error("Failed to create project");
27 | },
28 | });
29 |
30 | return mutation;
31 | };
32 |
--------------------------------------------------------------------------------
/src/features/projects/api/use-delete-project.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { InferRequestType, InferResponseType } from "hono";
3 |
4 | import { client } from "@/lib/hono";
5 | import { toast } from "sonner";
6 |
7 | type ResponseType = InferResponseType<(typeof client.api.projects)[":id"]["$delete"], 200>;
8 | type RequestType = InferRequestType<(typeof client.api.projects)[":id"]["$delete"]>["param"];
9 |
10 | export const useDeleteProject = () => {
11 |
12 | const queryClient = useQueryClient();
13 | const mutation = useMutation({
14 | mutationFn: async (param) => {
15 | const response = await client.api.projects[":id"].$delete({ param });
16 | if (!response.ok) {
17 | throw new Error("Failed to delete project");
18 | }
19 | return await response.json();
20 | },
21 | onSuccess: ({ data }) => {
22 | queryClient.invalidateQueries({ queryKey: ["projects"] });
23 | queryClient.invalidateQueries({ queryKey: ["project", { id: data.id }] });
24 | },
25 | onError: () => {
26 | toast.error("Failed to delete project");
27 | },
28 | });
29 |
30 | return mutation;
31 | };
32 |
--------------------------------------------------------------------------------
/src/features/projects/api/use-duplicate-project.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { InferRequestType, InferResponseType } from "hono";
3 |
4 | import { client } from "@/lib/hono";
5 | import { toast } from "sonner";
6 |
7 | type ResponseType = InferResponseType<(typeof client.api.projects)[":id"]["duplicate"]["$post"], 200>;
8 | type RequestType = InferRequestType<(typeof client.api.projects)[":id"]["duplicate"]["$post"]>["param"];
9 |
10 | export const useDuplicateProject = () => {
11 |
12 | const queryClient = useQueryClient();
13 | const mutation = useMutation({
14 | mutationFn: async (param) => {
15 | const response = await client.api.projects[":id"].duplicate.$post({ param });
16 | if (!response.ok) {
17 | throw new Error("Failed to duplicate project");
18 | }
19 | return await response.json();
20 | },
21 | onSuccess: () => {
22 | queryClient.invalidateQueries({ queryKey: ["projects"] });
23 | },
24 | onError: () => {
25 | toast.error("Failed to duplicate project");
26 | },
27 | });
28 |
29 | return mutation;
30 | };
31 |
--------------------------------------------------------------------------------
/src/features/projects/api/use-get-project.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 |
3 | import { client } from "@/lib/hono";
4 | import { InferResponseType } from "hono";
5 |
6 | export type ResponseType = InferResponseType<(typeof client.api.projects)[":id"]["$get"], 200>;
7 |
8 | export const useGetProject = (id: string) => {
9 | const query = useQuery({
10 | enabled: !!id,
11 | queryKey: ["project", { id }],
12 | queryFn: async () => {
13 | const response = await client.api.projects[":id"].$get({
14 | param: {
15 | id,
16 | },
17 | });
18 |
19 | if (!response.ok) {
20 | throw new Error("Failed to fetch project");
21 | }
22 |
23 | const { data } = await response.json();
24 | return data;
25 | },
26 | });
27 |
28 | return query;
29 | };
30 |
--------------------------------------------------------------------------------
/src/features/projects/api/use-get-projects.ts:
--------------------------------------------------------------------------------
1 | import { useInfiniteQuery } from "@tanstack/react-query";
2 |
3 | import { client } from "@/lib/hono";
4 | import { InferResponseType } from "hono";
5 |
6 | export type ResponseType = InferResponseType<(typeof client.api.projects)["$get"], 200>;
7 |
8 | export const useGetProjects = () => {
9 | const query = useInfiniteQuery({
10 | initialPageParam: 1,
11 | getNextPageParam: ( lastPage ) => lastPage.nextPage,
12 | queryKey: ["projects"],
13 | queryFn: async ({ pageParam }) => {
14 | const response = await client.api.projects.$get({
15 | query: {
16 | limit: "5",
17 | page: (pageParam as number).toString(),
18 | },
19 | });
20 |
21 | if (!response.ok) {
22 | throw new Error("Failed to fetch projects");
23 | }
24 |
25 | return response.json();
26 | },
27 | });
28 |
29 | return query;
30 | };
31 |
--------------------------------------------------------------------------------
/src/features/projects/api/use-get-templates.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 |
3 | import { client } from "@/lib/hono";
4 | import { InferRequestType, InferResponseType } from "hono";
5 |
6 | export type ResponseType = InferResponseType;
7 |
8 | type RequestType = InferRequestType["query"];
9 |
10 | export const useGetTemplates = (apiQuery: RequestType) => {
11 | const query = useQuery({
12 | queryKey: ["images",{
13 | page: apiQuery.page,
14 | limit: apiQuery.limit,
15 | }],
16 | queryFn: async () => {
17 | const response = await client.api.projects.templates.$get({
18 | query: apiQuery,
19 | });
20 |
21 | if (!response.ok) {
22 | throw new Error("Failed to fetch templates");
23 | }
24 |
25 | const { data } = await response.json();
26 | return data;
27 | },
28 | });
29 |
30 | return query;
31 | };
32 |
--------------------------------------------------------------------------------
/src/features/projects/api/use-update-project.ts:
--------------------------------------------------------------------------------
1 | import { useMutation,useQueryClient } from "@tanstack/react-query";
2 | import { InferRequestType, InferResponseType } from "hono";
3 |
4 | import { client } from "@/lib/hono";
5 | import { toast } from "sonner";
6 |
7 | type ResponseType = InferResponseType<(typeof client.api.projects)[":id"]["$patch"], 200>;
8 | type RequestType = InferRequestType<(typeof client.api.projects)[":id"]["$patch"]>["json"];
9 |
10 | export const useUpdateProject = (id: string) => {
11 | const queryClient = useQueryClient();
12 | const mutation = useMutation({
13 | mutationKey: ["project", { id }],
14 | mutationFn: async (json) => {
15 | const response = await client.api.projects[":id"].$patch({ param: { id }, json });
16 | if (!response.ok) {
17 | throw new Error("Failed to update project");
18 | }
19 | return await response.json();
20 | },
21 | onSuccess: () => {
22 | queryClient.invalidateQueries({ queryKey: ["project", { id }] });
23 | queryClient.invalidateQueries({ queryKey: ["projects"] });
24 | },
25 | onError: () => {
26 | toast.error("Failed to update project");
27 | },
28 | });
29 |
30 | return mutation;
31 | };
32 |
--------------------------------------------------------------------------------
/src/features/subscriptions/components/subscription-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import { useSubscriptionModal } from "@/features/subscriptions/store/use-subscription-modal";
5 | import {
6 | Dialog,
7 | DialogContent,
8 | DialogHeader,
9 | DialogTitle,
10 | DialogDescription,
11 | DialogFooter,
12 | } from "@/components/ui/dialog";
13 | import { Separator } from "@/components/ui/separator";
14 | import { CheckCircle2 } from "lucide-react";
15 | import { Button } from "@/components/ui/button";
16 |
17 | export const SubscriptionModal = () => {
18 | const { isOpen, onClose } = useSubscriptionModal();
19 |
20 | return (
21 |
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/src/features/subscriptions/hooks/use-paywall.ts:
--------------------------------------------------------------------------------
1 | import { useSubscriptionModal } from "@/features/subscriptions/store/use-subscription-modal";
2 |
3 | export const usePaywall = () => {
4 | const subscriptionModal = useSubscriptionModal();
5 |
6 | const shouldBlock = false;
7 |
8 | return {
9 | isLoading: false,
10 | shouldBlock,
11 | triggerPaywall: () => {
12 | subscriptionModal.onOpen();
13 | },
14 | };
15 | };
16 |
--------------------------------------------------------------------------------
/src/features/subscriptions/store/use-subscription-modal.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | type SubscriptionModalState = {
4 | isOpen: boolean;
5 | onOpen: () => void;
6 | onClose: () => void;
7 | };
8 |
9 | export const useSubscriptionModal = create((set) => ({
10 | isOpen: false,
11 | onOpen: () => set({ isOpen: true }),
12 | onClose: () => set({ isOpen: false }),
13 | }));
14 |
--------------------------------------------------------------------------------
/src/hooks/use-confirm.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogDescription,
8 | DialogFooter,
9 | DialogHeader,
10 | DialogTitle
11 | } from "@/components/ui/dialog";
12 |
13 | export const useConfirm = (
14 | title: string,
15 | message: string,
16 | ): [() => JSX.Element, () => Promise] => {
17 | const [promise, setPromise] = useState<{ resolve: (value: boolean) => void } | null>(null);
18 |
19 | const confirm = () => new Promise((resolve, reject) => {
20 | setPromise({ resolve });
21 | });
22 |
23 | const handleClose = () => {
24 | setPromise(null);
25 | };
26 |
27 | const handleConfirm = () => {
28 | promise?.resolve(true);
29 | handleClose();
30 | };
31 |
32 | const handleCancel = () => {
33 | promise?.resolve(false);
34 | handleClose();
35 | };
36 |
37 | const ConfirmationDialog = () => (
38 |
58 | );
59 |
60 | return [ConfirmationDialog, confirm];
61 | };
62 |
--------------------------------------------------------------------------------
/src/lib/hono.ts:
--------------------------------------------------------------------------------
1 | import { hc } from "hono/client";
2 | import { AppType } from "@/app/api/[[...route]]/route";
3 |
4 | export const client = hc(process.env.NEXT_PUBLIC_APP_URL!);
5 |
--------------------------------------------------------------------------------
/src/lib/replicate.ts:
--------------------------------------------------------------------------------
1 | import Replicate from "replicate";
2 |
3 | export const replicate = new Replicate({
4 | auth: process.env.REPLICATE_API_TOKEN,
5 | });
6 |
--------------------------------------------------------------------------------
/src/lib/unsplash.ts:
--------------------------------------------------------------------------------
1 | import { createApi } from "unsplash-js";
2 |
3 | export const unsplash = createApi({
4 | accessKey: process.env.NEXT_PUBLIC_UNSPLASH_ACCESS_KEY!,
5 | fetch: fetch,
6 | });
7 |
--------------------------------------------------------------------------------
/src/lib/uploadthing.ts:
--------------------------------------------------------------------------------
1 | import { generateUploadButton, generateUploadDropzone } from "@uploadthing/react";
2 |
3 | import type { OurFileRouter } from "@/app/api/uploadthing/core";
4 |
5 | export const UploadButton = generateUploadButton();
6 | export const UploadDropzone = generateUploadDropzone();
7 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | export { auth as middleware } from "@/auth"
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 | import { withUt } from "uploadthing/tw";
3 |
4 | const config: Config = {
5 | darkMode: ["class"],
6 | content: ["./pages/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}"],
7 | theme: {
8 | extend: {
9 | colors: {
10 | // background: "hsl(var(--background))",
11 | background: "#fff",
12 | foreground: "hsl(var(--foreground))",
13 | card: {
14 | DEFAULT: "hsl(var(--card))",
15 | foreground: "hsl(var(--card-foreground))",
16 | },
17 | popover: {
18 | DEFAULT: "hsl(var(--popover))",
19 | foreground: "hsl(var(--popover-foreground))",
20 | },
21 | primary: {
22 | DEFAULT: "hsl(var(--primary))",
23 | foreground: "hsl(var(--primary-foreground))",
24 | },
25 | secondary: {
26 | DEFAULT: "hsl(var(--secondary))",
27 | foreground: "hsl(var(--secondary-foreground))",
28 | },
29 | muted: {
30 | DEFAULT: "hsl(var(--muted))",
31 | foreground: "hsl(var(--muted-foreground))",
32 | },
33 | accent: {
34 | DEFAULT: "hsl(var(--accent))",
35 | foreground: "hsl(var(--accent-foreground))",
36 | },
37 | destructive: {
38 | DEFAULT: "hsl(var(--destructive))",
39 | foreground: "hsl(var(--destructive-foreground))",
40 | },
41 | border: "hsl(var(--border))",
42 | input: "hsl(var(--input))",
43 | ring: "hsl(var(--ring))",
44 | chart: {
45 | "1": "hsl(var(--chart-1))",
46 | "2": "hsl(var(--chart-2))",
47 | "3": "hsl(var(--chart-3))",
48 | "4": "hsl(var(--chart-4))",
49 | "5": "hsl(var(--chart-5))",
50 | },
51 | },
52 | borderRadius: {
53 | lg: "var(--radius)",
54 | md: "calc(var(--radius) - 2px)",
55 | sm: "calc(var(--radius) - 4px)",
56 | },
57 | },
58 | },
59 | plugins: [require("tailwindcss-animate")],
60 | };
61 | export default withUt(config);
62 |
--------------------------------------------------------------------------------
/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 | "@/*": ["./src/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------