Compare commits
497 Commits
dev/blog-r
...
cloudflare
Author | SHA1 | Date | |
---|---|---|---|
|
2a6e322c0a | ||
|
37f011cf74 | ||
|
35d0ca9e12 | ||
|
34baf20b31 | ||
|
28fcbae6a2 | ||
|
fc8cea13cd | ||
|
6065c4af06 | ||
|
ba7b950c01 | ||
|
c94784e711 | ||
|
48c045fb73 | ||
|
3fd47869a2 | ||
|
e3ac4a0a29 | ||
|
47adbcfd06 | ||
|
5d5eb82013 | ||
|
b0a065ced9 | ||
|
794c18a7e6 | ||
|
9899e1d164 | ||
|
ad1cbedb56 | ||
|
3707500ed8 | ||
|
f36018945d | ||
|
9f5d4aec59 | ||
|
e3f44a85a5 | ||
|
1f9a7c2621 | ||
|
a92ef86a71 | ||
|
e2dfab2ca7 | ||
|
e5061b3b67 | ||
|
4faa89c0ee | ||
|
481f3268db | ||
|
66d7dd3259 | ||
|
9aeb59dff2 | ||
|
2faedc2043 | ||
|
c0aa979382 | ||
|
fa2e981c16 | ||
|
0c415ee24b | ||
|
21eee041ab | ||
|
6c584c75e2 | ||
|
797ee9b7e5 | ||
|
658409cfbd | ||
|
422c323467 | ||
|
de7e87e5b8 | ||
|
613bbd0d78 | ||
|
4434f1900d | ||
|
895e02bfdd | ||
|
7cc1fd5835 | ||
|
4bad9714fa | ||
|
fa4b9a19a1 | ||
|
1c0c46fa34 | ||
|
0ae3f27c78 | ||
|
fc024ea0da | ||
|
80851fcf44 | ||
|
31829ce17b | ||
|
7c9b0a2697 | ||
|
5f14259197 | ||
|
15da1ee48a | ||
|
c2d7e51f5b | ||
|
00405d5335 | ||
|
610346055f | ||
|
cb9c3132fd | ||
|
32fc3d6dc9 | ||
|
69143ace47 | ||
|
8c3ef9bfaf | ||
|
7851a715a3 | ||
|
0fb4ef93d2 | ||
|
95a6f3b9d5 | ||
|
0794c7d297 | ||
|
395f753025 | ||
|
fc53045d99 | ||
|
64ba2711aa | ||
|
3a61c953a4 | ||
|
6de7dfebf3 | ||
|
e626bb9af4 | ||
|
33fe00b8dc | ||
|
2d0392db61 | ||
|
afdaeba2be | ||
|
258ddad399 | ||
|
e6bc1ea9e8 | ||
|
96d630f3ac | ||
|
e15d76461f | ||
|
1ff42009d8 | ||
|
669ac94bad | ||
|
d319bd8af2 | ||
|
01f5734dd5 | ||
|
6837c5a8d4 | ||
|
6927f4b234 | ||
|
ffe5bc4ea5 | ||
|
1be26638fc | ||
|
c66fedea27 | ||
|
b4dab95c04 | ||
|
1e1cafff32 | ||
|
23ddb90e1f | ||
|
8221f1753f | ||
|
18691030e7 | ||
|
7f4a7a61a2 | ||
|
a6a5d92dc1 | ||
|
63a5e4f328 | ||
|
19120ee7f1 | ||
|
7aa7cb5603 | ||
|
d644611afd | ||
|
ca30f95027 | ||
|
8cc16a898c | ||
|
cf8a7f1242 | ||
|
1fb89a2a05 | ||
|
13c23dab56 | ||
|
ac8d4dee4b | ||
|
ff1e72df13 | ||
|
d153ca655e | ||
|
c00223c79a | ||
|
d59be1044a | ||
|
5431160d62 | ||
|
73baf946bd | ||
|
d747683f82 | ||
|
978f13a368 | ||
|
779493965c | ||
|
3ae0411a44 | ||
|
ccf064b0d5 | ||
|
b55613b471 | ||
|
7c0e6a5131 | ||
|
d86f89e3de | ||
|
9800b1d842 | ||
|
9db52f352b | ||
|
47679ab91e | ||
|
90757475ac | ||
|
904dceec44 | ||
|
f468638f49 | ||
|
f1d02720d0 | ||
|
35ddf5e08e | ||
|
bade6b620e | ||
|
d1928575b3 | ||
|
262228d6e9 | ||
|
57b92cfe85 | ||
|
9f2fd58eb0 | ||
|
1f7c38f9f5 | ||
|
9f71c9942a | ||
|
e99d6da45c | ||
|
63dd4e52fb | ||
|
debbb5abf5 | ||
|
c5dfaafe61 | ||
|
df3f3aa895 | ||
|
a1ae6ca384 | ||
|
866988d73c | ||
|
46fd529390 | ||
|
fbb9a1b053 | ||
|
200a9963f7 | ||
|
f6a2df402e | ||
|
0da8f7d335 | ||
|
335c3b46d6 | ||
|
004edeecea | ||
|
f2b5bae866 | ||
|
6bb12a2d86 | ||
|
a1b54d7518 | ||
|
97654d97ea | ||
|
064576f48e | ||
|
0be53d3251 | ||
|
564efbd3e2 | ||
|
2814f87578 | ||
|
78f76f35b9 | ||
|
f50f60443a | ||
|
346d154604 | ||
|
7985769871 | ||
|
aa2e025270 | ||
|
11bfcb731d | ||
|
62eb4124be | ||
|
2b72570784 | ||
|
3c3dcd5d2a | ||
|
89fd7193ac | ||
|
8b2f1848a8 | ||
|
d0ddc2b1b0 | ||
|
6d4d316564 | ||
|
7a61aa3dff | ||
|
46ec614fd3 | ||
|
abb15de848 | ||
|
ba2a2b5fb0 | ||
|
2b8e0b9cb5 | ||
|
d7cc9b956d | ||
|
0b695cc4b2 | ||
|
3cb0911cf4 | ||
|
0d04f6914e | ||
|
22d68c005a | ||
|
70446d10b3 | ||
|
bc4578a3cd | ||
|
971b0d65a0 | ||
|
313c783dbd | ||
|
4384a1d43f | ||
|
7b9b7a0dd7 | ||
|
07ad39871f | ||
|
3d4245e8bc | ||
|
cc56f9d729 | ||
|
5912849fa7 | ||
|
3075681dc8 | ||
|
757f1dc4ae | ||
|
1be38e3e8d | ||
|
716eac324f | ||
|
bd029eac2a | ||
|
2c4db1e744 | ||
|
cb7743fe07 | ||
|
3a81a96316 | ||
|
d7077cb3d4 | ||
|
e5569dabd1 | ||
|
0d5185a789 | ||
|
813d8ea0bb | ||
|
a8c76d3249 | ||
|
49b39ad9dd | ||
|
1adf3d5dc3 | ||
|
5e877bf45e | ||
|
4277970074 | ||
|
b27d8cc505 | ||
|
2d2a85cd26 | ||
|
c960738133 | ||
|
bf8993ca96 | ||
|
a89910489a | ||
|
368644b434 | ||
|
f5e639bbc7 | ||
|
52aeb2d61c | ||
|
7af313868c | ||
|
4313e32471 | ||
|
31116cbf8b | ||
|
8a08dfdf3b | ||
|
141b562307 | ||
|
9fcfb3bdf7 | ||
|
8be9f6c775 | ||
|
ac02ea780a | ||
|
e3aa8eab55 | ||
|
72e0a14fc9 | ||
|
0f79ed14f0 | ||
|
ee341522f5 | ||
|
b4e8585929 | ||
|
b5997ded4c | ||
|
367965e41f | ||
|
c7a1ec69bb | ||
|
4160305a67 | ||
|
a5c6c8b493 | ||
|
4abca022aa | ||
|
765f5e1e39 | ||
|
9f3c5e80c2 | ||
|
997c362ac9 | ||
|
bda2571a78 | ||
|
788fbe2f18 | ||
|
f45bcad110 | ||
|
75db5e85a7 | ||
|
9711d13804 | ||
|
9d4fcbe36d | ||
|
e6663b013d | ||
|
5cb8b0048d | ||
|
0b6f81aca6 | ||
|
6cf9d4db9c | ||
|
59c7c807db | ||
|
de1ccca27b | ||
|
263440742a | ||
|
b75e9eb282 | ||
|
2aeb027e2f | ||
|
0500617803 | ||
|
3872a9d422 | ||
|
2e0a195a2a | ||
|
95bd256bc7 | ||
|
6c1a4685cd | ||
|
bbae584c88 | ||
|
f649db26ae | ||
|
1c7848f6b0 | ||
|
5c213d014a | ||
|
861502c28f | ||
|
737bd7f80f | ||
|
74d7cf44a1 | ||
|
e011d09803 | ||
|
50c500deb5 | ||
|
cd710bb9ed | ||
|
04f7f891a4 | ||
|
c67b804f4f | ||
|
a44e4a669c | ||
|
5af1182a58 | ||
|
24c0334911 | ||
|
3e0861f883 | ||
|
da4b018e8d | ||
|
a7738f0cbf | ||
|
adb9b80572 | ||
|
2e8f70dc76 | ||
|
b94fd34be5 | ||
|
0d4e8fe899 | ||
|
73ce18f564 | ||
|
e430a0c319 | ||
|
f7f7be2ef0 | ||
|
eafb3775e8 | ||
|
0af0aa3b09 | ||
|
04c2b2d7ee | ||
|
d9cda3e122 | ||
|
d8a12343c8 | ||
|
40af0f6922 | ||
|
1740c826c7 | ||
|
75083b32e4 | ||
|
e933844479 | ||
|
13bee49f90 | ||
|
fe2b1bbe39 | ||
|
8a9c76c628 | ||
|
98421afab8 | ||
|
6980507c43 | ||
|
8657bf4e84 | ||
|
bab58e6420 | ||
|
8d17bd80c5 | ||
|
c7e3de816c | ||
|
da26c2cf5d | ||
|
b838ddc293 | ||
|
cc9a15db8f | ||
|
8e63af3e7f | ||
|
aaadd7fcf4 | ||
|
1e2e4d77f7 | ||
|
88bb977c7b | ||
|
abf8b31ec7 | ||
|
12fb19e97b | ||
|
d8904750d9 | ||
|
b30355dfe5 | ||
|
dae7a3b0e8 | ||
|
e0c0ff9518 | ||
|
e1b0e2f44c | ||
|
6195df2bc5 | ||
|
3fa44f92c7 | ||
|
55ae5ced9e | ||
|
111568d746 | ||
|
45e6a59fe6 | ||
|
684bbdff82 | ||
|
181e478bc3 | ||
|
82d0fa1061 | ||
|
ba3cbe0724 | ||
|
91614ed6e5 | ||
|
0453db5ec6 | ||
|
1a297e33f9 | ||
|
05b90fb0a7 | ||
|
66567cfecd | ||
|
46d008e5fc | ||
|
c7cbf96a70 | ||
|
958852335d | ||
|
3058484803 | ||
|
985579b964 | ||
|
b3180e617d | ||
|
bc915a53dc | ||
|
bd67ac3517 | ||
|
e94625ce4e | ||
|
e70a8c92a2 | ||
|
2153cf6771 | ||
|
0164c833db | ||
|
cfbfa409b0 | ||
|
ffbe7f4fb0 | ||
|
13a79c7a01 | ||
|
1cb0793a83 | ||
|
5e1f9167e0 | ||
|
39a2870131 | ||
|
40b313a2f2 | ||
|
9e54932b27 | ||
|
5d50135ed6 | ||
|
befd10cc5b | ||
|
b17599976f | ||
|
107f761716 | ||
|
cb5c588b1e | ||
|
cbfe5e433d | ||
|
7c101d595e | ||
|
196f72ff68 | ||
|
7ab7d2d504 | ||
|
522d8de4ee | ||
|
0739c717d8 | ||
|
71b9807433 | ||
|
e49d4624fe | ||
|
8a72fb2409 | ||
|
e00c22d0fe | ||
|
8a5a49f041 | ||
|
bd8ccf4cf3 | ||
|
fd4426ddd7 | ||
|
fcb58d2206 | ||
|
aa547f209e | ||
|
5b30fd8c48 | ||
|
c8b29c59dc | ||
|
d946e2aead | ||
|
ddd3ee07df | ||
|
da0176ffc5 | ||
|
568ef9bc3a | ||
|
e05d20ee5e | ||
|
56fa6fb63c | ||
|
ebeacae587 | ||
|
7d5f4a52a8 | ||
|
0a2d081b07 | ||
|
21bc0b1293 | ||
|
c477aae333 | ||
|
543798e2c1 | ||
|
483a970b71 | ||
|
292faddc7a | ||
|
8fd3b679fb | ||
|
2d12d89e3b | ||
|
d0aef4b7d4 | ||
|
c006ee750d | ||
|
19a6c4d994 | ||
|
86f13a1748 | ||
|
745ba457df | ||
|
beb53639a3 | ||
|
65fb8722bc | ||
|
160a7eb929 | ||
|
c3d82d9183 | ||
|
767351c5cd | ||
|
fd3c82baaf | ||
|
168eae946f | ||
|
69390fed70 | ||
|
2cb041beb1 | ||
|
3645cf5773 | ||
|
c6ad6d0ad5 | ||
|
53ab869f07 | ||
|
e0f408fb07 | ||
|
1216732a55 | ||
|
4c6fddf99d | ||
|
90d5db88ab | ||
|
af5a3265a6 | ||
|
ec8ce54824 | ||
|
f4d8a09ab6 | ||
|
3b741b3b98 | ||
|
b07be5fab4 | ||
|
a22a5def4d | ||
|
d190bcb358 | ||
|
7f1fe23407 | ||
|
05a7de4599 | ||
|
c098300481 | ||
|
e7240db823 | ||
|
a4390d433b | ||
|
ae49d06cf4 | ||
|
6a448825a6 | ||
|
92a87ceb03 | ||
|
3136766a6d | ||
|
02b5ee1727 | ||
|
b10e27ee29 | ||
|
ea0c74aa12 | ||
|
4015cb3143 | ||
|
2ad6eab666 | ||
|
c23383fdde | ||
|
e610fe7335 | ||
|
a7a5a8a6a4 | ||
|
9ffe9af0fa | ||
|
17c7d67743 | ||
|
0684b16278 | ||
|
9c120d776d | ||
|
a7c56f4a6c | ||
|
563fc2099a | ||
|
56df0bed46 | ||
|
4d60d48212 | ||
|
26a88eb2f0 | ||
|
5b50e62774 | ||
|
c5d08a9846 | ||
|
f5b4ed2859 | ||
|
b88aa9c1f5 | ||
|
593333c3dd | ||
|
f3b6603db7 | ||
|
9cb559a48d | ||
|
c3392320b3 | ||
|
708fac652f | ||
|
ec124640f1 | ||
|
862132d8eb | ||
|
bf11c143fe | ||
|
6cfc76d621 | ||
|
d935bcff76 | ||
|
a727a31e2f | ||
|
2cdc520b1d | ||
|
81cfc5f6b3 | ||
|
8e8291c325 | ||
|
6ff2ea6845 | ||
|
b6836db12d | ||
|
4631aea7f5 | ||
|
8b3e9ecfe1 | ||
|
00dcd7ce61 | ||
|
9b7c1387f9 | ||
|
da69bb7d20 | ||
|
7edc627505 | ||
|
34b7c1c74f | ||
|
80763b4efb | ||
|
6a4f0575c4 | ||
|
d391d35e26 | ||
|
5f6e75fe93 | ||
|
5fae666b70 | ||
|
377e46a31f | ||
|
c23fdee88f | ||
|
3a8acc5ef4 | ||
|
4374f118b4 | ||
|
443f01769c | ||
|
ac320b21f4 | ||
|
9e0bd57ecc | ||
|
5f435b9614 | ||
|
9b03f6201f | ||
|
111f00adaa | ||
|
002d2090c2 | ||
|
c3913dbc88 | ||
|
9b68e3095e | ||
|
2fb627a6e9 | ||
|
f11e37374b | ||
|
3560616b52 | ||
|
80219fa10b | ||
|
a62abbf399 | ||
|
dd95dece87 | ||
|
c938122f7e | ||
|
3887da26d0 | ||
|
7af193f770 | ||
|
d6093394d8 | ||
|
f1537e305a | ||
|
1847ef4363 | ||
|
0fd695c8bc | ||
|
ae083a7992 |
@ -7,7 +7,7 @@ alwaysApply: false
|
||||
|
||||
## Database (Drizzle ORM)
|
||||
- Schema definitions in `src/db/schema.ts`
|
||||
- Migrations in `drizzle/`
|
||||
- Migrations in `src/db/migrations`
|
||||
- Use `db:generate` to create new migration files based on schema changes
|
||||
- Use `db:migrate` to apply pending migrations to the database
|
||||
- Use `db:push` to sync schema changes directly to the database (development only)
|
||||
|
@ -19,6 +19,7 @@ alwaysApply: false
|
||||
- `src/payment/`: Payment integration
|
||||
- `src/analytics/`: Analytics and tracking
|
||||
- `src/storage/`: File storage integration
|
||||
- `src/notification/`: Sending Notifications
|
||||
|
||||
## Configuration Files
|
||||
- `next.config.ts`: Next.js configuration
|
||||
@ -29,7 +30,7 @@ alwaysApply: false
|
||||
|
||||
## Content Management
|
||||
- `content/`: MDX content files
|
||||
- `content-collections.ts`: Content collection configuration
|
||||
- `source.config.ts`: Fumadocs source configuration
|
||||
|
||||
## Environment
|
||||
- `env.example`: Environment variables template
|
||||
|
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@ -0,0 +1,16 @@
|
||||
.cursor
|
||||
.claude
|
||||
.conductor
|
||||
.kiro
|
||||
.github
|
||||
.next
|
||||
.open-next
|
||||
.source
|
||||
.vscode
|
||||
.git
|
||||
.wrangler
|
||||
.dockerignore
|
||||
node_modules
|
||||
**/node_modules
|
||||
Dockerfile
|
||||
LICENSE
|
41
.gitattributes
vendored
Normal file
41
.gitattributes
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# Set default behavior to automatically normalize line endings
|
||||
* text=auto
|
||||
|
||||
# Force LF line endings for text files
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.json text eol=lf
|
||||
*.md text eol=lf
|
||||
*.mdx text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.css text eol=lf
|
||||
*.scss text eol=lf
|
||||
*.html text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.sh text eol=lf
|
||||
|
||||
# Ensure these files are always treated as text and get LF line endings
|
||||
.gitignore text eol=lf
|
||||
.gitattributes text eol=lf
|
||||
.editorconfig text eol=lf
|
||||
*.config.js text eol=lf
|
||||
*.config.ts text eol=lf
|
||||
|
||||
# Binary files should be left untouched
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.svg binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.tar.gz binary
|
20
.gitignore
vendored
20
.gitignore
vendored
@ -30,12 +30,23 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
certificates
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# claude code
|
||||
.claude
|
||||
|
||||
# conductor
|
||||
.conductor
|
||||
|
||||
# kiro
|
||||
.kiro
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@ -43,5 +54,14 @@ next-env.d.ts
|
||||
# content collections
|
||||
.content-collections
|
||||
|
||||
# fumadocs
|
||||
.source
|
||||
|
||||
# OpenNext build output
|
||||
.open-next
|
||||
|
||||
# wrangler files
|
||||
.wrangler
|
||||
.dev.vars
|
||||
.dev.vars*
|
||||
!.dev.vars.example
|
||||
|
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@ -4,6 +4,7 @@
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"Lokalise.i18n-ally",
|
||||
"unifiedjs.vscode-mdx",
|
||||
"eamodio.gitlens"
|
||||
"eamodio.gitlens",
|
||||
"editorconfig.editorconfig"
|
||||
]
|
||||
}
|
||||
|
12
.vscode/settings.json
vendored
12
.vscode/settings.json
vendored
@ -22,6 +22,14 @@
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
".next": true,
|
||||
".content-collections": true,
|
||||
".source": true,
|
||||
".wrangler": true,
|
||||
".open-next": true,
|
||||
".vscode": true,
|
||||
".cursor": true,
|
||||
".claude": true,
|
||||
".conductor": true,
|
||||
".kiro": true,
|
||||
".github": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
109
CLAUDE.md
Normal file
109
CLAUDE.md
Normal file
@ -0,0 +1,109 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Core Development
|
||||
- `pnpm dev` - Start development server with content collections
|
||||
- `pnpm build` - Build the application and content collections
|
||||
- `pnpm start` - Start production server
|
||||
- `pnpm lint` - Run Biome linter (use for code quality checks)
|
||||
- `pnpm format` - Format code with Biome
|
||||
|
||||
### Database Operations (Drizzle ORM)
|
||||
- `pnpm db:generate` - Generate new migration files based on schema changes
|
||||
- `pnpm db:migrate` - Apply pending migrations to the database
|
||||
- `pnpm db:push` - Sync schema changes directly to the database (development only)
|
||||
- `pnpm db:studio` - Open Drizzle Studio for database inspection and management
|
||||
|
||||
### Content and Email
|
||||
- `pnpm content` - Process MDX content collections
|
||||
- `pnpm email` - Start email template development server on port 3333
|
||||
|
||||
## Project Architecture
|
||||
|
||||
This is a Next.js 15 full-stack SaaS application with the following key architectural components:
|
||||
|
||||
### Core Stack
|
||||
- **Framework**: Next.js 15 with App Router
|
||||
- **Database**: PostgreSQL with Drizzle ORM
|
||||
- **Authentication**: Better Auth with social providers (Google, GitHub)
|
||||
- **Payments**: Stripe integration with subscription and one-time payments
|
||||
- **UI**: Radix UI components with TailwindCSS
|
||||
- **State Management**: Zustand for client-side state
|
||||
- **Internationalization**: next-intl with English and Chinese locales
|
||||
- **Content**: Fumadocs for documentation and MDX for content
|
||||
- **Code Quality**: Biome for formatting and linting
|
||||
|
||||
### Key Directory Structure
|
||||
- `src/app/` - Next.js app router with internationalized routing
|
||||
- `src/components/` - Reusable React components organized by feature
|
||||
- `src/lib/` - Utility functions and shared code
|
||||
- `src/db/` - Database schema and migrations
|
||||
- `src/actions/` - Server actions for API operations
|
||||
- `src/stores/` - Zustand state management
|
||||
- `src/hooks/` - Custom React hooks
|
||||
- `src/config/` - Application configuration files
|
||||
- `src/i18n/` - Internationalization setup
|
||||
- `src/mail/` - Email templates and mail functionality
|
||||
- `src/payment/` - Stripe payment integration
|
||||
- `src/credits/` - Credit system implementation
|
||||
- `content/` - MDX content files for docs and blog
|
||||
- `messages/` - Translation files (en.json, zh.json) for internationalization
|
||||
|
||||
### Authentication & User Management
|
||||
- Uses Better Auth with PostgreSQL adapter
|
||||
- Supports email/password and social login (Google, GitHub)
|
||||
- Includes user management, email verification, and password reset
|
||||
- Admin plugin for user management and banning
|
||||
- Automatic newsletter subscription on user creation
|
||||
|
||||
### Payment System
|
||||
- Stripe integration for subscriptions and one-time payments
|
||||
- Three pricing tiers: Free, Pro (monthly/yearly), and Lifetime
|
||||
- Credit system with packages for pay-per-use features
|
||||
- Customer portal for subscription management
|
||||
|
||||
### Feature Modules
|
||||
- **Blog**: MDX-based blog with pagination and categories
|
||||
- **Docs**: Fumadocs-powered documentation
|
||||
- **AI Features**: Image generation with multiple providers (OpenAI, Replicate, etc.)
|
||||
- **Newsletter**: Email subscription system
|
||||
- **Analytics**: Multiple analytics providers support
|
||||
- **Storage**: S3 integration for file uploads
|
||||
|
||||
### Development Workflow
|
||||
1. Use TypeScript for all new code
|
||||
2. Follow Biome formatting rules (single quotes, trailing commas)
|
||||
3. Write server actions in `src/actions/`
|
||||
4. Use Zustand for client-side state management
|
||||
5. Implement database changes through Drizzle migrations
|
||||
6. Use Radix UI components for consistent UI
|
||||
7. Follow the established directory structure
|
||||
8. Use proper error handling with error.tsx and not-found.tsx
|
||||
9. Leverage Next.js 15 features like Server Actions
|
||||
10. Use `next-safe-action` for secure form submissions
|
||||
|
||||
### Configuration
|
||||
- Main config in `src/config/website.tsx`
|
||||
- Environment variables template in `env.example`
|
||||
- Database config in `drizzle.config.ts`
|
||||
- Biome config in `biome.json` with specific ignore patterns
|
||||
- TypeScript config with path aliases (@/* for src/*)
|
||||
|
||||
### Testing and Quality
|
||||
- Use Biome for linting and formatting
|
||||
- TypeScript for type safety
|
||||
- Environment variables for configuration
|
||||
- Proper error boundaries and not-found pages
|
||||
- Zod for runtime validation
|
||||
|
||||
## Important Notes
|
||||
|
||||
- The project uses pnpm as the package manager
|
||||
- Database schema is in `src/db/schema.ts` with auth, payment, and credit tables
|
||||
- Email templates are in `src/mail/templates/`
|
||||
- The app supports both light and dark themes
|
||||
- Content is managed through MDX files in the `content/` directory
|
||||
- The project includes comprehensive internationalization support
|
62
Dockerfile
Normal file
62
Dockerfile
Normal file
@ -0,0 +1,62 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
# Copy config files needed for fumadocs-mdx postinstall
|
||||
COPY source.config.ts ./
|
||||
COPY content ./content
|
||||
RUN npm install -g pnpm && pnpm i --frozen-lockfile
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN npm install -g pnpm \
|
||||
&& DOCKER_BUILD=true pnpm build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
CMD ["node", "server.js"]
|
21
README.md
21
README.md
@ -19,28 +19,27 @@ If you found anything that could be improved, please let me know.
|
||||
- 🔥 website: [mksaas.com](https://mksaas.com)
|
||||
- 🌐 demo: [demo.mksaas.com](https://demo.mksaas.com)
|
||||
- 📚 documentation: [mksaas.com/docs](https://mksaas.com/docs)
|
||||
- 🗓️ roadmap: [mksaas project](https://mksaas.link/roadmap)
|
||||
- 🗓️ roadmap: [mksaas roadmap](https://mksaas.link/roadmap)
|
||||
- 👨💻 discord: [mksaas.link/discord](https://mksaas.link/discord)
|
||||
- 📹 video (WIP): [mksaas.link/youtube](https://mksaas.link/youtube)
|
||||
- 📹 video: [mksaas.link/youtube](https://mksaas.link/youtube)
|
||||
|
||||
## Repositories
|
||||
|
||||
By default, you should have access to all four repositories. If you find that you’re unable to access any of them, please don’t hesitate to reach out to me, and I’ll assist you in resolving the issue.
|
||||
By default, you should have access to all 5 repositories. If you find that you’re unable to access any of them, please don’t hesitate to reach out to me, and I’ll assist you in resolving the issue.
|
||||
|
||||
- [MkSaaSHQ/mksaas-template](https://github.com/MkSaaSHQ/mksaas-template): https://demo.mksaas.com (ready)
|
||||
- [MkSaaSHQ/mksaas-blog](https://github.com/MkSaaSHQ/mksaas-blog): https://mksaas.me (ready)
|
||||
- [MkSaaSHQ/mksaas-app](https://github.com/MkSaaSHQ/mksaas-app): https://mksaas.app (WIP)
|
||||
- [MkSaaSHQ/mksaas-haitang](https://github.com/MkSaaSHQ/mksaas-haitang): https://haitang.app (WIP)
|
||||
- [mksaas-template (ready)](https://github.com/MkSaaSHQ/mksaas-template): https://demo.mksaas.com
|
||||
- [mksaas-blog (ready)](https://github.com/MkSaaSHQ/mksaas-blog): https://mksaas.me
|
||||
- [mksaas-haitang (ready)](https://github.com/MkSaaSHQ/mksaas-haitang): https://haitang.app
|
||||
- [mksaas-outfit (ready)](https://github.com/MkSaaSHQ/mksaas-outfit)
|
||||
- [mksaas-app (WIP)](https://github.com/MkSaaSHQ/mksaas-app): https://mksaas.app
|
||||
|
||||
## Notice
|
||||
|
||||
> If you have any questions, please [submit an issue](https://github.com/MkSaaSHQ/mksaas-template/issues/new), or contact me at [support@mksaas.com](mailto:support@mksaas.com).
|
||||
|
||||
> If you have any feature requests or questions or ideas to share, please [submit it in the discussions](https://github.com/MkSaaSHQ/mksaas-template/discussions).
|
||||
> If you have any questions, please [submit an issue](https://github.com/MkSaaSHQ/mksaas-template/issues/new), or contact me at [support@mksaas.com](mailto:support@mksaas.com), or join our [discord community](https://mksaas.link/discord) and ask for help there.
|
||||
|
||||
> If you want to receive notifications whenever code changes, please click `Watch` button in the top right.
|
||||
|
||||
> When submitting any content to the issues or discussions of the repository, please use **English** as the main Language, so that everyone can read it and help you, thank you for your supports.
|
||||
> When submitting any content to the issues of the repository, please use **English** as the main Language, so that everyone can read it and help you, thank you for your supports.
|
||||
|
||||
## License
|
||||
|
||||
|
35
biome.json
35
biome.json
@ -9,22 +9,28 @@
|
||||
"ignoreUnknown": true,
|
||||
"ignore": [
|
||||
".next/**",
|
||||
".open-next/**",
|
||||
".wrangler/**",
|
||||
".cursor/**",
|
||||
".claude/**",
|
||||
".kiro/**",
|
||||
".conductor/**",
|
||||
".vscode/**",
|
||||
".content-collections/**",
|
||||
".source/**",
|
||||
"node_modules/**",
|
||||
"dist/**",
|
||||
"build/**",
|
||||
"drizzle/**",
|
||||
"src/db/**",
|
||||
"tailwind.config.ts",
|
||||
"src/components/ui/*.tsx",
|
||||
"src/components/magicui/*.tsx",
|
||||
"src/components/animate-ui/*.tsx",
|
||||
"src/components/tailark/*.tsx",
|
||||
"src/components/ai-elements/*.tsx",
|
||||
"src/app/[[]locale]/preview/**",
|
||||
"src/db/schema.ts",
|
||||
"src/payment/types.ts",
|
||||
"src/types/index.d.ts",
|
||||
"public/sw.js"
|
||||
"src/credits/types.ts",
|
||||
"src/types/index.d.ts"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
@ -32,7 +38,8 @@
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80,
|
||||
"formatWithErrors": true
|
||||
"formatWithErrors": true,
|
||||
"useEditorconfig": true
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
@ -67,22 +74,28 @@
|
||||
},
|
||||
"ignore": [
|
||||
".next/**",
|
||||
".open-next/**",
|
||||
".wrangler/**",
|
||||
".cursor/**",
|
||||
".claude/**",
|
||||
".conductor/**",
|
||||
".kiro/**",
|
||||
".vscode/**",
|
||||
".content-collections/**",
|
||||
".source/**",
|
||||
"node_modules/**",
|
||||
"dist/**",
|
||||
"build/**",
|
||||
"drizzle/**",
|
||||
"src/db/**",
|
||||
"tailwind.config.ts",
|
||||
"src/components/ui/*.tsx",
|
||||
"src/components/magicui/*.tsx",
|
||||
"src/components/animate-ui/*.tsx",
|
||||
"src/components/tailark/*.tsx",
|
||||
"src/components/ai-elements/*.tsx",
|
||||
"src/app/[[]locale]/preview/**",
|
||||
"src/db/schema.ts",
|
||||
"src/payment/types.ts",
|
||||
"src/types/index.d.ts",
|
||||
"public/sw.js"
|
||||
"src/credits/types.ts",
|
||||
"src/types/index.d.ts"
|
||||
]
|
||||
},
|
||||
"javascript": {
|
||||
|
7483
cloudflare-env.d.ts
vendored
Normal file
7483
cloudflare-env.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,328 +0,0 @@
|
||||
import path from 'path';
|
||||
import { DEFAULT_LOCALE, LOCALES } from '@/i18n/routing';
|
||||
import { defineCollection, defineConfig } from '@content-collections/core';
|
||||
import {
|
||||
createDocSchema,
|
||||
createMetaSchema,
|
||||
transformMDX,
|
||||
} from '@fumadocs/content-collections/configuration';
|
||||
|
||||
/**
|
||||
* 1. Content Collections documentation
|
||||
* https://www.content-collections.dev/docs/quickstart/next
|
||||
* https://www.content-collections.dev/docs/configuration
|
||||
* https://www.content-collections.dev/docs/transform#join-collections
|
||||
*
|
||||
* 2. Use Content Collections for Fumadocs
|
||||
* https://fumadocs.dev/docs/headless/content-collections
|
||||
*/
|
||||
const docs = defineCollection({
|
||||
name: 'docs',
|
||||
directory: 'content/docs',
|
||||
include: '**/*.mdx',
|
||||
schema: (z) => ({
|
||||
...createDocSchema(z),
|
||||
preview: z.string().optional(),
|
||||
index: z.boolean().default(false),
|
||||
}),
|
||||
transform: transformMDX,
|
||||
});
|
||||
|
||||
const metas = defineCollection({
|
||||
name: 'meta',
|
||||
directory: 'content/docs',
|
||||
include: '**/meta**.json',
|
||||
parser: 'json',
|
||||
schema: createMetaSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Blog Author collection
|
||||
*
|
||||
* Authors are identified by their slug across all languages
|
||||
* New format: content/author/authorname.{locale}.mdx
|
||||
* Example: content/author/mksaas.mdx (default locale) and content/author/mksaas.zh.mdx (Chinese)
|
||||
*
|
||||
* For author, slug is slugAsParams
|
||||
*/
|
||||
export const authors = defineCollection({
|
||||
name: 'author',
|
||||
directory: 'content/author',
|
||||
include: '**/*.mdx',
|
||||
schema: (z) => ({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
avatar: z.string(),
|
||||
locale: z.string().optional().default(DEFAULT_LOCALE),
|
||||
}),
|
||||
transform: async (data, context) => {
|
||||
// Get the filename from the path
|
||||
const filePath = data._meta.path;
|
||||
const fileName = filePath.split(path.sep).pop() || '';
|
||||
|
||||
// Extract locale and base from filename
|
||||
const { locale, base } = extractLocaleAndBase(fileName);
|
||||
// console.log(`author processed: ${fileName}, locale=${locale}`);
|
||||
|
||||
return {
|
||||
...data,
|
||||
locale,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Blog Category collection
|
||||
*
|
||||
* Categories are identified by their slug across all languages
|
||||
* New format: content/category/categoryname.{locale}.mdx
|
||||
* Example: content/category/tutorial.mdx (default locale) and content/category/tutorial.zh.mdx (Chinese)
|
||||
*
|
||||
* For category, slug is slugAsParams
|
||||
*/
|
||||
export const categories = defineCollection({
|
||||
name: 'category',
|
||||
directory: 'content/category',
|
||||
include: '**/*.mdx',
|
||||
schema: (z) => ({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
locale: z.string().optional().default(DEFAULT_LOCALE),
|
||||
}),
|
||||
transform: async (data, context) => {
|
||||
// Get the filename from the path
|
||||
const filePath = data._meta.path;
|
||||
const fileName = filePath.split(path.sep).pop() || '';
|
||||
|
||||
// Extract locale and base from filename
|
||||
const { locale, base } = extractLocaleAndBase(fileName);
|
||||
// console.log(`category processed: ${fileName}, locale=${locale}`);
|
||||
|
||||
return {
|
||||
...data,
|
||||
locale,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Blog Post collection
|
||||
*
|
||||
* New format: content/blog/post-slug.{locale}.mdx
|
||||
*
|
||||
* slug: /blog/first-post, used in URL or sitemap
|
||||
* slugAsParams: first-post, used in route params
|
||||
*
|
||||
* 1. For a blog post at content/blog/first-post.mdx (default locale):
|
||||
* locale: en
|
||||
* slug: /blog/first-post
|
||||
* slugAsParams: first-post
|
||||
*
|
||||
* 2. For a blog post at content/blog/first-post.zh.mdx (Chinese locale):
|
||||
* locale: zh
|
||||
* slug: /blog/first-post
|
||||
* slugAsParams: first-post
|
||||
*/
|
||||
export const posts = defineCollection({
|
||||
name: 'post',
|
||||
directory: 'content/blog',
|
||||
include: '**/*.mdx',
|
||||
schema: (z) => ({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
image: z.string(),
|
||||
date: z.string().datetime(),
|
||||
published: z.boolean().default(true),
|
||||
categories: z.array(z.string()),
|
||||
author: z.string(),
|
||||
estimatedTime: z.number().optional(), // Reading time in minutes
|
||||
}),
|
||||
transform: async (data, context) => {
|
||||
// Use Fumadocs transformMDX for consistent MDX processing
|
||||
const transformedData = await transformMDX(data, context);
|
||||
|
||||
// Get the filename from the path
|
||||
const filePath = data._meta.path;
|
||||
const fileName = filePath.split(path.sep).pop() || '';
|
||||
|
||||
// Extract locale and base from filename
|
||||
const { locale, base } = extractLocaleAndBase(fileName);
|
||||
// console.log(`post processed: ${fileName}, base=${base}, locale=${locale}`);
|
||||
|
||||
// Find the author by matching slug and locale
|
||||
const blogAuthor = context
|
||||
.documents(authors)
|
||||
.find((a) => a.slug === data.author && a.locale === locale);
|
||||
|
||||
// Find categories by matching slug and locale
|
||||
const blogCategories = data.categories
|
||||
.map((categorySlug) => {
|
||||
const category = context
|
||||
.documents(categories)
|
||||
.find((c) => c.slug === categorySlug && c.locale === locale);
|
||||
|
||||
return category;
|
||||
})
|
||||
.filter(Boolean); // Remove null values
|
||||
|
||||
// Create the slug and slugAsParams
|
||||
const slug = `/blog/${base}`;
|
||||
const slugAsParams = base;
|
||||
|
||||
// Calculate estimated reading time
|
||||
const wordCount = data.content.split(/\s+/).length;
|
||||
const wordsPerMinute = 200; // average reading speed: 200 words per minute
|
||||
const estimatedTime = Math.max(Math.ceil(wordCount / wordsPerMinute), 1);
|
||||
|
||||
return {
|
||||
...data,
|
||||
locale,
|
||||
author: blogAuthor,
|
||||
categories: blogCategories,
|
||||
slug,
|
||||
slugAsParams,
|
||||
estimatedTime,
|
||||
body: transformedData.body,
|
||||
toc: transformedData.toc,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Pages collection for policy pages like privacy-policy, terms-of-service, etc.
|
||||
*
|
||||
* New format: content/pages/page-slug.{locale}.mdx
|
||||
*
|
||||
* 1. For a page at content/pages/privacy-policy.mdx (default locale):
|
||||
* locale: en
|
||||
* slug: /pages/privacy-policy
|
||||
* slugAsParams: privacy-policy
|
||||
*
|
||||
* 2. For a page at content/pages/privacy-policy.zh.mdx (Chinese locale):
|
||||
* locale: zh
|
||||
* slug: /pages/privacy-policy
|
||||
* slugAsParams: privacy-policy
|
||||
*/
|
||||
export const pages = defineCollection({
|
||||
name: 'page',
|
||||
directory: 'content/pages',
|
||||
include: '**/*.mdx',
|
||||
schema: (z) => ({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
date: z.string().datetime(),
|
||||
published: z.boolean().default(true),
|
||||
}),
|
||||
transform: async (data, context) => {
|
||||
// Use Fumadocs transformMDX for consistent MDX processing
|
||||
const transformedData = await transformMDX(data, context);
|
||||
|
||||
// Get the filename from the path
|
||||
const filePath = data._meta.path;
|
||||
const fileName = filePath.split(path.sep).pop() || '';
|
||||
|
||||
// Extract locale and base from filename
|
||||
const { locale, base } = extractLocaleAndBase(fileName);
|
||||
// console.log(`page processed: ${fileName}, base=${base}, locale=${locale}`);
|
||||
|
||||
// Create the slug and slugAsParams
|
||||
const slug = `/pages/${base}`;
|
||||
const slugAsParams = base;
|
||||
|
||||
return {
|
||||
...data,
|
||||
locale,
|
||||
slug,
|
||||
slugAsParams,
|
||||
body: transformedData.body,
|
||||
toc: transformedData.toc,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Releases collection for changelog
|
||||
*
|
||||
* New format: content/release/version-slug.{locale}.mdx
|
||||
*
|
||||
* 1. For a release at content/release/v1-0-0.mdx (default locale):
|
||||
* locale: en
|
||||
* slug: /release/v1-0-0
|
||||
* slugAsParams: v1-0-0
|
||||
*
|
||||
* 2. For a release at content/release/v1-0-0.zh.mdx (Chinese locale):
|
||||
* locale: zh
|
||||
* slug: /release/v1-0-0
|
||||
* slugAsParams: v1-0-0
|
||||
*/
|
||||
export const releases = defineCollection({
|
||||
name: 'release',
|
||||
directory: 'content/release',
|
||||
include: '**/*.mdx',
|
||||
schema: (z) => ({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
date: z.string().datetime(),
|
||||
version: z.string(),
|
||||
published: z.boolean().default(true),
|
||||
}),
|
||||
transform: async (data, context) => {
|
||||
// Use Fumadocs transformMDX for consistent MDX processing
|
||||
const transformedData = await transformMDX(data, context);
|
||||
|
||||
// Get the filename from the path
|
||||
const filePath = data._meta.path;
|
||||
const fileName = filePath.split(path.sep).pop() || '';
|
||||
|
||||
// Extract locale and base from filename
|
||||
const { locale, base } = extractLocaleAndBase(fileName);
|
||||
// console.log(`release processed: ${fileName}, base=${base}, locale=${locale}`);
|
||||
|
||||
// Create the slug and slugAsParams
|
||||
const slug = `/release/${base}`;
|
||||
const slugAsParams = base;
|
||||
|
||||
return {
|
||||
...data,
|
||||
locale,
|
||||
slug,
|
||||
slugAsParams,
|
||||
body: transformedData.body,
|
||||
toc: transformedData.toc,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to extract locale and base name from filename
|
||||
* Handles filename formats:
|
||||
* - name -> locale: DEFAULT_LOCALE, base: name
|
||||
* - name.zh -> locale: zh, base: name
|
||||
*
|
||||
* @param fileName Filename without extension (already has .mdx removed)
|
||||
* @returns Object with locale and base name
|
||||
*/
|
||||
function extractLocaleAndBase(fileName: string): {
|
||||
locale: string;
|
||||
base: string;
|
||||
} {
|
||||
// Split filename into parts
|
||||
const parts = fileName.split('.');
|
||||
|
||||
if (parts.length === 1) {
|
||||
// Simple filename without locale: xxx
|
||||
return { locale: DEFAULT_LOCALE, base: parts[0] };
|
||||
}
|
||||
if (parts.length === 2 && LOCALES.includes(parts[1])) {
|
||||
// Filename with locale: xxx.zh
|
||||
return { locale: parts[1], base: parts[0] };
|
||||
}
|
||||
// Unexpected format, use first part as base and default locale
|
||||
console.warn(`Unexpected filename format: ${fileName}`);
|
||||
return { locale: DEFAULT_LOCALE, base: parts[0] };
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
collections: [docs, metas, authors, categories, posts, pages, releases],
|
||||
});
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: fox
|
||||
name: Fox
|
||||
avatar: /images/avatars/fox.png
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: fox
|
||||
name: Fox
|
||||
avatar: /images/avatars/fox.png
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: mkdirs
|
||||
name: Mkdirs
|
||||
avatar: /images/avatars/mkdirs.png
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: mkdirs
|
||||
name: Mkdirs模板
|
||||
avatar: /images/avatars/mkdirs.png
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: mksaas
|
||||
name: MkSaaS
|
||||
avatar: /images/avatars/mksaas.png
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: mksaas
|
||||
name: MkSaaS模板
|
||||
avatar: /images/avatars/mksaas.png
|
||||
---
|
||||
|
@ -2,7 +2,7 @@
|
||||
title: Comparisons
|
||||
description: How is Fumadocs different from other existing frameworks?
|
||||
image: /images/blog/post-2.png
|
||||
date: 2025-03-22T12:00:00.000Z
|
||||
date: "2025-03-22"
|
||||
published: true
|
||||
categories: [news, company]
|
||||
author: fox
|
||||
|
@ -2,7 +2,7 @@
|
||||
title: 对比
|
||||
description: Fumadocs 与其他现有框架有何不同?
|
||||
image: /images/blog/post-2.png
|
||||
date: 2025-03-22T12:00:00.000Z
|
||||
date: "2025-03-22"
|
||||
published: true
|
||||
categories: [news, company]
|
||||
author: fox
|
||||
@ -69,4 +69,4 @@ Docusaurus 是一个基于 React.js 的强大框架。它通过插件和自定
|
||||
|
||||
您可以通过插件轻松实现许多功能,他们的生态系统确实更大,并由许多贡献者维护。
|
||||
|
||||
相比之下,Fumadocs 的灵活性允许您自己实现它们,可能需要更长的时间来调整它以达到您的满意度。
|
||||
相比之下,Fumadocs 的灵活性允许您自己实现它们,可能需要更长的时间来调整它以达到您的满意度。
|
@ -2,7 +2,7 @@
|
||||
title: Quick Start
|
||||
description: Getting Started with Fumadocs
|
||||
image: /images/blog/post-8.png
|
||||
date: 2025-03-28T12:00:00.000Z
|
||||
date: "2025-03-28"
|
||||
published: true
|
||||
categories: [company, news]
|
||||
author: mksaas
|
||||
@ -99,7 +99,7 @@ title: Hello World
|
||||
|
||||
Run the app in development mode and see http://localhost:3000/docs.
|
||||
|
||||
```package-install
|
||||
```mdx
|
||||
npm run dev
|
||||
```
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
title: 快速入门
|
||||
description: Fumadocs 入门指南
|
||||
image: /images/blog/post-8.png
|
||||
date: 2025-03-28T12:00:00.000Z
|
||||
date: "2025-03-28"
|
||||
published: true
|
||||
categories: [company, news]
|
||||
author: mksaas
|
||||
@ -99,7 +99,7 @@ title: Hello World
|
||||
|
||||
在开发模式下运行应用程序并查看 http://localhost:3000/docs。
|
||||
|
||||
```package-install
|
||||
```mdx
|
||||
npm run dev
|
||||
```
|
||||
|
||||
@ -250,4 +250,4 @@ export const source = loader({
|
||||
|
||||
## 了解更多
|
||||
|
||||
刚来这里?别担心,我们欢迎您的问题。
|
||||
刚来这里?别担心,我们欢迎您的问题。
|
||||
|
@ -2,7 +2,7 @@
|
||||
title: Internationalization
|
||||
description: Support multiple languages in your documentation
|
||||
image: /images/blog/post-3.png
|
||||
date: 2025-03-15T12:00:00.000Z
|
||||
date: "2025-03-15"
|
||||
published: true
|
||||
categories: [company, product]
|
||||
author: mksaas
|
||||
|
@ -2,7 +2,7 @@
|
||||
title: 国际化
|
||||
description: 在您的文档中支持多种语言
|
||||
image: /images/blog/post-3.png
|
||||
date: 2025-03-15T12:00:00.000Z
|
||||
date: "2025-03-15"
|
||||
published: true
|
||||
categories: [company, product]
|
||||
author: mksaas
|
||||
@ -224,4 +224,4 @@ return <Link href={`/${lang}/another-page`}>This is a link</Link>;
|
||||
import { DynamicLink } from 'fumadocs-core/dynamic-link';
|
||||
|
||||
<DynamicLink href="/[lang]/another-page">This is a link</DynamicLink>
|
||||
```
|
||||
```
|
@ -2,7 +2,7 @@
|
||||
title: Manual Installation
|
||||
description: Create a new fumadocs project from scratch.
|
||||
image: /images/blog/post-4.png
|
||||
date: 2025-03-14T12:00:00.000Z
|
||||
date: "2025-03-14"
|
||||
published: true
|
||||
categories: [company, product]
|
||||
author: mkdirs
|
||||
@ -14,7 +14,7 @@ author: mkdirs
|
||||
|
||||
Create a new Next.js application with `create-next-app`, and install required packages.
|
||||
|
||||
```package-install
|
||||
```mdx
|
||||
fumadocs-ui fumadocs-core
|
||||
```
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
title: 手动安装
|
||||
description: 从零开始创建一个新的 Fumadocs 项目
|
||||
image: /images/blog/post-4.png
|
||||
date: 2025-03-14T12:00:00.000Z
|
||||
date: "2025-03-14"
|
||||
published: true
|
||||
categories: [company, product]
|
||||
author: mkdirs
|
||||
@ -14,7 +14,7 @@ author: mkdirs
|
||||
|
||||
使用 `create-next-app` 创建一个新的 Next.js 应用程序,并安装所需的包。
|
||||
|
||||
```package-install
|
||||
```mdx
|
||||
fumadocs-ui fumadocs-core
|
||||
```
|
||||
|
||||
@ -193,4 +193,4 @@ WORKDIR /app
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* source.config.ts ./
|
||||
```
|
||||
|
||||
这确保 Fumadocs MDX 在构建期间可以访问您的配置文件。
|
||||
这确保 Fumadocs MDX 在构建期间可以访问您的配置文件。
|
||||
|
@ -2,7 +2,7 @@
|
||||
title: Markdown
|
||||
description: How to write documents
|
||||
image: /images/blog/post-5.png
|
||||
date: 2025-03-05T12:00:00.000Z
|
||||
date: "2025-03-05"
|
||||
published: true
|
||||
categories: [news, company]
|
||||
author: mkdirs
|
||||
@ -353,12 +353,12 @@ Some optional plugins you can enable.
|
||||
Write math equations with TeX.
|
||||
|
||||
````md
|
||||
```math
|
||||
```mdx
|
||||
f(x) = x * e^{2 pi i \xi x}
|
||||
```
|
||||
````
|
||||
|
||||
```math
|
||||
```mdx
|
||||
f(x) = x * e^{2 pi i \xi x}
|
||||
```
|
||||
|
||||
@ -369,12 +369,12 @@ To enable, see [Math Integration](/docs/math).
|
||||
Generate code blocks for installing packages via package managers (JS/Node.js).
|
||||
|
||||
````md
|
||||
```package-install
|
||||
```mdx
|
||||
npm i next -D
|
||||
```
|
||||
````
|
||||
|
||||
```package-install
|
||||
```mdx
|
||||
npm i next -D
|
||||
```
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
title: Markdown
|
||||
description: 如何撰写文档
|
||||
image: /images/blog/post-5.png
|
||||
date: 2025-03-05T12:00:00.000Z
|
||||
date: "2025-03-05"
|
||||
published: true
|
||||
categories: [news, company]
|
||||
author: mkdirs
|
||||
@ -251,7 +251,7 @@ console.log('Hello World');
|
||||
```
|
||||
````
|
||||
|
||||
### 高亮行
|
||||
### 高亮行
|
||||
|
||||
````md
|
||||
```tsx
|
||||
|
56
content/blog/premium.mdx
Normal file
56
content/blog/premium.mdx
Normal file
@ -0,0 +1,56 @@
|
||||
---
|
||||
title: "Premium Blog Post"
|
||||
description: "This blog post is a test for premium content."
|
||||
date: "2025-08-30"
|
||||
published: true
|
||||
premium: true
|
||||
categories: ["product"]
|
||||
author: "fox"
|
||||
image: "/images/blog/post-7.png"
|
||||
---
|
||||
|
||||
This blog post is a test for premium content.
|
||||
|
||||
You can read this part of the blog post if you are not a premium user.
|
||||
|
||||
But for the rest of the blog post, you need to be logged in as a premium user.
|
||||
|
||||
You can click the "Sign In" button to sign in as a user with free plan.
|
||||
|
||||
Then you can click the "Upgrade Now" button to upgrade to a premium plan.
|
||||
|
||||
<Callout type="warn">
|
||||
Don't worry, you don't actually pay any cents, because we are in the sandbox environment of Stripe.
|
||||
</Callout>
|
||||
|
||||
You can use the test card number to pay for monthly or yearly PRO plan or LIFETIME plan.
|
||||
|
||||
```
|
||||
Card number: 4242 4242 4242 4242
|
||||
Exp: 12/34
|
||||
CVV: 567
|
||||
```
|
||||
|
||||
After that, you can return to the blog post and you can read the rest of the blog post.
|
||||
|
||||
For more details, please check out the documentation: [Blog](https://mksaas.com/docs/blog).
|
||||
|
||||
Now the rest of the blog post is premium content.
|
||||
|
||||
<PremiumContent>
|
||||
|
||||
<Callout type="info">
|
||||
This is the beginning of the premium content part.
|
||||
</Callout>
|
||||
|
||||
This is the premium content part.
|
||||
|
||||
You can read this paragraph only if you are a premium user.
|
||||
|
||||
Please don't share this blog post with others.
|
||||
|
||||
<Callout type="info">
|
||||
This is the end of the premium content part.
|
||||
</Callout>
|
||||
|
||||
</PremiumContent>
|
56
content/blog/premium.zh.mdx
Normal file
56
content/blog/premium.zh.mdx
Normal file
@ -0,0 +1,56 @@
|
||||
---
|
||||
title: "测试专用付费文章"
|
||||
description: "这是一篇测试专用付费文章。"
|
||||
date: "2025-08-30"
|
||||
published: true
|
||||
premium: true
|
||||
categories: ["product"]
|
||||
author: "fox"
|
||||
image: "/images/blog/post-7.png"
|
||||
---
|
||||
|
||||
这是一篇测试专用的付费文章。
|
||||
|
||||
如果你不是付费用户,你可以阅读这篇文章的这部分内容。
|
||||
|
||||
但如果你想阅读剩下的内容,你需要成为一个付费用户。
|
||||
|
||||
你可以点击 "登录" 按钮来以免费用户的身份登录。
|
||||
|
||||
然后你可以点击 "立即升级" 按钮来升级到付费计划。
|
||||
|
||||
<Callout type="warn">
|
||||
不用担心,你实际上不需要支付任何费用,因为我们处于 Stripe 的沙盒环境中。
|
||||
</Callout>
|
||||
|
||||
你可以使用测试卡号来支付月度或年度 PRO 计划或终身计划。
|
||||
|
||||
```
|
||||
Card number: 4242 4242 4242 4242
|
||||
Exp: 12/34
|
||||
CVV: 567
|
||||
```
|
||||
|
||||
之后,你可以返回这篇博客文章,然后你可以阅读剩下的内容。
|
||||
|
||||
更多详情,请参考文档:[博客](https://mksaas.com/docs/blog)。
|
||||
|
||||
现在剩下的内容是付费内容。
|
||||
|
||||
<PremiumContent>
|
||||
|
||||
<Callout type="info">
|
||||
这是付费内容部分的开始。
|
||||
</Callout>
|
||||
|
||||
这是付费内容部分。
|
||||
|
||||
你可以阅读这篇内容,只要你是一个付费用户。
|
||||
|
||||
请不要分享这篇文章给其他人。
|
||||
|
||||
<Callout type="info">
|
||||
这是付费内容部分的结束。
|
||||
</Callout>
|
||||
|
||||
</PremiumContent>
|
@ -2,7 +2,7 @@
|
||||
title: Search
|
||||
description: Implement document search in your docs
|
||||
image: /images/blog/post-6.png
|
||||
date: 2025-02-15T12:00:00.000Z
|
||||
date: "2025-02-15"
|
||||
published: true
|
||||
categories: [company, news]
|
||||
author: mksaas
|
||||
|
@ -2,7 +2,7 @@
|
||||
title: 搜索
|
||||
description: 在您的文档中实现文档搜索
|
||||
image: /images/blog/post-6.png
|
||||
date: 2025-02-15T12:00:00.000Z
|
||||
date: "2025-02-15"
|
||||
published: true
|
||||
categories: [company, news]
|
||||
author: mksaas
|
||||
@ -249,4 +249,4 @@ export default function CustomSearchDialog(props: SharedProps) {
|
||||
```
|
||||
|
||||
1. 将 `endpoint`、`apiKey` 替换为您想要的值。
|
||||
2. 用您的新组件[替换默认搜索对话框](#replace-search-dialog)。
|
||||
2. 用您的新组件[替换默认搜索对话框](#replace-search-dialog)。
|
@ -2,7 +2,7 @@
|
||||
title: Themes
|
||||
description: Add Theme to Fumadocs UI
|
||||
image: /images/blog/post-7.png
|
||||
date: 2025-01-15T12:00:00.000Z
|
||||
date: "2025-01-15"
|
||||
published: true
|
||||
categories: [product, news]
|
||||
author: mkdirs
|
||||
|
@ -2,7 +2,7 @@
|
||||
title: 主题
|
||||
description: 为 Fumadocs UI 添加主题
|
||||
image: /images/blog/post-7.png
|
||||
date: 2025-01-15T12:00:00.000Z
|
||||
date: "2025-01-15"
|
||||
published: true
|
||||
categories: [product, news]
|
||||
author: mkdirs
|
||||
@ -167,4 +167,4 @@ Tailwind CSS 预设引入了新的颜色和额外的工具,包括 `fd-steps`
|
||||
```
|
||||
|
||||
> 该插件仅与 Fumadocs UI 的 MDX 组件一起工作,它可能与 `@tailwindcss/typography` 冲突。
|
||||
> 如果您需要使用 `@tailwindcss/typography` 而不是默认插件,请[设置类名选项](https://github.com/tailwindlabs/tailwindcss-typography/blob/main/README.md#changing-the-default-class-name)以避免冲突。
|
||||
> 如果您需要使用 `@tailwindcss/typography` 而不是默认插件,请[设置类名选项](https://github.com/tailwindlabs/tailwindcss-typography/blob/main/README.md#changing-the-default-class-name)以避免冲突。
|
@ -2,7 +2,7 @@
|
||||
title: What is Fumadocs
|
||||
description: Introducing Fumadocs, a docs framework that you can break.
|
||||
image: /images/blog/post-1.png
|
||||
date: 2025-04-01T12:00:00.000Z
|
||||
date: "2025-04-01"
|
||||
published: true
|
||||
categories: [company, product]
|
||||
author: fox
|
||||
|
@ -2,7 +2,7 @@
|
||||
title: 什么是 Fumadocs
|
||||
description: 介绍 Fumadocs,一个可以打破常规的文档框架
|
||||
image: /images/blog/post-1.png
|
||||
date: 2025-04-01T12:00:00.000Z
|
||||
date: "2025-04-01"
|
||||
published: true
|
||||
categories: [company, product]
|
||||
author: fox
|
||||
@ -57,4 +57,4 @@ Fumadocs 为 Next.js 提供了额外的工具,包括语法高亮、文档搜
|
||||
|
||||
Fumadocs 由 Fuma 和许多贡献者维护,关注代码库的可维护性。
|
||||
虽然我们不打算提供人们想要的每一项功能,但我们更专注于使基本功能完美且维护良好。
|
||||
您也可以通过贡献来帮助 Fumadocs 变得更加有用!
|
||||
您也可以通过贡献来帮助 Fumadocs 变得更加有用!
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: company
|
||||
name: Company
|
||||
description: Company news and updates
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: company
|
||||
name: 公司
|
||||
description: 公司新闻和更新
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: news
|
||||
name: News
|
||||
description: News and updates about MkSaaS
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: news
|
||||
name: 新闻
|
||||
description: 最新新闻和更新
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: product
|
||||
name: Product
|
||||
description: Products and services powered by MkSaaS
|
||||
---
|
||||
|
@ -1,5 +1,4 @@
|
||||
---
|
||||
slug: product
|
||||
name: 产品
|
||||
description: 产品和服务
|
||||
---
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "Initial Release"
|
||||
description: "Our first official release with core features and functionality"
|
||||
date: "2024-03-01T00:00:00Z"
|
||||
date: "2024-03-01"
|
||||
version: "v1.0.0"
|
||||
published: true
|
||||
---
|
||||
@ -27,4 +27,4 @@ We're excited to announce the initial release of our platform with the following
|
||||
|
||||
- Fixed issues with user registration flow
|
||||
- Resolved authentication token expiration handling
|
||||
- Improved form validation and error messages
|
||||
- Improved form validation and error messages
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "初始版本"
|
||||
description: "我们的第一个正式版本,包含核心功能"
|
||||
date: "2024-03-01T00:00:00Z"
|
||||
date: "2024-03-01"
|
||||
version: "v1.0.0"
|
||||
published: true
|
||||
---
|
||||
@ -27,4 +27,4 @@ published: true
|
||||
|
||||
- 修复了用户注册流程中的问题
|
||||
- 解决了身份验证令牌过期处理
|
||||
- 改进了表单验证和错误消息
|
||||
- 改进了表单验证和错误消息
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "Feature Update"
|
||||
description: "New features and improvements to enhance your experience"
|
||||
date: "2024-03-15T00:00:00Z"
|
||||
date: "2024-03-15"
|
||||
version: "v1.1.0"
|
||||
published: true
|
||||
---
|
||||
@ -27,4 +27,4 @@ We've added several new features to improve your experience:
|
||||
- Fixed issue with project duplication
|
||||
- Resolved calendar sync problems
|
||||
- Fixed data import validation errors
|
||||
- Improved error handling for API requests
|
||||
- Improved error handling for API requests
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "功能更新"
|
||||
description: "新功能和改进,提升您的使用体验"
|
||||
date: "2024-03-15T00:00:00Z"
|
||||
date: "2024-03-15"
|
||||
version: "v1.1.0"
|
||||
published: true
|
||||
---
|
||||
@ -27,4 +27,4 @@ published: true
|
||||
- 修复了项目复制问题
|
||||
- 解决了日历同步问题
|
||||
- 修复了数据导入验证错误
|
||||
- 改进了API请求的错误处理
|
||||
- 改进了API请求的错误处理
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "AI Integration"
|
||||
description: "Introducing AI-powered features to boost productivity"
|
||||
date: "2024-03-30T00:00:00Z"
|
||||
date: "2024-03-30"
|
||||
version: "v1.2.0"
|
||||
published: true
|
||||
---
|
||||
@ -34,4 +34,4 @@ We're thrilled to introduce our new AI capabilities:
|
||||
- Fixed issues with file uploads on certain browsers
|
||||
- Resolved synchronization issues between devices
|
||||
- Improved error handling for third-party integrations
|
||||
- Fixed accessibility issues in the dashboard
|
||||
- Fixed accessibility issues in the dashboard
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "AI集成"
|
||||
description: "引入AI驱动的功能,提高生产力"
|
||||
date: "2024-03-30T00:00:00Z"
|
||||
date: "2024-03-30"
|
||||
version: "v1.2.0"
|
||||
published: true
|
||||
---
|
||||
@ -34,4 +34,4 @@ published: true
|
||||
- 修复了某些浏览器上文件上传的问题
|
||||
- 解决了设备之间的同步问题
|
||||
- 改进了第三方集成的错误处理
|
||||
- 修复了仪表板中的可访问性问题
|
||||
- 修复了仪表板中的可访问性问题
|
@ -2,4 +2,4 @@
|
||||
title: 组件
|
||||
description: 改进文档的额外组件
|
||||
index: true
|
||||
---
|
||||
---
|
||||
|
@ -46,6 +46,6 @@ Since the design system is built on Tailwind CSS, you can customise it [with CSS
|
||||
|
||||
If none of them suits you, Fumadocs CLI is a tool to install Fumadocs UI components and layouts to your codebase, similar to Shadcn UI. Allowing you to fully customise Fumadocs UI:
|
||||
|
||||
```package-install
|
||||
```mdx
|
||||
npx fumadocs add
|
||||
```
|
||||
|
@ -46,6 +46,6 @@ Fumadocs UI 还提供了样式化组件,用于交互式示例以增强您的
|
||||
|
||||
如果这些都不适合您,Fumadocs CLI 是一个工具,可以将 Fumadocs UI 组件和布局安装到您的代码库中,类似于 Shadcn UI。允许您完全自定义 Fumadocs UI:
|
||||
|
||||
```package-install
|
||||
```mdx
|
||||
npx fumadocs add
|
||||
```
|
||||
```
|
||||
|
@ -95,7 +95,7 @@ title: Hello World
|
||||
|
||||
Run the app in development mode and see http://localhost:3000/docs.
|
||||
|
||||
```package-install
|
||||
```mdx
|
||||
npm run dev
|
||||
```
|
||||
|
||||
|
@ -95,7 +95,7 @@ title: Hello World
|
||||
|
||||
在开发模式下运行应用程序并查看 http://localhost:3000/docs。
|
||||
|
||||
```package-install
|
||||
```mdx
|
||||
npm run dev
|
||||
```
|
||||
|
||||
@ -250,4 +250,4 @@ export const source = loader({
|
||||
|
||||
## 了解更多
|
||||
|
||||
刚来这里?别担心,我们欢迎您的问题。
|
||||
刚来这里?别担心,我们欢迎您的问题。
|
||||
|
@ -9,7 +9,7 @@ description: Create a new fumadocs project from scratch.
|
||||
|
||||
Create a new Next.js application with `create-next-app`, and install required packages.
|
||||
|
||||
```package-install
|
||||
```mdx
|
||||
fumadocs-ui fumadocs-core
|
||||
```
|
||||
|
||||
|
@ -9,7 +9,7 @@ description: 从零开始创建一个新的 Fumadocs 项目
|
||||
|
||||
使用 `create-next-app` 创建一个新的 Next.js 应用程序,并安装所需的包。
|
||||
|
||||
```package-install
|
||||
```mdx
|
||||
fumadocs-ui fumadocs-core
|
||||
```
|
||||
|
||||
@ -188,4 +188,4 @@ WORKDIR /app
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* source.config.ts ./
|
||||
```
|
||||
|
||||
这确保 Fumadocs MDX 在构建期间可以访问您的配置文件。
|
||||
这确保 Fumadocs MDX 在构建期间可以访问您的配置文件。
|
||||
|
@ -348,12 +348,12 @@ Some optional plugins you can enable.
|
||||
Write math equations with TeX.
|
||||
|
||||
````md
|
||||
```math
|
||||
```mdx
|
||||
f(x) = x * e^{2 pi i \xi x}
|
||||
```
|
||||
````
|
||||
|
||||
```math
|
||||
```mdx
|
||||
f(x) = x * e^{2 pi i \xi x}
|
||||
```
|
||||
|
||||
@ -364,12 +364,12 @@ To enable, see [Math Integration](/docs/math).
|
||||
Generate code blocks for installing packages via package managers (JS/Node.js).
|
||||
|
||||
````md
|
||||
```package-install
|
||||
```mdx
|
||||
npm i next -D
|
||||
```
|
||||
````
|
||||
|
||||
```package-install
|
||||
```mdx
|
||||
npm i next -D
|
||||
```
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
title: What is Fumadocs
|
||||
description: Introducing Fumadocs, a docs framework that you can break.
|
||||
icon: CircleHelp
|
||||
premium: true
|
||||
---
|
||||
|
||||
Fumadocs was created because I wanted a more customisable experience for building docs, to be a docs framework that is not opinionated, **a "framework" that you can break**.
|
||||
@ -18,6 +19,8 @@ You are still using features of Next.js App Router, like **Static Site Generatio
|
||||
**Opinionated on UI:** The only thing Fumadocs UI (the default theme) offers is **User Interface**. The UI is opinionated for bringing better mobile responsiveness and user experience.
|
||||
Instead, we use a much more flexible approach inspired by Shadcn UI — [Fumadocs CLI](/docs/cli), so we can iterate our design quick, and welcome for more feedback about the UI.
|
||||
|
||||
<PremiumContent>
|
||||
|
||||
## Why Fumadocs
|
||||
|
||||
Fumadocs is designed with flexibility in mind.
|
||||
@ -56,3 +59,5 @@ docs easier, with less boilerplate.
|
||||
Fumadocs is maintained by Fuma and many contributors, with care on the maintainability of codebase.
|
||||
While we don't aim to offer every functionality people wanted, we're more focused on making basic features perfect and well-maintained.
|
||||
You can also help Fumadocs to be more useful by contributing!
|
||||
|
||||
</PremiumContent>
|
||||
|
@ -2,6 +2,7 @@
|
||||
title: 什么是 Fumadocs
|
||||
description: 介绍 Fumadocs,一个可以打破常规的文档框架
|
||||
icon: CircleHelp
|
||||
premium: true
|
||||
---
|
||||
|
||||
Fumadocs 的创建是因为我想要一种更加可定制化的文档构建体验,一个不固执己见的文档框架,**一个你可以"打破"的"框架"**。
|
||||
@ -18,6 +19,8 @@ Fumadocs 的创建是因为我想要一种更加可定制化的文档构建体
|
||||
**对 UI 有自己的看法:** Fumadocs UI(默认主题)提供的唯一东西是**用户界面**。UI 的设计理念是提供更好的移动响应性和用户体验。
|
||||
相反,我们使用受 Shadcn UI 启发的更灵活的方法 — [Fumadocs CLI](/docs/cli),这样我们可以快速迭代设计,并欢迎更多关于 UI 的反馈。
|
||||
|
||||
<PremiumContent>
|
||||
|
||||
## 为什么选择 Fumadocs
|
||||
|
||||
Fumadocs 的设计考虑了灵活性。
|
||||
@ -53,4 +56,6 @@ Fumadocs 为 Next.js 提供了额外的工具,包括语法高亮、文档搜
|
||||
|
||||
Fumadocs 由 Fuma 和许多贡献者维护,关注代码库的可维护性。
|
||||
虽然我们不打算提供人们想要的每一项功能,但我们更专注于使基本功能完美且维护良好。
|
||||
您也可以通过贡献来帮助 Fumadocs 变得更加有用!
|
||||
您也可以通过贡献来帮助 Fumadocs 变得更加有用!
|
||||
|
||||
</PremiumContent>
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Cookie Policy
|
||||
description: How we use cookies and similar technologies on our website
|
||||
date: 2025-03-10T00:00:00.000Z
|
||||
date: "2025-03-10"
|
||||
published: true
|
||||
---
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Cookie 政策
|
||||
description: 我们如何在网站上使用 Cookie 和类似技术
|
||||
date: 2025-03-10T00:00:00.000Z
|
||||
date: "2025-03-10"
|
||||
published: true
|
||||
---
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Privacy Policy
|
||||
description: Our commitment to protecting your privacy and personal data
|
||||
date: 2025-03-10T00:00:00.000Z
|
||||
date: "2025-03-10"
|
||||
published: true
|
||||
---
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 隐私政策
|
||||
description: 我们致力于保护您的隐私和个人数据
|
||||
date: 2025-03-10T00:00:00.000Z
|
||||
date: "2025-03-10"
|
||||
published: true
|
||||
---
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Terms of Service
|
||||
description: The terms and conditions governing the use of our services
|
||||
date: 2025-03-10T00:00:00.000Z
|
||||
date: "2025-03-10"
|
||||
published: true
|
||||
---
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 服务条款
|
||||
description: 管理我们服务使用的条款和条件
|
||||
date: 2025-03-10T00:00:00.000Z
|
||||
date: "2025-03-10"
|
||||
published: true
|
||||
---
|
||||
|
||||
|
1
dev.vars.example
Normal file
1
dev.vars.example
Normal file
@ -0,0 +1 @@
|
||||
NEXTJS_ENV=development
|
@ -5,7 +5,7 @@ import { defineConfig } from 'drizzle-kit';
|
||||
* https://orm.drizzle.team/docs/get-started/neon-new#step-5---setup-drizzle-config-file
|
||||
*/
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
out: './src/db/migrations',
|
||||
schema: './src/db/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
|
@ -1,13 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1744304844165,
|
||||
"tag": "0000_fine_sir_ram",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
88
env.example
88
env.example
@ -8,13 +8,13 @@ NEXT_PUBLIC_BASE_URL="http://localhost:3000"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database
|
||||
# https://mksaas.com/docs/database#setup
|
||||
# https://mksaas.com/docs/database
|
||||
# -----------------------------------------------------------------------------
|
||||
DATABASE_URL=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Better Auth
|
||||
# https://mksaas.com/docs/auth#setup
|
||||
# https://mksaas.com/docs/auth
|
||||
# Generate a random string for the secret key using `openssl rand -base64 32`
|
||||
# -----------------------------------------------------------------------------
|
||||
BETTER_AUTH_SECRET=""
|
||||
@ -39,8 +39,8 @@ GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Email / Newsletter (Resend)
|
||||
# https://mksaas.com/docs/email#setup
|
||||
# https://mksaas.com/docs/newsletter#setup
|
||||
# https://mksaas.com/docs/email
|
||||
# https://mksaas.com/docs/newsletter
|
||||
# Get API key and audience id from https://resend.com
|
||||
# -----------------------------------------------------------------------------
|
||||
RESEND_API_KEY=""
|
||||
@ -48,7 +48,7 @@ RESEND_AUDIENCE_ID=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Storage (Cloudflare R2 or S3-compatible service of your choice)
|
||||
# https://mksaas.com/docs/storage#setup
|
||||
# https://mksaas.com/docs/storage
|
||||
# Cloudflare R2: https://www.cloudflare.com/developer-platform/products/r2
|
||||
# -----------------------------------------------------------------------------
|
||||
STORAGE_REGION="auto"
|
||||
@ -56,12 +56,11 @@ STORAGE_BUCKET_NAME=""
|
||||
STORAGE_ACCESS_KEY_ID=""
|
||||
STORAGE_SECRET_ACCESS_KEY=""
|
||||
STORAGE_ENDPOINT=""
|
||||
STORAGE_FORCE_PATH_STYLE="false"
|
||||
STORAGE_PUBLIC_URL=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Payment (Stripe)
|
||||
# https://mksaas.com/docs/payment#setup
|
||||
# https://mksaas.com/docs/payment
|
||||
# Get Stripe key and secret from https://dashboard.stripe.com
|
||||
# -----------------------------------------------------------------------------
|
||||
STRIPE_SECRET_KEY=""
|
||||
@ -72,18 +71,29 @@ NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=""
|
||||
NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=""
|
||||
# Lifetime plan - one-time payment
|
||||
NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=""
|
||||
# Credit package - basic
|
||||
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=""
|
||||
# Credit package - standard
|
||||
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STANDARD=""
|
||||
# Credit package - premium
|
||||
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PREMIUM=""
|
||||
# Credit package - enterprise
|
||||
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_ENTERPRISE=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configurations
|
||||
# -----------------------------------------------------------------------------
|
||||
# Disable image optimization, check out next.config.ts for more details
|
||||
# -----------------------------------------------------------------------------
|
||||
DISABLE_IMAGE_OPTIMIZATION=false
|
||||
# -----------------------------------------------------------------------------
|
||||
# Run this website as demo website, in most cases, you should set this to false
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_DEMO_WEBSITE=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Analytics
|
||||
# https://mksaas.com/docs/analytics#setup
|
||||
# https://mksaas.com/docs/analytics
|
||||
# -----------------------------------------------------------------------------
|
||||
# Google Analytics (https://analytics.google.com)
|
||||
# https://mksaas.com/docs/analytics#google
|
||||
@ -120,18 +130,68 @@ NEXT_PUBLIC_SELINE_TOKEN=""
|
||||
# DataFast Analytics (https://datafa.st)
|
||||
# https://mksaas.com/docs/analytics#datafast
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_DATAFAST_ANALYTICS_ID=""
|
||||
NEXT_PUBLIC_DATAFAST_ANALYTICS_DOMAIN=""
|
||||
NEXT_PUBLIC_DATAFAST_WEBSITE_ID=""
|
||||
NEXT_PUBLIC_DATAFAST_DOMAIN=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Discord
|
||||
# Notification (Discord)
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_DISCORD_WIDGET_SERVER_ID=""
|
||||
NEXT_PUBLIC_DISCORD_WIDGET_CHANNEL_ID=""
|
||||
DISCORD_WEBHOOK_URL=""
|
||||
# -----------------------------------------------------------------------------
|
||||
# Notification (Feishu)
|
||||
# -----------------------------------------------------------------------------
|
||||
FEISHU_WEBHOOK_URL=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Affiliate (Affonso)
|
||||
# Affiliate
|
||||
# https://mksaas.com/docs/affiliate
|
||||
# -----------------------------------------------------------------------------
|
||||
# Affonso
|
||||
# https://affonso.com/
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_AFFILIATE_AFFONSO_ID=""
|
||||
# -----------------------------------------------------------------------------
|
||||
# PromoteKit
|
||||
# https://www.promotekit.com/
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_AFFILIATE_PROMOTEKIT_ID=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Captcha (Cloudflare Turnstile)
|
||||
# https://mksaas.com/docs/captcha
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
|
||||
TURNSTILE_SECRET_KEY=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Crisp
|
||||
# https://mksaas.com/docs/chat
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_CRISP_WEBSITE_ID=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Cron Jobs
|
||||
# https://mksaas.com/docs/cronjobs
|
||||
# -----------------------------------------------------------------------------
|
||||
CRON_JOBS_USERNAME=""
|
||||
CRON_JOBS_PASSWORD=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# AI
|
||||
# https://mksaas.com/docs/ai
|
||||
# -----------------------------------------------------------------------------
|
||||
AI_GATEWAY_API_KEY=""
|
||||
FAL_API_KEY=""
|
||||
FIREWORKS_API_KEY=""
|
||||
OPENAI_API_KEY=""
|
||||
REPLICATE_API_TOKEN=""
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=""
|
||||
DEEPSEEK_API_KEY=""
|
||||
OPENROUTER_API_KEY=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Web Content Analyzer (Firecrawl)
|
||||
# https://firecrawl.dev/
|
||||
# -----------------------------------------------------------------------------
|
||||
FIRECRAWL_API_KEY=""
|
||||
|
214
messages/en.json
214
messages/en.json
@ -5,6 +5,7 @@
|
||||
"description": "MkSaaS is the best AI SaaS boilerplate. Make AI SaaS in days, simply and effortlessly"
|
||||
},
|
||||
"Common": {
|
||||
"premium": "Premium",
|
||||
"login": "Log in",
|
||||
"logout": "Log out",
|
||||
"signUp": "Sign up",
|
||||
@ -28,7 +29,21 @@
|
||||
"save": "Save",
|
||||
"loading": "Loading...",
|
||||
"cancel": "Cancel",
|
||||
"logoutFailed": "Failed to log out"
|
||||
"logoutFailed": "Failed to log out",
|
||||
"table": {
|
||||
"totalRecords": "Total {count} records",
|
||||
"noResults": "No results",
|
||||
"loading": "Loading...",
|
||||
"columns": "Columns",
|
||||
"rowsPerPage": "Rows per page",
|
||||
"page": "Page",
|
||||
"firstPage": "First Page",
|
||||
"lastPage": "Last Page",
|
||||
"nextPage": "Next Page",
|
||||
"previousPage": "Previous Page",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc"
|
||||
}
|
||||
},
|
||||
"PricingPage": {
|
||||
"title": "Pricing",
|
||||
@ -99,6 +114,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreditPackages": {
|
||||
"basic": {
|
||||
"name": "Basic",
|
||||
"description": "Basic credits package description"
|
||||
},
|
||||
"standard": {
|
||||
"name": "Standard",
|
||||
"description": "Standard credits package description"
|
||||
},
|
||||
"premium": {
|
||||
"name": "Premium",
|
||||
"description": "Premium credits package description"
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "Enterprise",
|
||||
"description": "Enterprise credits package description"
|
||||
}
|
||||
},
|
||||
"NotFoundPage": {
|
||||
"title": "404",
|
||||
"message": "Sorry, the page you are looking for does not exist.",
|
||||
@ -187,7 +220,9 @@
|
||||
"hidePassword": "Hide password",
|
||||
"or": "Or continue with",
|
||||
"emailRequired": "Please enter your email",
|
||||
"passwordRequired": "Please enter your password"
|
||||
"passwordRequired": "Please enter your password",
|
||||
"captchaInvalid": "Captcha verification failed",
|
||||
"captchaError": "Captcha verification error"
|
||||
},
|
||||
"register": {
|
||||
"title": "Register",
|
||||
@ -202,7 +237,9 @@
|
||||
"hidePassword": "Hide password",
|
||||
"nameRequired": "Please enter your name",
|
||||
"emailRequired": "Please enter your email",
|
||||
"passwordRequired": "Please enter your password"
|
||||
"passwordRequired": "Please enter your password",
|
||||
"captchaInvalid": "Captcha verification failed",
|
||||
"captchaError": "Captcha verification error"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Forgot Password",
|
||||
@ -256,8 +293,20 @@
|
||||
"nextPage": "Next",
|
||||
"chooseLanguage": "Select language",
|
||||
"title": "MkSaaS Docs",
|
||||
"homepage": "Homepage",
|
||||
"blog": "Blog"
|
||||
"homepage": "Homepage"
|
||||
},
|
||||
"PremiumContent": {
|
||||
"title": "Unlock Premium Content",
|
||||
"description": "Subscribe to our Pro plan to access all premium content and exclusive content.",
|
||||
"upgradeCta": "Upgrade Now",
|
||||
"benefit1": "All premium content",
|
||||
"benefit2": "Exclusive content",
|
||||
"benefit3": "Cancel anytime",
|
||||
"signIn": "Sign In",
|
||||
"loginRequired": "Sign in to continue reading",
|
||||
"loginDescription": "This is premium content. Sign in to your account to access the full content.",
|
||||
"checkingAccess": "Checking access...",
|
||||
"loadingContent": "Loading full content..."
|
||||
},
|
||||
"Marketing": {
|
||||
"navbar": {
|
||||
@ -284,6 +333,10 @@
|
||||
"title": "AI Image",
|
||||
"description": "Show how to use AI to generate beautiful images"
|
||||
},
|
||||
"chat": {
|
||||
"title": "AI Chat",
|
||||
"description": "Show how to use AI to chat with your customers"
|
||||
},
|
||||
"video": {
|
||||
"title": "AI Video",
|
||||
"description": "Show how to use AI to generate amazing videos"
|
||||
@ -430,6 +483,7 @@
|
||||
"avatar": {
|
||||
"dashboard": "Dashboard",
|
||||
"billing": "Billing",
|
||||
"credits": "Credits",
|
||||
"settings": "Settings"
|
||||
}
|
||||
},
|
||||
@ -455,14 +509,6 @@
|
||||
"banReason": "Ban Reason",
|
||||
"banExpires": "Ban Expires"
|
||||
},
|
||||
"noResults": "No results",
|
||||
"firstPage": "First Page",
|
||||
"lastPage": "Last Page",
|
||||
"nextPage": "Next Page",
|
||||
"previousPage": "Previous Page",
|
||||
"rowsPerPage": "Rows per page",
|
||||
"page": "Page",
|
||||
"loading": "Loading...",
|
||||
"admin": "Admin",
|
||||
"user": "User",
|
||||
"email": {
|
||||
@ -472,8 +518,8 @@
|
||||
"emailCopied": "Email copied to clipboard",
|
||||
"banned": "Banned",
|
||||
"active": "Active",
|
||||
"joined": "Joined",
|
||||
"updated": "Updated",
|
||||
"joined": "Joined at",
|
||||
"updated": "Updated at",
|
||||
"ban": {
|
||||
"reason": "Ban Reason",
|
||||
"reasonPlaceholder": "Enter the reason for banning this user",
|
||||
@ -544,7 +590,8 @@
|
||||
"createCustomerPortalFailed": "Failed to open Stripe customer portal"
|
||||
},
|
||||
"price": "Price:",
|
||||
"nextBillingDate": "Next billing date:",
|
||||
"periodStartDate": "Period start date:",
|
||||
"periodEndDate": "Period end date:",
|
||||
"trialEnds": "Trial ends:",
|
||||
"freePlanMessage": "You are currently on the free plan with limited features",
|
||||
"lifetimeMessage": "You have lifetime access to all premium features",
|
||||
@ -552,7 +599,77 @@
|
||||
"manageBilling": "Manage Billing",
|
||||
"upgradePlan": "Upgrade Plan",
|
||||
"retry": "Retry",
|
||||
"errorMessage": "Failed to get data"
|
||||
"errorMessage": "Failed to get data",
|
||||
"paymentSuccess": "Payment successful"
|
||||
},
|
||||
"credits": {
|
||||
"title": "Credits",
|
||||
"description": "Manage your credit transactions",
|
||||
"tabs": {
|
||||
"balance": "Balance",
|
||||
"transactions": "Transactions"
|
||||
},
|
||||
"balance": {
|
||||
"title": "Credit Balance",
|
||||
"description": "Your credit balance",
|
||||
"credits": "Credits",
|
||||
"creditsDescription": "You have {credits} credits",
|
||||
"creditsExpired": "Credits expired",
|
||||
"creditsAdded": "Credits have been added to your account",
|
||||
"viewTransactions": "View Credit Transactions",
|
||||
"retry": "Retry",
|
||||
"expiringCredits": "{credits} credits expiring in the next {days} days"
|
||||
},
|
||||
"packages": {
|
||||
"title": "Credit Packages",
|
||||
"description": "Purchase additional credits to use our services",
|
||||
"purchase": "Purchase",
|
||||
"processing": "Processing...",
|
||||
"popular": "Popular",
|
||||
"completePurchase": "Complete Your Purchase",
|
||||
"failedToFetchCredits": "Failed to fetch credits",
|
||||
"failedToCreatePaymentIntent": "Failed to create payment intent",
|
||||
"failedToInitiatePayment": "Failed to initiate payment",
|
||||
"cancel": "Cancel",
|
||||
"purchaseFailed": "Purchase credits failed",
|
||||
"checkoutFailed": "Failed to create checkout session",
|
||||
"loading": "Loading...",
|
||||
"pay": "Pay"
|
||||
},
|
||||
"transactions": {
|
||||
"title": "Credit Transactions",
|
||||
"error": "Failed to get credit transactions",
|
||||
"search": "Search credit transactions...",
|
||||
"paymentIdCopied": "Payment ID copied to clipboard",
|
||||
"columns": {
|
||||
"columns": "Columns",
|
||||
"id": "ID",
|
||||
"type": "Type",
|
||||
"description": "Description",
|
||||
"amount": "Amount",
|
||||
"remainingAmount": "Remaining Amount",
|
||||
"paymentId": "Payment ID",
|
||||
"expirationDate": "Expiration Date",
|
||||
"expirationDateProcessedAt": "Expiration Date Processed At",
|
||||
"createdAt": "Created At",
|
||||
"updatedAt": "Updated At"
|
||||
},
|
||||
"types": {
|
||||
"MONTHLY_REFRESH": "Monthly Refresh",
|
||||
"REGISTER_GIFT": "Register Gift",
|
||||
"PURCHASE": "Purchased Credits",
|
||||
"USAGE": "Consumed Credits",
|
||||
"EXPIRE": "Expired Credits",
|
||||
"SUBSCRIPTION_RENEWAL": "Subscription Renewal",
|
||||
"LIFETIME_MONTHLY": "Lifetime Monthly"
|
||||
},
|
||||
"detailViewer": {
|
||||
"title": "Credit Transaction Detail",
|
||||
"close": "Close"
|
||||
},
|
||||
"expired": "Expired",
|
||||
"never": "Never"
|
||||
}
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notification",
|
||||
@ -890,23 +1007,68 @@
|
||||
}
|
||||
},
|
||||
"AITextPage": {
|
||||
"title": "AI Text",
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
|
||||
"content": "Working in progress"
|
||||
"title": "AI Text Demo",
|
||||
"description": "Analyze web content with AI to extract key information, features, and insights",
|
||||
"content": "Web Content Analyzer",
|
||||
"subtitle": "Enter a website URL to get AI-powered analysis of its content",
|
||||
"analyzer": {
|
||||
"title": "Web Content Analyzer",
|
||||
"description": "Analyze any website content using AI to extract structured information",
|
||||
"placeholder": "Enter website URL (e.g., https://example.com)",
|
||||
"button": "Analyze Website",
|
||||
"loading": {
|
||||
"scraping": "Scraping website content...",
|
||||
"analyzing": "Analyzing content with AI..."
|
||||
},
|
||||
"results": {
|
||||
"title": "Analysis Results",
|
||||
"newAnalysis": "Analyze Another Website",
|
||||
"sections": {
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
"introduction": "Introduction",
|
||||
"features": "Features",
|
||||
"pricing": "Pricing",
|
||||
"useCases": "Use Cases",
|
||||
"screenshot": "Website Screenshot"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Please enter a valid URL starting with http:// or https://",
|
||||
"analysisError": "Failed to analyze website. Please try again.",
|
||||
"networkError": "Network error. Please check your connection and try again.",
|
||||
"insufficientCredits": "Insufficient credits. Please purchase more credits to continue."
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"scraping": {
|
||||
"title": "Smart Web Scraping",
|
||||
"description": "Advanced web scraping technology extracts clean, structured content from any website"
|
||||
},
|
||||
"analysis": {
|
||||
"title": "AI-Powered Analysis",
|
||||
"description": "Intelligent AI analysis extracts key insights, features, and structured information"
|
||||
},
|
||||
"results": {
|
||||
"title": "Structured Results",
|
||||
"description": "Get organized, easy-to-read results with clear sections and actionable insights"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AIImagePage": {
|
||||
"title": "AI Image",
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
|
||||
"content": "Working in progress"
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
||||
},
|
||||
"AIChatPage": {
|
||||
"title": "AI Chat",
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
||||
},
|
||||
"AIVideoPage": {
|
||||
"title": "AI Video",
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
|
||||
"content": "Working in progress"
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
||||
},
|
||||
"AIAudioPage": {
|
||||
"title": "AI Audio",
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
|
||||
"content": "Working in progress"
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
||||
}
|
||||
}
|
||||
|
219
messages/zh.json
219
messages/zh.json
@ -5,6 +5,7 @@
|
||||
"description": "MkSaaS 是构建 AI SaaS 的最佳模板,使用 MkSaaS 可以在几天内轻松构建您的 AI SaaS,简单且毫不费力。"
|
||||
},
|
||||
"Common": {
|
||||
"premium": "付费文章",
|
||||
"login": "登录",
|
||||
"logout": "退出",
|
||||
"signUp": "注册",
|
||||
@ -28,7 +29,21 @@
|
||||
"saving": "保存中...",
|
||||
"loading": "加载中...",
|
||||
"cancel": "取消",
|
||||
"logoutFailed": "退出失败"
|
||||
"logoutFailed": "退出失败",
|
||||
"table": {
|
||||
"totalRecords": "总共 {count} 条记录",
|
||||
"noResults": "无结果",
|
||||
"loading": "加载中...",
|
||||
"columns": "列",
|
||||
"rowsPerPage": "每页行数",
|
||||
"page": "页",
|
||||
"firstPage": "第一页",
|
||||
"lastPage": "最后一页",
|
||||
"nextPage": "下一页",
|
||||
"previousPage": "上一页",
|
||||
"ascending": "升序",
|
||||
"descending": "降序"
|
||||
}
|
||||
},
|
||||
"PricingPage": {
|
||||
"title": "价格",
|
||||
@ -94,12 +109,29 @@
|
||||
"feature-3": "专属支持",
|
||||
"feature-4": "企业级安全",
|
||||
"feature-5": "高级集成",
|
||||
"feature-6": "自定义域名",
|
||||
"feature-7": "自定义品牌",
|
||||
"feature-8": "终身更新"
|
||||
"feature-6": "自定义品牌",
|
||||
"feature-7": "终身更新"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreditPackages": {
|
||||
"basic": {
|
||||
"name": "基础版",
|
||||
"description": "基础版功能介绍放这里"
|
||||
},
|
||||
"standard": {
|
||||
"name": "标准版",
|
||||
"description": "标准版功能介绍放这里"
|
||||
},
|
||||
"premium": {
|
||||
"name": "高级版",
|
||||
"description": "高级版功能介绍放这里"
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "企业版",
|
||||
"description": "企业版功能介绍放这里"
|
||||
}
|
||||
},
|
||||
"NotFoundPage": {
|
||||
"title": "404",
|
||||
"message": "抱歉,您正在寻找的页面不存在",
|
||||
@ -188,7 +220,9 @@
|
||||
"hidePassword": "隐藏密码",
|
||||
"or": "或以社媒账号登录",
|
||||
"emailRequired": "请输入邮箱",
|
||||
"passwordRequired": "请输入密码"
|
||||
"passwordRequired": "请输入密码",
|
||||
"captchaInvalid": "验证码验证失败",
|
||||
"captchaError": "验证码验证出错"
|
||||
},
|
||||
"register": {
|
||||
"title": "注册",
|
||||
@ -203,7 +237,9 @@
|
||||
"hidePassword": "隐藏密码",
|
||||
"nameRequired": "请输入姓名",
|
||||
"emailRequired": "请输入邮箱",
|
||||
"passwordRequired": "请输入密码"
|
||||
"passwordRequired": "请输入密码",
|
||||
"captchaInvalid": "验证码验证失败",
|
||||
"captchaError": "验证码验证出错"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "忘记密码",
|
||||
@ -257,8 +293,20 @@
|
||||
"nextPage": "下一页",
|
||||
"chooseLanguage": "选择语言",
|
||||
"title": "MkSaaS文档",
|
||||
"homepage": "首页",
|
||||
"blog": "博客"
|
||||
"homepage": "首页"
|
||||
},
|
||||
"PremiumContent": {
|
||||
"title": "解锁付费内容",
|
||||
"description": "订阅我们的付费计划,访问所有付费内容和独家内容。",
|
||||
"upgradeCta": "立即升级",
|
||||
"benefit1": "所有内容",
|
||||
"benefit2": "独家内容",
|
||||
"benefit3": "随时取消",
|
||||
"signIn": "登录",
|
||||
"loginRequired": "登录以继续阅读",
|
||||
"loginDescription": "这是一篇付费内容,请登录您的账户以访问完整内容。",
|
||||
"checkingAccess": "检查阅读权限...",
|
||||
"loadingContent": "加载完整内容..."
|
||||
},
|
||||
"Marketing": {
|
||||
"navbar": {
|
||||
@ -285,6 +333,10 @@
|
||||
"title": "AI 图像",
|
||||
"description": "展示如何使用 AI 生成精美图像"
|
||||
},
|
||||
"chat": {
|
||||
"title": "AI 聊天",
|
||||
"description": "展示如何使用 AI 与客户聊天"
|
||||
},
|
||||
"video": {
|
||||
"title": "AI 视频",
|
||||
"description": "展示如何使用 AI 生成惊人视频"
|
||||
@ -374,13 +426,13 @@
|
||||
"comparator": {
|
||||
"title": "Comparator 组件"
|
||||
},
|
||||
"faqs": {
|
||||
"title": "FAQs 组件"
|
||||
"faq": {
|
||||
"title": "FAQ 组件"
|
||||
},
|
||||
"login": {
|
||||
"title": "Login 组件"
|
||||
},
|
||||
"sign-up": {
|
||||
"signup": {
|
||||
"title": "Signup 组件"
|
||||
},
|
||||
"forgot-password": {
|
||||
@ -431,6 +483,7 @@
|
||||
"avatar": {
|
||||
"dashboard": "工作台",
|
||||
"billing": "账单",
|
||||
"credits": "积分",
|
||||
"settings": "设置"
|
||||
}
|
||||
},
|
||||
@ -456,14 +509,6 @@
|
||||
"banReason": "封禁原因",
|
||||
"banExpires": "封禁到期时间"
|
||||
},
|
||||
"noResults": "没有结果",
|
||||
"firstPage": "第一页",
|
||||
"lastPage": "最后一页",
|
||||
"nextPage": "下一页",
|
||||
"previousPage": "上一页",
|
||||
"rowsPerPage": "每页行数",
|
||||
"page": "页",
|
||||
"loading": "加载中...",
|
||||
"admin": "管理员",
|
||||
"user": "用户",
|
||||
"email": {
|
||||
@ -545,7 +590,8 @@
|
||||
"createCustomerPortalFailed": "打开Stripe客户界面失败"
|
||||
},
|
||||
"price": "价格:",
|
||||
"nextBillingDate": "下次账单日期:",
|
||||
"periodStartDate": "周期开始日期:",
|
||||
"periodEndDate": "周期结束日期:",
|
||||
"trialEnds": "试用结束日期:",
|
||||
"freePlanMessage": "您当前使用的是功能有限的免费方案",
|
||||
"lifetimeMessage": "您拥有所有高级功能的终身使用权限",
|
||||
@ -553,7 +599,77 @@
|
||||
"manageBilling": "管理账单",
|
||||
"upgradePlan": "升级方案",
|
||||
"retry": "重试",
|
||||
"errorMessage": "获取数据失败"
|
||||
"errorMessage": "获取数据失败",
|
||||
"paymentSuccess": "支付成功"
|
||||
},
|
||||
"credits": {
|
||||
"title": "积分",
|
||||
"description": "管理您的积分交易",
|
||||
"tabs": {
|
||||
"balance": "积分余额",
|
||||
"transactions": "交易记录"
|
||||
},
|
||||
"balance": {
|
||||
"title": "积分余额",
|
||||
"description": "您的积分余额",
|
||||
"credits": "积分",
|
||||
"creditsDescription": "您有 {credits} 积分",
|
||||
"creditsExpired": "积分已过期",
|
||||
"creditsAdded": "积分已添加到您的账户",
|
||||
"viewTransactions": "查看积分记录",
|
||||
"retry": "重试",
|
||||
"expiringCredits": "{credits} 积分将在 {days} 天内过期"
|
||||
},
|
||||
"packages": {
|
||||
"title": "积分套餐",
|
||||
"description": "购买积分以使用我们的更多服务",
|
||||
"purchase": "购买",
|
||||
"processing": "处理中...",
|
||||
"popular": "热门",
|
||||
"completePurchase": "请支付订单",
|
||||
"failedToFetchCredits": "获取积分失败",
|
||||
"failedToCreatePaymentIntent": "创建付款意向失败",
|
||||
"failedToInitiatePayment": "发起付款失败",
|
||||
"cancel": "取消",
|
||||
"purchaseFailed": "购买积分失败",
|
||||
"checkoutFailed": "创建支付会话失败",
|
||||
"loading": "加载中...",
|
||||
"pay": "支付"
|
||||
},
|
||||
"transactions": {
|
||||
"title": "积分记录",
|
||||
"error": "获取积分交易记录失败",
|
||||
"search": "搜索积分交易记录...",
|
||||
"paymentIdCopied": "支付ID已复制到剪贴板",
|
||||
"columns": {
|
||||
"columns": "列",
|
||||
"id": "ID",
|
||||
"type": "类型",
|
||||
"description": "描述",
|
||||
"amount": "金额",
|
||||
"remainingAmount": "剩余金额",
|
||||
"paymentId": "支付编号",
|
||||
"expirationDate": "过期日期",
|
||||
"expirationDateProcessedAt": "过期处理时间",
|
||||
"createdAt": "创建时间",
|
||||
"updatedAt": "更新时间"
|
||||
},
|
||||
"types": {
|
||||
"MONTHLY_REFRESH": "每月赠送",
|
||||
"REGISTER_GIFT": "注册赠送",
|
||||
"PURCHASE": "购买积分",
|
||||
"USAGE": "使用积分",
|
||||
"EXPIRE": "过期积分",
|
||||
"SUBSCRIPTION_RENEWAL": "订阅月度积分",
|
||||
"LIFETIME_MONTHLY": "终身月度积分"
|
||||
},
|
||||
"detailViewer": {
|
||||
"title": "积分交易详情",
|
||||
"close": "关闭"
|
||||
},
|
||||
"expired": "已过期",
|
||||
"never": "永不过期"
|
||||
}
|
||||
},
|
||||
"notification": {
|
||||
"title": "通知",
|
||||
@ -892,22 +1008,67 @@
|
||||
},
|
||||
"AITextPage": {
|
||||
"title": "AI 文本",
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力",
|
||||
"content": "正在开发中"
|
||||
"description": "使用 AI 分析网页内容,提取关键信息、功能和见解",
|
||||
"content": "网页内容分析器",
|
||||
"subtitle": "输入网站 URL,使用 AI 分析其内容",
|
||||
"analyzer": {
|
||||
"title": "网页内容分析器",
|
||||
"description": "使用 AI 分析任何网站的内容,提取结构化信息",
|
||||
"placeholder": "输入网站 URL(例如:https://example.com)",
|
||||
"button": "分析网站",
|
||||
"loading": {
|
||||
"scraping": "正在抓取网站内容...",
|
||||
"analyzing": "正在使用 AI 分析内容..."
|
||||
},
|
||||
"results": {
|
||||
"title": "分析结果",
|
||||
"newAnalysis": "分析其他网站",
|
||||
"sections": {
|
||||
"title": "标题",
|
||||
"description": "描述",
|
||||
"introduction": "介绍",
|
||||
"features": "功能",
|
||||
"pricing": "定价",
|
||||
"useCases": "使用场景",
|
||||
"screenshot": "网站截图"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "请输入以 http:// 或 https:// 开头的有效 URL",
|
||||
"analysisError": "分析网站失败,请重试。",
|
||||
"networkError": "网络错误,请检查您的连接并重试。",
|
||||
"insufficientCredits": "积分不足,请购买更多积分以继续。"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"scraping": {
|
||||
"title": "智能网页抓取",
|
||||
"description": "先进的网页抓取技术从任何网站提取干净、结构化的内容"
|
||||
},
|
||||
"analysis": {
|
||||
"title": "AI 驱动分析",
|
||||
"description": "智能 AI 分析提取关键见解、功能和结构化信息"
|
||||
},
|
||||
"results": {
|
||||
"title": "结构化结果",
|
||||
"description": "获得有组织、易于阅读的结果,包含清晰的部分和可操作的见解"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AIImagePage": {
|
||||
"title": "AI 图片",
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力",
|
||||
"content": "正在开发中"
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
||||
},
|
||||
"AIChatPage": {
|
||||
"title": "AI 聊天",
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
||||
},
|
||||
"AIVideoPage": {
|
||||
"title": "AI 视频",
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力",
|
||||
"content": "正在开发中"
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
||||
},
|
||||
"AIAudioPage": {
|
||||
"title": "AI 音频",
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力",
|
||||
"content": "正在开发中"
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { withContentCollections } from '@content-collections/next';
|
||||
import { createMDX } from 'fumadocs-mdx/next';
|
||||
import type { NextConfig } from 'next';
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
|
||||
@ -6,13 +6,28 @@ import createNextIntlPlugin from 'next-intl/plugin';
|
||||
* https://nextjs.org/docs/app/api-reference/config/next-config-js
|
||||
*/
|
||||
const nextConfig: NextConfig = {
|
||||
// Docker standalone output
|
||||
...(process.env.DOCKER_BUILD === 'true' && { output: 'standalone' }),
|
||||
|
||||
/* config options here */
|
||||
devIndicators: false,
|
||||
|
||||
// https://nextjs.org/docs/architecture/nextjs-compiler#remove-console
|
||||
// Remove all console.* calls in production only
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === 'production',
|
||||
// removeConsole: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
|
||||
// https://github.com/vercel/next.js/discussions/50177#discussioncomment-6006702
|
||||
// fix build error: Module build failed: UnhandledSchemeError:
|
||||
// Reading from "cloudflare:sockets" is not handled by plugins (Unhandled scheme).
|
||||
webpack: (config, { webpack }) => {
|
||||
config.plugins.push(
|
||||
new webpack.IgnorePlugin({
|
||||
resourceRegExp: /^pg-native$|^cloudflare:sockets$/,
|
||||
})
|
||||
);
|
||||
return config;
|
||||
},
|
||||
|
||||
images: {
|
||||
@ -41,6 +56,14 @@ const nextConfig: NextConfig = {
|
||||
protocol: 'https',
|
||||
hostname: 'ik.imagekit.io',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'html.tailus.io',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'service.firecrawl.dev',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@ -53,8 +76,15 @@ const nextConfig: NextConfig = {
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
/**
|
||||
* withContentCollections must be the outermost plugin
|
||||
*
|
||||
* https://www.content-collections.dev/docs/quickstart/next
|
||||
* https://fumadocs.dev/docs/ui/manual-installation
|
||||
* https://fumadocs.dev/docs/mdx/plugin
|
||||
*/
|
||||
export default withContentCollections(withNextIntl(nextConfig));
|
||||
const withMDX = createMDX();
|
||||
|
||||
export default withMDX(withNextIntl(nextConfig));
|
||||
|
||||
// https://opennext.js.org/cloudflare/get-started#12-develop-locally
|
||||
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
|
||||
|
||||
// during local development, to access in any of your server code, local versions of Cloudflare bindings
|
||||
initOpenNextCloudflareForDev();
|
||||
|
6
open-next.config.ts
Normal file
6
open-next.config.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||
|
||||
|
||||
export default defineCloudflareConfig({
|
||||
|
||||
});
|
79
package.json
79
package.json
@ -3,9 +3,11 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"content-collections watch\" \"next dev\"",
|
||||
"build": "content-collections build && next build",
|
||||
"dev": "next dev",
|
||||
"cf-dev": "next dev -p 8787",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
"lint": "biome check --write .",
|
||||
"lint:fix": "biome check --fix --unsafe .",
|
||||
"format": "biome format --write .",
|
||||
@ -14,25 +16,35 @@
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"list-contacts": "tsx scripts/list-contacts.ts",
|
||||
"docs": "content-collections build",
|
||||
"email": "email dev --dir src/mail/templates --port 3333"
|
||||
"list-users": "tsx scripts/list-users.ts",
|
||||
"content": "fumadocs-mdx",
|
||||
"email": "email dev --dir src/mail/templates --port 3333",
|
||||
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
||||
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
|
||||
"knip": "knip"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^1.1.13",
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.758.0",
|
||||
"@ai-sdk/deepseek": "^1.0.0",
|
||||
"@ai-sdk/fal": "^1.0.0",
|
||||
"@ai-sdk/fireworks": "^1.0.0",
|
||||
"@ai-sdk/google": "^2.0.0",
|
||||
"@ai-sdk/openai": "^2.0.0",
|
||||
"@ai-sdk/react": "^2.0.22",
|
||||
"@ai-sdk/replicate": "^1.0.0",
|
||||
"@base-ui-components/react": "1.0.0-beta.0",
|
||||
"@better-fetch/fetch": "^1.1.18",
|
||||
"@content-collections/core": "^0.8.0",
|
||||
"@content-collections/mdx": "^0.2.0",
|
||||
"@content-collections/next": "^0.2.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fumadocs/content-collections": "^1.1.8",
|
||||
"@hookform/resolvers": "^4.1.0",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@mendable/firecrawl-js": "^1.29.1",
|
||||
"@next/third-parties": "^15.3.0",
|
||||
"@openpanel/nextjs": "^1.0.7",
|
||||
"@openrouter/ai-sdk-provider": "^1.0.0-beta.6",
|
||||
"@orama/orama": "^3.1.4",
|
||||
"@orama/tokenizers": "^3.1.4",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
@ -64,22 +76,25 @@
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@react-email/components": "0.0.33",
|
||||
"@react-email/render": "1.0.5",
|
||||
"@stripe/stripe-js": "^5.6.0",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"@tanstack/react-query-devtools": "^5.85.5",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
"@vercel/speed-insights": "^1.2.0",
|
||||
"@widgetbot/react-embed": "^1.9.0",
|
||||
"ai": "^4.1.45",
|
||||
"ai": "^5.0.0",
|
||||
"better-auth": "^1.1.19",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"cookie": "^1.0.2",
|
||||
"crisp-sdk-web": "^1.0.25",
|
||||
"date-fns": "^4.1.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dotenv": "^16.4.7",
|
||||
@ -87,63 +102,65 @@
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"embla-carousel-react": "^8.5.2",
|
||||
"framer-motion": "^12.4.7",
|
||||
"fumadocs-core": "^15.1.2",
|
||||
"fumadocs-ui": "^15.1.2",
|
||||
"fumadocs-core": "^15.6.7",
|
||||
"fumadocs-mdx": "^11.7.3",
|
||||
"fumadocs-ui": "^15.6.7",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.483.0",
|
||||
"mdast-util-toc": "^7.1.0",
|
||||
"motion": "^12.4.3",
|
||||
"next": "15.2.1",
|
||||
"next-intl": "^4.0.0",
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-safe-action": "^7.10.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"pg": "^8.16.0",
|
||||
"nuqs": "^2.5.1",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.2",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-remove-scroll": "^2.6.3",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-syntax-highlighter": "^15.6.3",
|
||||
"react-tweet": "^3.2.2",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"recharts": "^2.15.1",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-pretty-code": "^0.14.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-code-import": "^1.2.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"resend": "^4.4.1",
|
||||
"s3mini": "^0.2.0",
|
||||
"shiki": "^2.4.2",
|
||||
"sonner": "^2.0.0",
|
||||
"streamdown": "^1.0.12",
|
||||
"stripe": "^17.6.0",
|
||||
"swiper": "^11.2.5",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.2.4",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-intl": "^3.26.5",
|
||||
"use-media": "^1.5.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.2",
|
||||
"zod": "^4.0.17",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@content-collections/cli": "^0.1.6",
|
||||
"@opennextjs/cloudflare": "^1.6.5",
|
||||
"@tailwindcss/postcss": "^4.0.14",
|
||||
"@tanstack/eslint-plugin-query": "^5.83.1",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/pg": "^8.11.11",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"concurrently": "^9.1.2",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"knip": "^5.61.2",
|
||||
"postcss": "^8",
|
||||
"react-email": "3.0.7",
|
||||
"tailwindcss": "^4.0.14",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5.8.3",
|
||||
"wrangler": "^4.28.1"
|
||||
}
|
||||
}
|
||||
|
9046
pnpm-lock.yaml
generated
9046
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
public/_headers
Normal file
2
public/_headers
Normal file
@ -0,0 +1,2 @@
|
||||
/_next/static/*
|
||||
Cache-Control: public,max-age=31536000,immutable
|
129
public/sw.js
129
public/sw.js
@ -1,129 +0,0 @@
|
||||
// Service Worker for caching iframe content
|
||||
const CACHE_NAME = 'cnblocks-iframe-cache-v1'
|
||||
|
||||
// Add iframe URLs to this list to prioritize caching
|
||||
const URLS_TO_CACHE = [
|
||||
// Default assets that should be cached
|
||||
'/favicon.ico',
|
||||
// Images used in iframes
|
||||
'/payments.png',
|
||||
'/payments-light.png',
|
||||
'/origin-cal.png',
|
||||
'/origin-cal-dark.png',
|
||||
'/exercice.png',
|
||||
'/exercice-dark.png',
|
||||
'/charts-light.png',
|
||||
'/charts.png',
|
||||
'/music-light.png',
|
||||
'/music.png',
|
||||
'/mail-back-light.png',
|
||||
'/mail-upper.png',
|
||||
'/mail-back.png',
|
||||
'/card.png',
|
||||
'/dark-card.webp',
|
||||
]
|
||||
|
||||
// Install event - cache resources
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
console.log('Opened cache')
|
||||
return cache.addAll(URLS_TO_CACHE)
|
||||
})
|
||||
.then(() => self.skipWaiting()) // Activate SW immediately
|
||||
)
|
||||
})
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
const currentCaches = [CACHE_NAME]
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((cacheNames) => {
|
||||
return cacheNames.filter((cacheName) => !currentCaches.includes(cacheName))
|
||||
})
|
||||
.then((cachesToDelete) => {
|
||||
return Promise.all(
|
||||
cachesToDelete.map((cacheToDelete) => {
|
||||
return caches.delete(cacheToDelete)
|
||||
})
|
||||
)
|
||||
})
|
||||
.then(() => self.clients.claim()) // Take control of clients immediately
|
||||
)
|
||||
})
|
||||
|
||||
// Fetch event - serve from cache or fetch from network and cache
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Check if this is an iframe request - typically they'll be HTML or have 'preview' in the URL
|
||||
const isIframeRequest = event.request.url.includes('/preview/') || event.request.url.includes('/examples/')
|
||||
|
||||
if (isIframeRequest) {
|
||||
event.respondWith(
|
||||
caches.match(event.request, { ignoreSearch: true }).then((response) => {
|
||||
// Return cached response if found
|
||||
if (response) {
|
||||
return response
|
||||
}
|
||||
|
||||
// Clone the request (requests are one-time use)
|
||||
const fetchRequest = event.request.clone()
|
||||
|
||||
return fetch(fetchRequest).then((response) => {
|
||||
// Check if we received a valid response
|
||||
if (!response || response.status !== 200 || response.type !== 'basic') {
|
||||
return response
|
||||
}
|
||||
|
||||
// Clone the response (responses are one-time use)
|
||||
const responseToCache = response.clone()
|
||||
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache)
|
||||
})
|
||||
|
||||
return response
|
||||
})
|
||||
})
|
||||
)
|
||||
} else {
|
||||
// For non-iframe requests, use a standard cache-first strategy
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((response) => {
|
||||
if (response) {
|
||||
return response
|
||||
}
|
||||
return fetch(event.request)
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for messages from clients (to force cache update, etc)
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting()
|
||||
}
|
||||
|
||||
// Handle cache clearing
|
||||
if (event.data && event.data.type === 'CLEAR_IFRAME_CACHE') {
|
||||
const url = event.data.url
|
||||
|
||||
if (url) {
|
||||
// Clear specific URL from cache
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.delete(url).then(() => {
|
||||
console.log(`Cleared cache for: ${url}`)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// Clear the entire cache
|
||||
caches.delete(CACHE_NAME).then(() => {
|
||||
console.log('Cleared entire iframe cache')
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
24
scripts/list-users.ts
Normal file
24
scripts/list-users.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { getDb } from '../src/db/index.js';
|
||||
import { user } from '../src/db/schema.js';
|
||||
dotenv.config();
|
||||
|
||||
export default async function listUsers() {
|
||||
const db = await getDb();
|
||||
|
||||
try {
|
||||
const users = await db.select({ email: user.email }).from(user);
|
||||
|
||||
// Extract emails from users
|
||||
const emails: string[] = users.map((user) => user.email);
|
||||
|
||||
console.log(`Total users: ${emails.length}`);
|
||||
|
||||
// Output all emails joined with comma
|
||||
console.log(emails.join(', '));
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
}
|
||||
}
|
||||
|
||||
listUsers();
|
102
source.config.ts
Normal file
102
source.config.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import {
|
||||
defineCollections,
|
||||
defineDocs,
|
||||
frontmatterSchema,
|
||||
metaSchema,
|
||||
} from 'fumadocs-mdx/config';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* https://fumadocs.dev/docs/mdx/collections#schema-1
|
||||
*/
|
||||
export const docs = defineDocs({
|
||||
dir: 'content/docs',
|
||||
docs: {
|
||||
schema: frontmatterSchema.extend({
|
||||
preview: z.string().optional(),
|
||||
index: z.boolean().default(false),
|
||||
premium: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
meta: {
|
||||
schema: metaSchema.extend({
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Changelog
|
||||
*
|
||||
* title is required, but description is optional in frontmatter
|
||||
*/
|
||||
export const changelog = defineCollections({
|
||||
type: 'doc',
|
||||
dir: 'content/changelog',
|
||||
schema: frontmatterSchema.extend({
|
||||
version: z.string(),
|
||||
date: z.string().date(),
|
||||
published: z.boolean().default(true),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Pages, like privacy policy, terms of service, etc.
|
||||
*
|
||||
* title is required, but description is optional in frontmatter
|
||||
*/
|
||||
export const pages = defineCollections({
|
||||
type: 'doc',
|
||||
dir: 'content/pages',
|
||||
schema: frontmatterSchema.extend({
|
||||
date: z.string().date(),
|
||||
published: z.boolean().default(true),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Blog authors
|
||||
*
|
||||
* description is optional in frontmatter, but we must add it to the schema
|
||||
*/
|
||||
export const author = defineCollections({
|
||||
type: 'doc',
|
||||
dir: 'content/author',
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
avatar: z.string(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Blog categories
|
||||
*
|
||||
* description is optional in frontmatter, but we must add it to the schema
|
||||
*/
|
||||
export const category = defineCollections({
|
||||
type: 'doc',
|
||||
dir: 'content/category',
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Blog posts
|
||||
*
|
||||
* title is required, but description is optional in frontmatter
|
||||
*/
|
||||
export const blog = defineCollections({
|
||||
type: 'doc',
|
||||
dir: 'content/blog',
|
||||
schema: frontmatterSchema.extend({
|
||||
image: z.string(),
|
||||
date: z.string().date(),
|
||||
published: z.boolean().default(true),
|
||||
premium: z.boolean().optional(),
|
||||
categories: z.array(z.string()),
|
||||
author: z.string(),
|
||||
}),
|
||||
});
|
@ -1,19 +1,16 @@
|
||||
'use server';
|
||||
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
import { isSubscribed } from '@/newsletter';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Create a safe action client
|
||||
const actionClient = createSafeActionClient();
|
||||
|
||||
// Newsletter schema for validation
|
||||
const newsletterSchema = z.object({
|
||||
email: z.string().email({ message: 'Please enter a valid email address' }),
|
||||
email: z.email({ error: 'Please enter a valid email address' }),
|
||||
});
|
||||
|
||||
// Create a safe action to check if a user is subscribed to the newsletter
|
||||
export const checkNewsletterStatusAction = actionClient
|
||||
export const checkNewsletterStatusAction = userActionClient
|
||||
.schema(newsletterSchema)
|
||||
.action(async ({ parsedInput: { email } }) => {
|
||||
try {
|
||||
|
37
src/actions/consume-credits.ts
Normal file
37
src/actions/consume-credits.ts
Normal file
@ -0,0 +1,37 @@
|
||||
'use server';
|
||||
|
||||
import { consumeCredits } from '@/credits/credits';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
import { z } from 'zod';
|
||||
|
||||
// consume credits schema
|
||||
const consumeSchema = z.object({
|
||||
amount: z.number().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Consume credits
|
||||
*/
|
||||
export const consumeCreditsAction = userActionClient
|
||||
.schema(consumeSchema)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const { amount, description } = parsedInput;
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
|
||||
try {
|
||||
await consumeCredits({
|
||||
userId: currentUser.id,
|
||||
amount,
|
||||
description: description || `Consume credits: ${amount}`,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('consume credits error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Something went wrong',
|
||||
};
|
||||
}
|
||||
});
|
@ -1,57 +1,34 @@
|
||||
'use server';
|
||||
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { findPlanByPlanId } from '@/lib/price-plan';
|
||||
import { getSession } from '@/lib/server';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { createCheckout } from '@/payment';
|
||||
import type { CreateCheckoutParams } from '@/payment/types';
|
||||
import { Routes } from '@/routes';
|
||||
import { getLocale } from 'next-intl/server';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
import { cookies } from 'next/headers';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Create a safe action client
|
||||
const actionClient = createSafeActionClient();
|
||||
|
||||
// Checkout schema for validation
|
||||
// metadata is optional, and may contain referral information if you need
|
||||
const checkoutSchema = z.object({
|
||||
userId: z.string().min(1, { message: 'User ID is required' }),
|
||||
planId: z.string().min(1, { message: 'Plan ID is required' }),
|
||||
priceId: z.string().min(1, { message: 'Price ID is required' }),
|
||||
metadata: z.record(z.string()).optional(),
|
||||
userId: z.string().min(1, { error: 'User ID is required' }),
|
||||
planId: z.string().min(1, { error: 'Plan ID is required' }),
|
||||
priceId: z.string().min(1, { error: 'Price ID is required' }),
|
||||
metadata: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a checkout session for a price plan
|
||||
*/
|
||||
export const createCheckoutAction = actionClient
|
||||
export const createCheckoutAction = userActionClient
|
||||
.schema(checkoutSchema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const { userId, planId, priceId, metadata } = parsedInput;
|
||||
|
||||
// Get the current user session for authorization
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
console.warn(
|
||||
`unauthorized request to create checkout session for user ${userId}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
};
|
||||
}
|
||||
|
||||
// Only allow users to create their own checkout session
|
||||
if (session.user.id !== userId) {
|
||||
console.warn(
|
||||
`current user ${session.user.id} is not authorized to create checkout session for user ${userId}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authorized to do this action',
|
||||
};
|
||||
}
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const { planId, priceId, metadata } = parsedInput;
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
|
||||
try {
|
||||
// Get the current locale from the request
|
||||
@ -62,27 +39,37 @@ export const createCheckoutAction = actionClient
|
||||
if (!plan) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Plan not found',
|
||||
error: 'Price plan not found',
|
||||
};
|
||||
}
|
||||
|
||||
// Add user id to metadata, so we can get it in the webhook event
|
||||
const customMetadata = {
|
||||
const customMetadata: Record<string, string> = {
|
||||
...metadata,
|
||||
userId: session.user.id,
|
||||
userName: session.user.name,
|
||||
userId: currentUser.id,
|
||||
userName: currentUser.name,
|
||||
};
|
||||
|
||||
// https://datafa.st/docs/stripe-checkout-api
|
||||
// if datafast analytics is enabled, add the revenue attribution to the metadata
|
||||
if (websiteConfig.features.enableDatafastRevenueTrack) {
|
||||
const cookieStore = await cookies();
|
||||
customMetadata.datafast_visitor_id =
|
||||
cookieStore.get('datafast_visitor_id')?.value ?? '';
|
||||
customMetadata.datafast_session_id =
|
||||
cookieStore.get('datafast_session_id')?.value ?? '';
|
||||
}
|
||||
|
||||
// Create the checkout session with localized URLs
|
||||
const successUrl = getUrlWithLocale(
|
||||
'/settings/billing?session_id={CHECKOUT_SESSION_ID}',
|
||||
`${Routes.SettingsBilling}?session_id={CHECKOUT_SESSION_ID}`,
|
||||
locale
|
||||
);
|
||||
const cancelUrl = getUrlWithLocale(Routes.Pricing, locale);
|
||||
const params: CreateCheckoutParams = {
|
||||
planId,
|
||||
priceId,
|
||||
customerEmail: session.user.email,
|
||||
customerEmail: currentUser.email,
|
||||
metadata: customMetadata,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
|
99
src/actions/create-credit-checkout-session.ts
Normal file
99
src/actions/create-credit-checkout-session.ts
Normal file
@ -0,0 +1,99 @@
|
||||
'use server';
|
||||
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { getCreditPackageById } from '@/credits/server';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { createCreditCheckout } from '@/payment';
|
||||
import type { CreateCreditCheckoutParams } from '@/payment/types';
|
||||
import { Routes } from '@/routes';
|
||||
import { getLocale } from 'next-intl/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Credit checkout schema for validation
|
||||
// metadata is optional, and may contain referral information if you need
|
||||
const creditCheckoutSchema = z.object({
|
||||
userId: z.string().min(1, { error: 'User ID is required' }),
|
||||
packageId: z.string().min(1, { error: 'Package ID is required' }),
|
||||
priceId: z.string().min(1, { error: 'Price ID is required' }),
|
||||
metadata: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a checkout session for a credit package
|
||||
*/
|
||||
export const createCreditCheckoutSession = userActionClient
|
||||
.schema(creditCheckoutSchema)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const { packageId, priceId, metadata } = parsedInput;
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
|
||||
try {
|
||||
// Get the current locale from the request
|
||||
const locale = await getLocale();
|
||||
|
||||
// Find the credit package
|
||||
const creditPackage = getCreditPackageById(packageId);
|
||||
if (!creditPackage) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Credit package not found',
|
||||
};
|
||||
}
|
||||
|
||||
// Add metadata to identify this as a credit purchase
|
||||
const customMetadata: Record<string, string> = {
|
||||
...metadata,
|
||||
type: 'credit_purchase',
|
||||
packageId,
|
||||
credits: creditPackage.amount.toString(),
|
||||
userId: currentUser.id,
|
||||
userName: currentUser.name,
|
||||
};
|
||||
|
||||
// https://datafa.st/docs/stripe-checkout-api
|
||||
// if datafast analytics is enabled, add the revenue attribution to the metadata
|
||||
if (websiteConfig.features.enableDatafastRevenueTrack) {
|
||||
const cookieStore = await cookies();
|
||||
customMetadata.datafast_visitor_id =
|
||||
cookieStore.get('datafast_visitor_id')?.value ?? '';
|
||||
customMetadata.datafast_session_id =
|
||||
cookieStore.get('datafast_session_id')?.value ?? '';
|
||||
}
|
||||
|
||||
// Create checkout session with credit-specific URLs
|
||||
const successUrl = getUrlWithLocale(
|
||||
`${Routes.SettingsCredits}?credits_session_id={CHECKOUT_SESSION_ID}`,
|
||||
locale
|
||||
);
|
||||
const cancelUrl = getUrlWithLocale(Routes.SettingsCredits, locale);
|
||||
|
||||
const params: CreateCreditCheckoutParams = {
|
||||
packageId,
|
||||
priceId,
|
||||
customerEmail: currentUser.email,
|
||||
metadata: customMetadata,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
locale,
|
||||
};
|
||||
|
||||
const result = await createCreditCheckout(params);
|
||||
// console.log('create credit checkout session result:', result);
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Create credit checkout session error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to create checkout session',
|
||||
};
|
||||
}
|
||||
});
|
@ -1,69 +1,45 @@
|
||||
'use server';
|
||||
|
||||
import db from '@/db';
|
||||
import { getDb } from '@/db';
|
||||
import { user } from '@/db/schema';
|
||||
import { getSession } from '@/lib/server';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { createCustomerPortal } from '@/payment';
|
||||
import type { CreatePortalParams } from '@/payment/types';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getLocale } from 'next-intl/server';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Create a safe action client
|
||||
const actionClient = createSafeActionClient();
|
||||
|
||||
// Portal schema for validation
|
||||
const portalSchema = z.object({
|
||||
userId: z.string().min(1, { message: 'User ID is required' }),
|
||||
userId: z.string().min(1, { error: 'User ID is required' }),
|
||||
returnUrl: z
|
||||
.string()
|
||||
.url({ message: 'Return URL must be a valid URL' })
|
||||
.url({ error: 'Return URL must be a valid URL' })
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a customer portal session
|
||||
*/
|
||||
export const createPortalAction = actionClient
|
||||
export const createPortalAction = userActionClient
|
||||
.schema(portalSchema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const { userId, returnUrl } = parsedInput;
|
||||
|
||||
// Get the current user session for authorization
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
console.warn(
|
||||
`unauthorized request to create portal session for user ${userId}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
};
|
||||
}
|
||||
|
||||
// Only allow users to create their own portal session
|
||||
if (session.user.id !== userId) {
|
||||
console.warn(
|
||||
`current user ${session.user.id} is not authorized to create portal session for user ${userId}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authorized to do this action',
|
||||
};
|
||||
}
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const { returnUrl } = parsedInput;
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
|
||||
try {
|
||||
// Get the user's customer ID from the database
|
||||
const db = await getDb();
|
||||
const customerResult = await db
|
||||
.select({ customerId: user.customerId })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.where(eq(user.id, currentUser.id))
|
||||
.limit(1);
|
||||
|
||||
if (customerResult.length <= 0 || !customerResult[0].customerId) {
|
||||
console.error(`No customer found for user ${session.user.id}`);
|
||||
console.error(`No customer found for user ${currentUser.id}`);
|
||||
return {
|
||||
success: false,
|
||||
error: 'No customer found for user',
|
||||
|
@ -1,16 +1,13 @@
|
||||
'use server';
|
||||
|
||||
import { getSession } from '@/lib/server';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
import { getSubscriptions } from '@/payment';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Create a safe action client
|
||||
const actionClient = createSafeActionClient();
|
||||
|
||||
// Input schema
|
||||
const schema = z.object({
|
||||
userId: z.string().min(1, { message: 'User ID is required' }),
|
||||
userId: z.string().min(1, { error: 'User ID is required' }),
|
||||
});
|
||||
|
||||
/**
|
||||
@ -19,38 +16,27 @@ const schema = z.object({
|
||||
* If the user has multiple subscriptions,
|
||||
* it returns the most recent active or trialing one
|
||||
*/
|
||||
export const getActiveSubscriptionAction = actionClient
|
||||
export const getActiveSubscriptionAction = userActionClient
|
||||
.schema(schema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const { userId } = parsedInput;
|
||||
.action(async ({ ctx }) => {
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
|
||||
// Get the current user session for authorization
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
console.warn(
|
||||
`unauthorized request to get active subscription for user ${userId}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
};
|
||||
}
|
||||
// Check if Stripe environment variables are configured
|
||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
|
||||
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
// Only allow users to check their own status unless they're admins
|
||||
if (session.user.id !== userId && session.user.role !== 'admin') {
|
||||
console.warn(
|
||||
`current user ${session.user.id} is not authorized to get active subscription for user ${userId}`
|
||||
);
|
||||
if (!stripeSecretKey || !stripeWebhookSecret) {
|
||||
console.log('Stripe environment variables not configured, return');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authorized to do this action',
|
||||
success: true,
|
||||
data: null, // No subscription = free plan
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the user's most recent active subscription
|
||||
const subscriptions = await getSubscriptions({
|
||||
userId: session.user.id,
|
||||
userId: currentUser.id,
|
||||
});
|
||||
// console.log('get user subscriptions:', subscriptions);
|
||||
|
||||
@ -64,16 +50,16 @@ export const getActiveSubscriptionAction = actionClient
|
||||
|
||||
// If found, use it
|
||||
if (activeSubscription) {
|
||||
console.log('find active subscription for userId:', session.user.id);
|
||||
console.log('find active subscription for userId:', currentUser.id);
|
||||
subscriptionData = activeSubscription;
|
||||
} else {
|
||||
console.log(
|
||||
'no active subscription found for userId:',
|
||||
session.user.id
|
||||
currentUser.id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log('no subscriptions found for userId:', session.user.id);
|
||||
console.log('no subscriptions found for userId:', currentUser.id);
|
||||
}
|
||||
|
||||
return {
|
||||
|
27
src/actions/get-credit-balance.ts
Normal file
27
src/actions/get-credit-balance.ts
Normal file
@ -0,0 +1,27 @@
|
||||
'use server';
|
||||
|
||||
import { getUserCredits } from '@/credits/credits';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
|
||||
/**
|
||||
* Get current user's credits
|
||||
*/
|
||||
export const getCreditBalanceAction = userActionClient.action(
|
||||
async ({ ctx }) => {
|
||||
try {
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
const credits = await getUserCredits(currentUser.id);
|
||||
return { success: true, credits };
|
||||
} catch (error) {
|
||||
console.error('get credit balance error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch credit balance',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
62
src/actions/get-credit-stats.ts
Normal file
62
src/actions/get-credit-stats.ts
Normal file
@ -0,0 +1,62 @@
|
||||
'use server';
|
||||
|
||||
import { getDb } from '@/db';
|
||||
import { creditTransaction } from '@/db/schema';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { CREDITS_EXPIRATION_DAYS } from '@/lib/constants';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
import { addDays } from 'date-fns';
|
||||
import { and, eq, gt, gte, isNotNull, lte, sum } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Get credit statistics for a user
|
||||
*/
|
||||
export const getCreditStatsAction = userActionClient.action(async ({ ctx }) => {
|
||||
try {
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
const userId = currentUser.id;
|
||||
|
||||
const db = await getDb();
|
||||
const now = new Date();
|
||||
// Get credits expiring in the next 30 days
|
||||
const expirationDaysFromNow = addDays(now, CREDITS_EXPIRATION_DAYS);
|
||||
|
||||
// Get total credits expiring in the next 30 days
|
||||
const expiringCreditsResult = await db
|
||||
.select({
|
||||
totalAmount: sum(creditTransaction.remainingAmount),
|
||||
})
|
||||
.from(creditTransaction)
|
||||
.where(
|
||||
and(
|
||||
eq(creditTransaction.userId, userId),
|
||||
isNotNull(creditTransaction.expirationDate),
|
||||
isNotNull(creditTransaction.remainingAmount),
|
||||
gt(creditTransaction.remainingAmount, 0),
|
||||
lte(creditTransaction.expirationDate, expirationDaysFromNow),
|
||||
gte(creditTransaction.expirationDate, now)
|
||||
)
|
||||
);
|
||||
|
||||
const totalExpiringCredits =
|
||||
Number(expiringCreditsResult[0]?.totalAmount) || 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
expiringCredits: {
|
||||
amount: totalExpiringCredits,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('get credit stats error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch credit statistics',
|
||||
};
|
||||
}
|
||||
});
|
128
src/actions/get-credit-transactions.ts
Normal file
128
src/actions/get-credit-transactions.ts
Normal file
@ -0,0 +1,128 @@
|
||||
'use server';
|
||||
|
||||
import { getDb } from '@/db';
|
||||
import { creditTransaction } from '@/db/schema';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
import { and, asc, desc, eq, ilike, or, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define the schema for getCreditTransactions parameters
|
||||
const getCreditTransactionsSchema = z.object({
|
||||
pageIndex: z.number().min(0).default(0),
|
||||
pageSize: z.number().min(1).max(100).default(10),
|
||||
search: z.string().optional().default(''),
|
||||
sorting: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
desc: z.boolean(),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
// Define sort field mapping
|
||||
const sortFieldMap = {
|
||||
type: creditTransaction.type,
|
||||
amount: creditTransaction.amount,
|
||||
remainingAmount: creditTransaction.remainingAmount,
|
||||
description: creditTransaction.description,
|
||||
createdAt: creditTransaction.createdAt,
|
||||
updatedAt: creditTransaction.updatedAt,
|
||||
expirationDate: creditTransaction.expirationDate,
|
||||
expirationDateProcessedAt: creditTransaction.expirationDateProcessedAt,
|
||||
paymentId: creditTransaction.paymentId,
|
||||
} as const;
|
||||
|
||||
// Create a safe action for getting credit transactions
|
||||
export const getCreditTransactionsAction = userActionClient
|
||||
.schema(getCreditTransactionsSchema)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
try {
|
||||
const { pageIndex, pageSize, search, sorting } = parsedInput;
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
|
||||
// Search logic: text fields use ilike, and if search is a number, also search amount fields
|
||||
const searchConditions = [];
|
||||
if (search) {
|
||||
// Always search text fields
|
||||
searchConditions.push(
|
||||
ilike(creditTransaction.type, `%${search}%`),
|
||||
ilike(creditTransaction.paymentId, `%${search}%`),
|
||||
ilike(creditTransaction.description, `%${search}%`)
|
||||
);
|
||||
|
||||
// If search is a valid number, also search numeric fields
|
||||
const numericSearch = Number.parseInt(search, 10);
|
||||
if (!Number.isNaN(numericSearch)) {
|
||||
searchConditions.push(
|
||||
eq(creditTransaction.amount, numericSearch),
|
||||
eq(creditTransaction.remainingAmount, numericSearch)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const where = search
|
||||
? and(
|
||||
eq(creditTransaction.userId, currentUser.id),
|
||||
or(...searchConditions)
|
||||
)
|
||||
: eq(creditTransaction.userId, currentUser.id);
|
||||
|
||||
const offset = pageIndex * pageSize;
|
||||
|
||||
// Get the sort configuration
|
||||
const sortConfig = sorting[0];
|
||||
const sortField = sortConfig?.id
|
||||
? sortFieldMap[sortConfig.id as keyof typeof sortFieldMap]
|
||||
: creditTransaction.createdAt;
|
||||
const sortDirection = sortConfig?.desc ? desc : asc;
|
||||
|
||||
const db = await getDb();
|
||||
const [items, [{ count }]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: creditTransaction.id,
|
||||
userId: creditTransaction.userId,
|
||||
type: creditTransaction.type,
|
||||
description: creditTransaction.description,
|
||||
amount: creditTransaction.amount,
|
||||
remainingAmount: creditTransaction.remainingAmount,
|
||||
paymentId: creditTransaction.paymentId,
|
||||
expirationDate: creditTransaction.expirationDate,
|
||||
expirationDateProcessedAt:
|
||||
creditTransaction.expirationDateProcessedAt,
|
||||
createdAt: creditTransaction.createdAt,
|
||||
updatedAt: creditTransaction.updatedAt,
|
||||
})
|
||||
.from(creditTransaction)
|
||||
.where(where)
|
||||
.orderBy(sortDirection(sortField))
|
||||
.limit(pageSize)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql`count(*)` })
|
||||
.from(creditTransaction)
|
||||
.where(where),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total: Number(count),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('get credit transactions error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch credit transactions',
|
||||
};
|
||||
}
|
||||
});
|
@ -1,20 +1,17 @@
|
||||
'use server';
|
||||
|
||||
import db from '@/db';
|
||||
import { getDb } from '@/db';
|
||||
import { payment } from '@/db/schema';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { findPlanByPriceId, getAllPricePlans } from '@/lib/price-plan';
|
||||
import { getSession } from '@/lib/server';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
import { PaymentTypes } from '@/payment/types';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Create a safe action client
|
||||
const actionClient = createSafeActionClient();
|
||||
|
||||
// Input schema
|
||||
const schema = z.object({
|
||||
userId: z.string().min(1, { message: 'User ID is required' }),
|
||||
userId: z.string().min(1, { error: 'User ID is required' }),
|
||||
});
|
||||
|
||||
/**
|
||||
@ -25,33 +22,11 @@ const schema = z.object({
|
||||
* in order to do this, you have to update the logic to check the lifetime status,
|
||||
* for example, just check the planId is `lifetime` or not.
|
||||
*/
|
||||
export const getLifetimeStatusAction = actionClient
|
||||
export const getLifetimeStatusAction = userActionClient
|
||||
.schema(schema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const { userId } = parsedInput;
|
||||
|
||||
// Get the current user session for authorization
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
console.warn(
|
||||
`unauthorized request to get lifetime status for user ${userId}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
};
|
||||
}
|
||||
|
||||
// Only allow users to check their own status unless they're admins
|
||||
if (session.user.id !== userId && session.user.role !== 'admin') {
|
||||
console.warn(
|
||||
`current user ${session.user.id} is not authorized to get lifetime status for user ${userId}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authorized to do this action',
|
||||
};
|
||||
}
|
||||
.action(async ({ ctx }) => {
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
const userId = currentUser.id;
|
||||
|
||||
try {
|
||||
// Get lifetime plans
|
||||
@ -69,6 +44,7 @@ export const getLifetimeStatusAction = actionClient
|
||||
}
|
||||
|
||||
// Query the database for one-time payments with lifetime plans
|
||||
const db = await getDb();
|
||||
const result = await db
|
||||
.select({
|
||||
id: payment.id,
|
||||
|
@ -1,14 +1,12 @@
|
||||
'use server';
|
||||
|
||||
import db from '@/db';
|
||||
import { getDb } from '@/db';
|
||||
import { user } from '@/db/schema';
|
||||
import { isDemoWebsite } from '@/lib/demo';
|
||||
import { adminActionClient } from '@/lib/safe-action';
|
||||
import { asc, desc, ilike, or, sql } from 'drizzle-orm';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Create a safe action client
|
||||
const actionClient = createSafeActionClient();
|
||||
|
||||
// Define the schema for getUsers parameters
|
||||
const getUsersSchema = z.object({
|
||||
pageIndex: z.number().min(0).default(0),
|
||||
@ -38,14 +36,19 @@ const sortFieldMap = {
|
||||
} as const;
|
||||
|
||||
// Create a safe action for getting users
|
||||
export const getUsersAction = actionClient
|
||||
export const getUsersAction = adminActionClient
|
||||
.schema(getUsersSchema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
try {
|
||||
const { pageIndex, pageSize, search, sorting } = parsedInput;
|
||||
|
||||
// search by name, email, and customerId
|
||||
const where = search
|
||||
? or(ilike(user.name, `%${search}%`), ilike(user.email, `%${search}%`))
|
||||
? or(
|
||||
ilike(user.name, `%${search}%`),
|
||||
ilike(user.email, `%${search}%`),
|
||||
ilike(user.customerId, `%${search}%`)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const offset = pageIndex * pageSize;
|
||||
@ -57,6 +60,7 @@ export const getUsersAction = actionClient
|
||||
: user.createdAt;
|
||||
const sortDirection = sortConfig?.desc ? desc : asc;
|
||||
|
||||
const db = await getDb();
|
||||
let [items, [{ count }]] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
@ -69,7 +73,8 @@ export const getUsersAction = actionClient
|
||||
]);
|
||||
|
||||
// hide user data in demo website
|
||||
if (process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true') {
|
||||
const isDemo = isDemoWebsite();
|
||||
if (isDemo) {
|
||||
items = items.map((item) => ({
|
||||
...item,
|
||||
name: 'Demo User',
|
||||
|
@ -1,14 +1,11 @@
|
||||
'use server';
|
||||
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { actionClient } from '@/lib/safe-action';
|
||||
import { sendEmail } from '@/mail';
|
||||
import { getLocale } from 'next-intl/server';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Create a safe action client
|
||||
const actionClient = createSafeActionClient();
|
||||
|
||||
/**
|
||||
* DOC: When using Zod for validation, how can I localize error messages?
|
||||
* https://next-intl.dev/docs/environments/actions-metadata-route-handlers#server-actions
|
||||
@ -17,13 +14,13 @@ const actionClient = createSafeActionClient();
|
||||
const contactFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(3, { message: 'Name must be at least 3 characters' })
|
||||
.max(30, { message: 'Name must not exceed 30 characters' }),
|
||||
email: z.string().email({ message: 'Please enter a valid email address' }),
|
||||
.min(3, { error: 'Name must be at least 3 characters' })
|
||||
.max(30, { error: 'Name must not exceed 30 characters' }),
|
||||
email: z.email({ error: 'Please enter a valid email address' }),
|
||||
message: z
|
||||
.string()
|
||||
.min(10, { message: 'Message must be at least 10 characters' })
|
||||
.max(500, { message: 'Message must not exceed 500 characters' }),
|
||||
.min(10, { error: 'Message must be at least 10 characters' })
|
||||
.max(500, { error: 'Message must not exceed 500 characters' }),
|
||||
});
|
||||
|
||||
// Create a safe action for contact form submission
|
||||
|
@ -1,17 +1,14 @@
|
||||
'use server';
|
||||
|
||||
import { actionClient } from '@/lib/safe-action';
|
||||
import { sendEmail } from '@/mail';
|
||||
import { subscribe } from '@/newsletter';
|
||||
import { getLocale } from 'next-intl/server';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Create a safe action client
|
||||
const actionClient = createSafeActionClient();
|
||||
|
||||
// Newsletter schema for validation
|
||||
const newsletterSchema = z.object({
|
||||
email: z.string().email({ message: 'Please enter a valid email address' }),
|
||||
email: z.email({ error: 'Please enter a valid email address' }),
|
||||
});
|
||||
|
||||
// Create a safe action for newsletter subscription
|
||||
|
@ -1,30 +1,18 @@
|
||||
'use server';
|
||||
|
||||
import { getSession } from '@/lib/server';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
import { unsubscribe } from '@/newsletter';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Create a safe action client
|
||||
const actionClient = createSafeActionClient();
|
||||
|
||||
// Newsletter schema for validation
|
||||
const newsletterSchema = z.object({
|
||||
email: z.string().email({ message: 'Please enter a valid email address' }),
|
||||
email: z.email({ error: 'Please enter a valid email address' }),
|
||||
});
|
||||
|
||||
// Create a safe action for newsletter unsubscription
|
||||
export const unsubscribeNewsletterAction = actionClient
|
||||
export const unsubscribeNewsletterAction = userActionClient
|
||||
.schema(newsletterSchema)
|
||||
.action(async ({ parsedInput: { email } }) => {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const unsubscribed = await unsubscribe(email);
|
||||
|
||||
|
33
src/actions/validate-captcha.ts
Normal file
33
src/actions/validate-captcha.ts
Normal file
@ -0,0 +1,33 @@
|
||||
'use server';
|
||||
|
||||
import { validateTurnstileToken } from '@/lib/captcha';
|
||||
import { actionClient } from '@/lib/safe-action';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Captcha validation schema
|
||||
const captchaSchema = z.object({
|
||||
captchaToken: z.string().min(1, { error: 'Captcha token is required' }),
|
||||
});
|
||||
|
||||
// Create a safe action for captcha validation
|
||||
export const validateCaptchaAction = actionClient
|
||||
.schema(captchaSchema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const { captchaToken } = parsedInput;
|
||||
|
||||
try {
|
||||
const isValid = await validateTurnstileToken(captchaToken);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
valid: isValid,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Captcha validation error:', error);
|
||||
return {
|
||||
success: false,
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : 'Something went wrong',
|
||||
};
|
||||
}
|
||||
});
|
181
src/ai/chat/components/ChatBot.tsx
Normal file
181
src/ai/chat/components/ChatBot.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation';
|
||||
import { Loader } from '@/components/ai-elements/loader';
|
||||
import { Message, MessageContent } from '@/components/ai-elements/message';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputButton,
|
||||
PromptInputModelSelect,
|
||||
PromptInputModelSelectContent,
|
||||
PromptInputModelSelectItem,
|
||||
PromptInputModelSelectTrigger,
|
||||
PromptInputModelSelectValue,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputToolbar,
|
||||
PromptInputTools,
|
||||
} from '@/components/ai-elements/prompt-input';
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning';
|
||||
import { Response } from '@/components/ai-elements/response';
|
||||
import {
|
||||
Source,
|
||||
Sources,
|
||||
SourcesContent,
|
||||
SourcesTrigger,
|
||||
} from '@/components/ai-elements/source';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { GlobeIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
const models = [
|
||||
{
|
||||
name: 'GPT 4o',
|
||||
value: 'openai/gpt-4o',
|
||||
},
|
||||
{
|
||||
name: 'Deepseek R1',
|
||||
value: 'deepseek/deepseek-r1',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ChatBot() {
|
||||
const [input, setInput] = useState('');
|
||||
const [model, setModel] = useState<string>(models[0].value);
|
||||
const [webSearch, setWebSearch] = useState(false);
|
||||
const { messages, sendMessage, status } = useChat();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (input.trim()) {
|
||||
sendMessage(
|
||||
{ text: input },
|
||||
{
|
||||
body: {
|
||||
model: model,
|
||||
webSearch: webSearch,
|
||||
},
|
||||
}
|
||||
);
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto p-6 relative size-full h-screen rounded-lg bg-muted">
|
||||
<div className="flex flex-col h-full">
|
||||
<Conversation className="h-full">
|
||||
<ConversationContent>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id}>
|
||||
{message.role === 'assistant' && (
|
||||
<Sources>
|
||||
{message.parts.map((part, i) => {
|
||||
switch (part.type) {
|
||||
case 'source-url':
|
||||
return (
|
||||
<>
|
||||
<SourcesTrigger
|
||||
count={
|
||||
message.parts.filter(
|
||||
(part) => part.type === 'source-url'
|
||||
).length
|
||||
}
|
||||
/>
|
||||
<SourcesContent key={`${message.id}-${i}`}>
|
||||
<Source
|
||||
key={`${message.id}-${i}`}
|
||||
href={part.url}
|
||||
title={part.url}
|
||||
/>
|
||||
</SourcesContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</Sources>
|
||||
)}
|
||||
<Message from={message.role} key={message.id}>
|
||||
<MessageContent>
|
||||
{message.parts.map((part, i) => {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<Response key={`${message.id}-${i}`}>
|
||||
{part.text}
|
||||
</Response>
|
||||
);
|
||||
case 'reasoning':
|
||||
return (
|
||||
<Reasoning
|
||||
key={`${message.id}-${i}`}
|
||||
className="w-full"
|
||||
isStreaming={status === 'streaming'}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</div>
|
||||
))}
|
||||
{status === 'submitted' && <Loader />}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
<PromptInput onSubmit={handleSubmit} className="mt-4">
|
||||
<PromptInputTextarea
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
value={input}
|
||||
/>
|
||||
<PromptInputToolbar>
|
||||
<PromptInputTools>
|
||||
<PromptInputButton
|
||||
variant={webSearch ? 'default' : 'ghost'}
|
||||
onClick={() => setWebSearch(!webSearch)}
|
||||
>
|
||||
<GlobeIcon size={16} />
|
||||
<span>Search</span>
|
||||
</PromptInputButton>
|
||||
<PromptInputModelSelect
|
||||
onValueChange={(value) => {
|
||||
setModel(value);
|
||||
}}
|
||||
value={model}
|
||||
>
|
||||
<PromptInputModelSelectTrigger>
|
||||
<PromptInputModelSelectValue />
|
||||
</PromptInputModelSelectTrigger>
|
||||
<PromptInputModelSelectContent>
|
||||
{models.map((model) => (
|
||||
<PromptInputModelSelectItem
|
||||
key={model.value}
|
||||
value={model.value}
|
||||
>
|
||||
{model.name}
|
||||
</PromptInputModelSelectItem>
|
||||
))}
|
||||
</PromptInputModelSelectContent>
|
||||
</PromptInputModelSelect>
|
||||
</PromptInputTools>
|
||||
<PromptInputSubmit disabled={!input} status={status} />
|
||||
</PromptInputToolbar>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
102
src/ai/image/components/ImageCarousel.tsx
Normal file
102
src/ai/image/components/ImageCarousel.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from '@/components/ui/carousel';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { GeneratedImage, ProviderTiming } from '../lib/image-types';
|
||||
import type { ProviderKey } from '../lib/provider-config';
|
||||
import { ImageDisplay } from './ImageDisplay';
|
||||
|
||||
interface ImageCarouselProps {
|
||||
providers: ProviderKey[];
|
||||
images: GeneratedImage[];
|
||||
timings: Record<ProviderKey, ProviderTiming>;
|
||||
failedProviders: ProviderKey[];
|
||||
enabledProviders: Record<ProviderKey, boolean>;
|
||||
providerToModel: Record<ProviderKey, string>;
|
||||
}
|
||||
|
||||
export function ImageCarousel({
|
||||
providers,
|
||||
images,
|
||||
timings,
|
||||
failedProviders,
|
||||
enabledProviders,
|
||||
providerToModel,
|
||||
}: ImageCarouselProps) {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
api.on('select', () => {
|
||||
setCurrentSlide(api.selectedScrollSnap());
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Carousel setApi={setApi} opts={{ align: 'start', loop: true }}>
|
||||
<CarouselContent>
|
||||
{providers.map((provider, i) => {
|
||||
const imageData = images?.find(
|
||||
(img) => img.provider === provider
|
||||
)?.image;
|
||||
const timing = timings[provider];
|
||||
|
||||
return (
|
||||
<CarouselItem key={provider}>
|
||||
<ImageDisplay
|
||||
modelId={
|
||||
images?.find((img) => img.provider === provider)?.modelId ||
|
||||
providerToModel[provider]
|
||||
}
|
||||
provider={provider}
|
||||
image={imageData}
|
||||
timing={timing}
|
||||
failed={failedProviders.includes(provider)}
|
||||
enabled={enabledProviders[provider]}
|
||||
/>
|
||||
<div className="text-center text-sm text-muted-foreground mt-4">
|
||||
{i + 1} of {providers.length}
|
||||
</div>
|
||||
</CarouselItem>
|
||||
);
|
||||
})}
|
||||
</CarouselContent>
|
||||
|
||||
<CarouselPrevious className="left-0 bg-background/80 backdrop-blur-sm" />
|
||||
<CarouselNext className="right-0 bg-background/80 backdrop-blur-sm" />
|
||||
</Carousel>
|
||||
|
||||
{/* Dot Indicators */}
|
||||
<div className="absolute -bottom-6 left-0 right-0">
|
||||
<div className="flex justify-center gap-1">
|
||||
{providers.map((_, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
className={cn(
|
||||
'h-1.5 rounded-full transition-all',
|
||||
index === currentSlide
|
||||
? 'w-4 bg-primary'
|
||||
: 'w-1.5 bg-primary/50'
|
||||
)}
|
||||
onClick={() => api?.scrollTo(index)}
|
||||
>
|
||||
<span className="sr-only">Go to image {index + 1}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
195
src/ai/image/components/ImageDisplay.tsx
Normal file
195
src/ai/image/components/ImageDisplay.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AlertCircle, Download, ImageIcon, Share } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { imageHelpers } from '../lib/image-helpers';
|
||||
import type { ProviderTiming } from '../lib/image-types';
|
||||
import { Stopwatch } from './Stopwatch';
|
||||
|
||||
interface ImageDisplayProps {
|
||||
provider: string;
|
||||
image: string | null | undefined;
|
||||
timing?: ProviderTiming;
|
||||
failed?: boolean;
|
||||
fallbackIcon?: React.ReactNode;
|
||||
enabled?: boolean;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export function ImageDisplay({
|
||||
provider,
|
||||
image,
|
||||
timing,
|
||||
failed,
|
||||
fallbackIcon,
|
||||
modelId,
|
||||
}: ImageDisplayProps) {
|
||||
const [isZoomed, setIsZoomed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isZoomed) {
|
||||
window.history.pushState({ zoomed: true }, '');
|
||||
}
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isZoomed) {
|
||||
setIsZoomed(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePopState = () => {
|
||||
if (isZoomed) {
|
||||
setIsZoomed(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isZoomed) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, [isZoomed]);
|
||||
|
||||
const handleImageClick = (e: React.MouseEvent) => {
|
||||
if (image) {
|
||||
e.stopPropagation();
|
||||
setIsZoomed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActionClick = (
|
||||
e: React.MouseEvent,
|
||||
imageData: string,
|
||||
provider: string
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
imageHelpers.shareOrDownload(imageData, provider).catch((error) => {
|
||||
console.error('Failed to share/download image:', error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full aspect-square group bg-zinc-50 rounded-lg',
|
||||
image && !failed && 'cursor-pointer',
|
||||
(!image || failed) && 'border-1 border-zinc-100'
|
||||
)}
|
||||
onClick={handleImageClick}
|
||||
>
|
||||
{(image || failed) && (
|
||||
<div className="absolute top-2 left-2 max-w-[75%] bg-white/95 px-2 py-1 flex items-center gap-2 rounded-lg">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Label className="text-xs text-gray-900 truncate min-w-0 grow">
|
||||
{imageHelpers.formatModelId(modelId)}
|
||||
</Label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{modelId}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
{image && !failed ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`data:image/png;base64,${image}`}
|
||||
alt={`Generated by ${provider}`}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="absolute bottom-2 left-2 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => handleActionClick(e, image, provider)}
|
||||
>
|
||||
<span className="sm:hidden">
|
||||
<Share className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="hidden sm:block">
|
||||
<Download className="h-4 w-4" />
|
||||
</span>
|
||||
</Button>
|
||||
{timing?.elapsed && (
|
||||
<div className="absolute bottom-2 right-2 bg-black/70 backdrop-blur-sm rounded-md px-2 py-1 shadow">
|
||||
<span className="text-xs text-white/90 font-medium">
|
||||
{(timing.elapsed / 1000).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{failed ? (
|
||||
fallbackIcon || <AlertCircle className="h-8 w-8 text-red-500" />
|
||||
) : image ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`data:image/png;base64,${image}`}
|
||||
alt={`Generated by ${provider}`}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="absolute bottom-2 left-2 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => handleActionClick(e, image, provider)}
|
||||
>
|
||||
<span className="sm:hidden">
|
||||
<Share className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="hidden sm:block">
|
||||
<Download className="h-4 w-4" />
|
||||
</span>
|
||||
</Button>
|
||||
</>
|
||||
) : timing?.startTime ? (
|
||||
<>
|
||||
{/* <div className="text-zinc-400 mb-2">{provider}</div> */}
|
||||
<Stopwatch startTime={timing.startTime} />
|
||||
</>
|
||||
) : (
|
||||
<ImageIcon className="h-12 w-12 text-zinc-300" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isZoomed &&
|
||||
image &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center cursor-pointer min-h-[100dvh] w-screen"
|
||||
onClick={() => setIsZoomed(false)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`data:image/png;base64,${image}`}
|
||||
alt={`Generated by ${provider}`}
|
||||
className="max-h-[90dvh] max-w-[90vw] object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
120
src/ai/image/components/ImageGenerator.tsx
Normal file
120
src/ai/image/components/ImageGenerator.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { AlertCircle, ChevronDown, Settings } from 'lucide-react';
|
||||
import type {
|
||||
GeneratedImage,
|
||||
ImageError,
|
||||
ProviderTiming,
|
||||
} from '../lib/image-types';
|
||||
import {
|
||||
PROVIDER_ORDER,
|
||||
type ProviderKey,
|
||||
initializeProviderRecord,
|
||||
} from '../lib/provider-config';
|
||||
import { ImageCarousel } from './ImageCarousel';
|
||||
import { ImageDisplay } from './ImageDisplay';
|
||||
|
||||
interface ImageGeneratorProps {
|
||||
images: GeneratedImage[];
|
||||
errors: ImageError[];
|
||||
failedProviders: ProviderKey[];
|
||||
timings: Record<ProviderKey, ProviderTiming>;
|
||||
enabledProviders: Record<ProviderKey, boolean>;
|
||||
toggleView: () => void;
|
||||
}
|
||||
|
||||
export function ImageGenerator({
|
||||
images,
|
||||
errors,
|
||||
failedProviders,
|
||||
timings,
|
||||
enabledProviders,
|
||||
toggleView,
|
||||
}: ImageGeneratorProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* If there are errors, render a collapsible alert */}
|
||||
{errors.length > 0 && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex items-center gap-2 text-destructive"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{errors.length} {errors.length === 1 ? 'error' : 'errors'}{' '}
|
||||
occurred
|
||||
<ChevronDown className="h-4 w-4 transition-transform duration-200 data-[state=open]:rotate-180" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-2 mt-2">
|
||||
{errors.map((err, index) => (
|
||||
<Alert key={index} variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="ml-3">
|
||||
<AlertTitle className="capitalize">
|
||||
{err.provider} Error
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-1 text-sm">
|
||||
{err.message}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold">Generated Images</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
className=""
|
||||
onClick={() => toggleView()}
|
||||
size="icon"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mobile layout: Carousel */}
|
||||
<div className="sm:hidden">
|
||||
<ImageCarousel
|
||||
providers={PROVIDER_ORDER}
|
||||
images={images}
|
||||
timings={timings}
|
||||
failedProviders={failedProviders}
|
||||
enabledProviders={enabledProviders}
|
||||
providerToModel={initializeProviderRecord<string>()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop layout: Grid */}
|
||||
<div className="hidden sm:grid sm:grid-cols-2 2xl:grid-cols-4 gap-6">
|
||||
{PROVIDER_ORDER.map((provider) => {
|
||||
const imageItem = images.find((img) => img.provider === provider);
|
||||
const imageData = imageItem?.image;
|
||||
const timing = timings[provider];
|
||||
return (
|
||||
<ImageDisplay
|
||||
key={provider}
|
||||
provider={provider}
|
||||
image={imageData}
|
||||
timing={timing}
|
||||
failed={failedProviders.includes(provider)}
|
||||
enabled={enabledProviders[provider]}
|
||||
modelId={imageItem?.modelId ?? ''}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
25
src/ai/image/components/ImageGeneratorHeader.tsx
Normal file
25
src/ai/image/components/ImageGeneratorHeader.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowUpRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { QualityModeToggle } from './QualityModeToggle';
|
||||
|
||||
export const ImageGeneratorHeader = () => {
|
||||
return (
|
||||
<header className="mb-4">
|
||||
<div className="mx-auto flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-xl flex sm:text-xl sm:font-bold antialiased font-semibold">
|
||||
<span className="mr-2">🏞️</span> AI Image Generator
|
||||
</h1>
|
||||
</div>
|
||||
{/* <Link href={`${process.env.NEXT_PUBLIC_APP_URL}`} target="_blank">
|
||||
<Button size="icon" className="block sm:hidden">
|
||||
<ArrowUpRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link> */}
|
||||
|
||||
{/* <QualityModeToggle onValueChange={() => {}} value="performance" /> */}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
144
src/ai/image/components/ImagePlayground.tsx
Normal file
144
src/ai/image/components/ImagePlayground.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useImageGeneration } from '../hooks/use-image-generation';
|
||||
import {
|
||||
MODEL_CONFIGS,
|
||||
type ModelMode,
|
||||
PROVIDERS,
|
||||
PROVIDER_ORDER,
|
||||
type ProviderKey,
|
||||
initializeProviderRecord,
|
||||
} from '../lib/provider-config';
|
||||
import type { Suggestion } from '../lib/suggestions';
|
||||
import { ImageGeneratorHeader } from './ImageGeneratorHeader';
|
||||
import { ModelCardCarousel } from './ModelCardCarousel';
|
||||
import { ModelSelect } from './ModelSelect';
|
||||
import { PromptInput } from './PromptInput';
|
||||
|
||||
export function ImagePlayground({
|
||||
suggestions,
|
||||
}: {
|
||||
suggestions: Suggestion[];
|
||||
}) {
|
||||
const {
|
||||
images,
|
||||
timings,
|
||||
failedProviders,
|
||||
isLoading,
|
||||
startGeneration,
|
||||
activePrompt,
|
||||
} = useImageGeneration();
|
||||
|
||||
const [showProviders, setShowProviders] = useState(true);
|
||||
const [selectedModels, setSelectedModels] = useState<
|
||||
Record<ProviderKey, string>
|
||||
>(MODEL_CONFIGS.performance);
|
||||
const [enabledProviders, setEnabledProviders] = useState(
|
||||
initializeProviderRecord(true)
|
||||
);
|
||||
const [mode, setMode] = useState<ModelMode>('performance');
|
||||
const toggleView = () => {
|
||||
setShowProviders((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleModeChange = (newMode: ModelMode) => {
|
||||
setMode(newMode);
|
||||
setSelectedModels(MODEL_CONFIGS[newMode]);
|
||||
setShowProviders(true);
|
||||
};
|
||||
|
||||
const handleModelChange = (providerKey: ProviderKey, model: string) => {
|
||||
setSelectedModels((prev) => ({ ...prev, [providerKey]: model }));
|
||||
};
|
||||
|
||||
const handleProviderToggle = (provider: string, enabled: boolean) => {
|
||||
setEnabledProviders((prev) => ({
|
||||
...prev,
|
||||
[provider]: enabled,
|
||||
}));
|
||||
};
|
||||
|
||||
const providerToModel = {
|
||||
replicate: selectedModels.replicate,
|
||||
openai: selectedModels.openai,
|
||||
fireworks: selectedModels.fireworks,
|
||||
fal: selectedModels.fal,
|
||||
};
|
||||
|
||||
const handlePromptSubmit = (newPrompt: string) => {
|
||||
const activeProviders = PROVIDER_ORDER.filter((p) => enabledProviders[p]);
|
||||
if (activeProviders.length > 0) {
|
||||
startGeneration(newPrompt, activeProviders, providerToModel);
|
||||
}
|
||||
setShowProviders(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-background py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto">
|
||||
{/* header */}
|
||||
{/* <ImageGeneratorHeader /> */}
|
||||
|
||||
{/* input prompt */}
|
||||
<PromptInput
|
||||
onSubmit={handlePromptSubmit}
|
||||
isLoading={isLoading}
|
||||
showProviders={showProviders}
|
||||
onToggleProviders={toggleView}
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
suggestions={suggestions}
|
||||
/>
|
||||
|
||||
{/* models carousel */}
|
||||
{(() => {
|
||||
const getModelProps = () =>
|
||||
(Object.keys(PROVIDERS) as ProviderKey[]).map((key) => {
|
||||
const provider = PROVIDERS[key];
|
||||
const imageItem = images.find((img) => img.provider === key);
|
||||
const imageData = imageItem?.image;
|
||||
const modelId = imageItem?.modelId ?? 'N/A';
|
||||
const timing = timings[key];
|
||||
|
||||
return {
|
||||
label: provider.displayName,
|
||||
models: provider.models,
|
||||
value: selectedModels[key],
|
||||
providerKey: key,
|
||||
onChange: (model: string, providerKey: ProviderKey) =>
|
||||
handleModelChange(providerKey, model),
|
||||
iconPath: provider.iconPath,
|
||||
color: provider.color,
|
||||
enabled: enabledProviders[key],
|
||||
onToggle: (enabled: boolean) =>
|
||||
handleProviderToggle(key, enabled),
|
||||
image: imageData,
|
||||
modelId,
|
||||
timing,
|
||||
failed: failedProviders.includes(key),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:hidden">
|
||||
<ModelCardCarousel models={getModelProps()} />
|
||||
</div>
|
||||
<div className="hidden md:grid md:grid-cols-2 2xl:grid-cols-4 gap-8">
|
||||
{getModelProps().map((props) => (
|
||||
<ModelSelect key={props.label} {...props} />
|
||||
))}
|
||||
</div>
|
||||
{activePrompt && activePrompt.length > 0 && (
|
||||
<div className="text-center mt-8 text-muted-foreground">
|
||||
{activePrompt}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user