Compare commits
1 Commits
cloudflare
...
dev/ai-gat
Author | SHA1 | Date | |
---|---|---|---|
|
e78a992dd6 |
@ -1,7 +1,4 @@
|
|||||||
.cursor
|
.cursor
|
||||||
.claude
|
|
||||||
.conductor
|
|
||||||
.kiro
|
|
||||||
.github
|
.github
|
||||||
.next
|
.next
|
||||||
.open-next
|
.open-next
|
||||||
@ -13,4 +10,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
**/node_modules
|
**/node_modules
|
||||||
Dockerfile
|
Dockerfile
|
||||||
LICENSE
|
LICENSE
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -41,9 +41,6 @@ certificates
|
|||||||
# claude code
|
# claude code
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
# conductor
|
|
||||||
.conductor
|
|
||||||
|
|
||||||
# kiro
|
# kiro
|
||||||
.kiro
|
.kiro
|
||||||
|
|
||||||
|
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@ -24,12 +24,6 @@
|
|||||||
".next": true,
|
".next": true,
|
||||||
".source": true,
|
".source": true,
|
||||||
".wrangler": true,
|
".wrangler": true,
|
||||||
".open-next": true,
|
".open-next": true
|
||||||
".vscode": true,
|
|
||||||
".cursor": true,
|
|
||||||
".claude": true,
|
|
||||||
".conductor": true,
|
|
||||||
".kiro": true,
|
|
||||||
".github": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,7 @@ If you found anything that could be improved, please let me know.
|
|||||||
- 📚 documentation: [mksaas.com/docs](https://mksaas.com/docs)
|
- 📚 documentation: [mksaas.com/docs](https://mksaas.com/docs)
|
||||||
- 🗓️ roadmap: [mksaas roadmap](https://mksaas.link/roadmap)
|
- 🗓️ roadmap: [mksaas roadmap](https://mksaas.link/roadmap)
|
||||||
- 👨💻 discord: [mksaas.link/discord](https://mksaas.link/discord)
|
- 👨💻 discord: [mksaas.link/discord](https://mksaas.link/discord)
|
||||||
- 📹 video: [mksaas.link/youtube](https://mksaas.link/youtube)
|
- 📹 video (WIP): [mksaas.link/youtube](https://mksaas.link/youtube)
|
||||||
|
|
||||||
## Repositories
|
## Repositories
|
||||||
|
|
||||||
|
14
biome.json
14
biome.json
@ -12,9 +12,6 @@
|
|||||||
".open-next/**",
|
".open-next/**",
|
||||||
".wrangler/**",
|
".wrangler/**",
|
||||||
".cursor/**",
|
".cursor/**",
|
||||||
".claude/**",
|
|
||||||
".kiro/**",
|
|
||||||
".conductor/**",
|
|
||||||
".vscode/**",
|
".vscode/**",
|
||||||
".source/**",
|
".source/**",
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
@ -26,11 +23,11 @@
|
|||||||
"src/components/magicui/*.tsx",
|
"src/components/magicui/*.tsx",
|
||||||
"src/components/animate-ui/*.tsx",
|
"src/components/animate-ui/*.tsx",
|
||||||
"src/components/tailark/*.tsx",
|
"src/components/tailark/*.tsx",
|
||||||
"src/components/ai-elements/*.tsx",
|
|
||||||
"src/app/[[]locale]/preview/**",
|
"src/app/[[]locale]/preview/**",
|
||||||
"src/payment/types.ts",
|
"src/payment/types.ts",
|
||||||
"src/credits/types.ts",
|
"src/credits/types.ts",
|
||||||
"src/types/index.d.ts"
|
"src/types/index.d.ts",
|
||||||
|
"public/sw.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
@ -77,9 +74,6 @@
|
|||||||
".open-next/**",
|
".open-next/**",
|
||||||
".wrangler/**",
|
".wrangler/**",
|
||||||
".cursor/**",
|
".cursor/**",
|
||||||
".claude/**",
|
|
||||||
".conductor/**",
|
|
||||||
".kiro/**",
|
|
||||||
".vscode/**",
|
".vscode/**",
|
||||||
".source/**",
|
".source/**",
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
@ -91,11 +85,11 @@
|
|||||||
"src/components/magicui/*.tsx",
|
"src/components/magicui/*.tsx",
|
||||||
"src/components/animate-ui/*.tsx",
|
"src/components/animate-ui/*.tsx",
|
||||||
"src/components/tailark/*.tsx",
|
"src/components/tailark/*.tsx",
|
||||||
"src/components/ai-elements/*.tsx",
|
|
||||||
"src/app/[[]locale]/preview/**",
|
"src/app/[[]locale]/preview/**",
|
||||||
"src/payment/types.ts",
|
"src/payment/types.ts",
|
||||||
"src/credits/types.ts",
|
"src/credits/types.ts",
|
||||||
"src/types/index.d.ts"
|
"src/types/index.d.ts",
|
||||||
|
"public/sw.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
|
7483
cloudflare-env.d.ts
vendored
7483
cloudflare-env.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@ -1,56 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
@ -1,56 +0,0 @@
|
|||||||
---
|
|
||||||
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,6 @@
|
|||||||
title: What is Fumadocs
|
title: What is Fumadocs
|
||||||
description: Introducing Fumadocs, a docs framework that you can break.
|
description: Introducing Fumadocs, a docs framework that you can break.
|
||||||
icon: CircleHelp
|
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**.
|
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**.
|
||||||
@ -19,8 +18,6 @@ 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.
|
**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.
|
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
|
## Why Fumadocs
|
||||||
|
|
||||||
Fumadocs is designed with flexibility in mind.
|
Fumadocs is designed with flexibility in mind.
|
||||||
@ -59,5 +56,3 @@ docs easier, with less boilerplate.
|
|||||||
Fumadocs is maintained by Fuma and many contributors, with care on the maintainability of codebase.
|
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.
|
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!
|
You can also help Fumadocs to be more useful by contributing!
|
||||||
|
|
||||||
</PremiumContent>
|
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
title: 什么是 Fumadocs
|
title: 什么是 Fumadocs
|
||||||
description: 介绍 Fumadocs,一个可以打破常规的文档框架
|
description: 介绍 Fumadocs,一个可以打破常规的文档框架
|
||||||
icon: CircleHelp
|
icon: CircleHelp
|
||||||
premium: true
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Fumadocs 的创建是因为我想要一种更加可定制化的文档构建体验,一个不固执己见的文档框架,**一个你可以"打破"的"框架"**。
|
Fumadocs 的创建是因为我想要一种更加可定制化的文档构建体验,一个不固执己见的文档框架,**一个你可以"打破"的"框架"**。
|
||||||
@ -19,8 +18,6 @@ Fumadocs 的创建是因为我想要一种更加可定制化的文档构建体
|
|||||||
**对 UI 有自己的看法:** Fumadocs UI(默认主题)提供的唯一东西是**用户界面**。UI 的设计理念是提供更好的移动响应性和用户体验。
|
**对 UI 有自己的看法:** Fumadocs UI(默认主题)提供的唯一东西是**用户界面**。UI 的设计理念是提供更好的移动响应性和用户体验。
|
||||||
相反,我们使用受 Shadcn UI 启发的更灵活的方法 — [Fumadocs CLI](/docs/cli),这样我们可以快速迭代设计,并欢迎更多关于 UI 的反馈。
|
相反,我们使用受 Shadcn UI 启发的更灵活的方法 — [Fumadocs CLI](/docs/cli),这样我们可以快速迭代设计,并欢迎更多关于 UI 的反馈。
|
||||||
|
|
||||||
<PremiumContent>
|
|
||||||
|
|
||||||
## 为什么选择 Fumadocs
|
## 为什么选择 Fumadocs
|
||||||
|
|
||||||
Fumadocs 的设计考虑了灵活性。
|
Fumadocs 的设计考虑了灵活性。
|
||||||
@ -56,6 +53,4 @@ Fumadocs 为 Next.js 提供了额外的工具,包括语法高亮、文档搜
|
|||||||
|
|
||||||
Fumadocs 由 Fuma 和许多贡献者维护,关注代码库的可维护性。
|
Fumadocs 由 Fuma 和许多贡献者维护,关注代码库的可维护性。
|
||||||
虽然我们不打算提供人们想要的每一项功能,但我们更专注于使基本功能完美且维护良好。
|
虽然我们不打算提供人们想要的每一项功能,但我们更专注于使基本功能完美且维护良好。
|
||||||
您也可以通过贡献来帮助 Fumadocs 变得更加有用!
|
您也可以通过贡献来帮助 Fumadocs 变得更加有用!
|
||||||
|
|
||||||
</PremiumContent>
|
|
@ -1 +0,0 @@
|
|||||||
NEXTJS_ENV=development
|
|
@ -181,7 +181,6 @@ CRON_JOBS_PASSWORD=""
|
|||||||
# AI
|
# AI
|
||||||
# https://mksaas.com/docs/ai
|
# https://mksaas.com/docs/ai
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
AI_GATEWAY_API_KEY=""
|
|
||||||
FAL_API_KEY=""
|
FAL_API_KEY=""
|
||||||
FIREWORKS_API_KEY=""
|
FIREWORKS_API_KEY=""
|
||||||
OPENAI_API_KEY=""
|
OPENAI_API_KEY=""
|
||||||
|
@ -5,7 +5,6 @@
|
|||||||
"description": "MkSaaS is the best AI SaaS boilerplate. Make AI SaaS in days, simply and effortlessly"
|
"description": "MkSaaS is the best AI SaaS boilerplate. Make AI SaaS in days, simply and effortlessly"
|
||||||
},
|
},
|
||||||
"Common": {
|
"Common": {
|
||||||
"premium": "Premium",
|
|
||||||
"login": "Log in",
|
"login": "Log in",
|
||||||
"logout": "Log out",
|
"logout": "Log out",
|
||||||
"signUp": "Sign up",
|
"signUp": "Sign up",
|
||||||
@ -293,20 +292,8 @@
|
|||||||
"nextPage": "Next",
|
"nextPage": "Next",
|
||||||
"chooseLanguage": "Select language",
|
"chooseLanguage": "Select language",
|
||||||
"title": "MkSaaS Docs",
|
"title": "MkSaaS Docs",
|
||||||
"homepage": "Homepage"
|
"homepage": "Homepage",
|
||||||
},
|
"blog": "Blog"
|
||||||
"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": {
|
"Marketing": {
|
||||||
"navbar": {
|
"navbar": {
|
||||||
@ -333,10 +320,6 @@
|
|||||||
"title": "AI Image",
|
"title": "AI Image",
|
||||||
"description": "Show how to use AI to generate beautiful images"
|
"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": {
|
"video": {
|
||||||
"title": "AI Video",
|
"title": "AI Video",
|
||||||
"description": "Show how to use AI to generate amazing videos"
|
"description": "Show how to use AI to generate amazing videos"
|
||||||
@ -591,7 +574,7 @@
|
|||||||
},
|
},
|
||||||
"price": "Price:",
|
"price": "Price:",
|
||||||
"periodStartDate": "Period start date:",
|
"periodStartDate": "Period start date:",
|
||||||
"periodEndDate": "Period end date:",
|
"nextBillingDate": "Next billing date:",
|
||||||
"trialEnds": "Trial ends:",
|
"trialEnds": "Trial ends:",
|
||||||
"freePlanMessage": "You are currently on the free plan with limited features",
|
"freePlanMessage": "You are currently on the free plan with limited features",
|
||||||
"lifetimeMessage": "You have lifetime access to all premium features",
|
"lifetimeMessage": "You have lifetime access to all premium features",
|
||||||
@ -618,7 +601,8 @@
|
|||||||
"creditsAdded": "Credits have been added to your account",
|
"creditsAdded": "Credits have been added to your account",
|
||||||
"viewTransactions": "View Credit Transactions",
|
"viewTransactions": "View Credit Transactions",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"expiringCredits": "{credits} credits expiring in the next {days} days"
|
|
||||||
|
"expiringCredits": "{credits} credits expiring on {date}"
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"title": "Credit Packages",
|
"title": "Credit Packages",
|
||||||
@ -1057,18 +1041,17 @@
|
|||||||
},
|
},
|
||||||
"AIImagePage": {
|
"AIImagePage": {
|
||||||
"title": "AI Image",
|
"title": "AI Image",
|
||||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
|
||||||
},
|
"content": "Working in progress"
|
||||||
"AIChatPage": {
|
|
||||||
"title": "AI Chat",
|
|
||||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
|
||||||
},
|
},
|
||||||
"AIVideoPage": {
|
"AIVideoPage": {
|
||||||
"title": "AI Video",
|
"title": "AI Video",
|
||||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
|
||||||
|
"content": "Working in progress"
|
||||||
},
|
},
|
||||||
"AIAudioPage": {
|
"AIAudioPage": {
|
||||||
"title": "AI Audio",
|
"title": "AI Audio",
|
||||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
|
||||||
|
"content": "Working in progress"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,6 @@
|
|||||||
"description": "MkSaaS 是构建 AI SaaS 的最佳模板,使用 MkSaaS 可以在几天内轻松构建您的 AI SaaS,简单且毫不费力。"
|
"description": "MkSaaS 是构建 AI SaaS 的最佳模板,使用 MkSaaS 可以在几天内轻松构建您的 AI SaaS,简单且毫不费力。"
|
||||||
},
|
},
|
||||||
"Common": {
|
"Common": {
|
||||||
"premium": "付费文章",
|
|
||||||
"login": "登录",
|
"login": "登录",
|
||||||
"logout": "退出",
|
"logout": "退出",
|
||||||
"signUp": "注册",
|
"signUp": "注册",
|
||||||
@ -293,20 +292,8 @@
|
|||||||
"nextPage": "下一页",
|
"nextPage": "下一页",
|
||||||
"chooseLanguage": "选择语言",
|
"chooseLanguage": "选择语言",
|
||||||
"title": "MkSaaS文档",
|
"title": "MkSaaS文档",
|
||||||
"homepage": "首页"
|
"homepage": "首页",
|
||||||
},
|
"blog": "博客"
|
||||||
"PremiumContent": {
|
|
||||||
"title": "解锁付费内容",
|
|
||||||
"description": "订阅我们的付费计划,访问所有付费内容和独家内容。",
|
|
||||||
"upgradeCta": "立即升级",
|
|
||||||
"benefit1": "所有内容",
|
|
||||||
"benefit2": "独家内容",
|
|
||||||
"benefit3": "随时取消",
|
|
||||||
"signIn": "登录",
|
|
||||||
"loginRequired": "登录以继续阅读",
|
|
||||||
"loginDescription": "这是一篇付费内容,请登录您的账户以访问完整内容。",
|
|
||||||
"checkingAccess": "检查阅读权限...",
|
|
||||||
"loadingContent": "加载完整内容..."
|
|
||||||
},
|
},
|
||||||
"Marketing": {
|
"Marketing": {
|
||||||
"navbar": {
|
"navbar": {
|
||||||
@ -333,10 +320,6 @@
|
|||||||
"title": "AI 图像",
|
"title": "AI 图像",
|
||||||
"description": "展示如何使用 AI 生成精美图像"
|
"description": "展示如何使用 AI 生成精美图像"
|
||||||
},
|
},
|
||||||
"chat": {
|
|
||||||
"title": "AI 聊天",
|
|
||||||
"description": "展示如何使用 AI 与客户聊天"
|
|
||||||
},
|
|
||||||
"video": {
|
"video": {
|
||||||
"title": "AI 视频",
|
"title": "AI 视频",
|
||||||
"description": "展示如何使用 AI 生成惊人视频"
|
"description": "展示如何使用 AI 生成惊人视频"
|
||||||
@ -591,7 +574,7 @@
|
|||||||
},
|
},
|
||||||
"price": "价格:",
|
"price": "价格:",
|
||||||
"periodStartDate": "周期开始日期:",
|
"periodStartDate": "周期开始日期:",
|
||||||
"periodEndDate": "周期结束日期:",
|
"nextBillingDate": "下次账单日期:",
|
||||||
"trialEnds": "试用结束日期:",
|
"trialEnds": "试用结束日期:",
|
||||||
"freePlanMessage": "您当前使用的是功能有限的免费方案",
|
"freePlanMessage": "您当前使用的是功能有限的免费方案",
|
||||||
"lifetimeMessage": "您拥有所有高级功能的终身使用权限",
|
"lifetimeMessage": "您拥有所有高级功能的终身使用权限",
|
||||||
@ -618,7 +601,8 @@
|
|||||||
"creditsAdded": "积分已添加到您的账户",
|
"creditsAdded": "积分已添加到您的账户",
|
||||||
"viewTransactions": "查看积分记录",
|
"viewTransactions": "查看积分记录",
|
||||||
"retry": "重试",
|
"retry": "重试",
|
||||||
"expiringCredits": "{credits} 积分将在 {days} 天内过期"
|
|
||||||
|
"expiringCredits": "{credits} 积分将在 {date} 过期"
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"title": "积分套餐",
|
"title": "积分套餐",
|
||||||
@ -1057,18 +1041,17 @@
|
|||||||
},
|
},
|
||||||
"AIImagePage": {
|
"AIImagePage": {
|
||||||
"title": "AI 图片",
|
"title": "AI 图片",
|
||||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力",
|
||||||
},
|
"content": "正在开发中"
|
||||||
"AIChatPage": {
|
|
||||||
"title": "AI 聊天",
|
|
||||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
|
||||||
},
|
},
|
||||||
"AIVideoPage": {
|
"AIVideoPage": {
|
||||||
"title": "AI 视频",
|
"title": "AI 视频",
|
||||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力",
|
||||||
|
"content": "正在开发中"
|
||||||
},
|
},
|
||||||
"AIAudioPage": {
|
"AIAudioPage": {
|
||||||
"title": "AI 音频",
|
"title": "AI 音频",
|
||||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力",
|
||||||
|
"content": "正在开发中"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,18 +18,6 @@ const nextConfig: NextConfig = {
|
|||||||
// 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: {
|
images: {
|
||||||
// https://vercel.com/docs/image-optimization/managing-image-optimization-costs#minimizing-image-optimization-costs
|
// https://vercel.com/docs/image-optimization/managing-image-optimization-costs#minimizing-image-optimization-costs
|
||||||
// https://nextjs.org/docs/app/api-reference/components/image#unoptimized
|
// https://nextjs.org/docs/app/api-reference/components/image#unoptimized
|
||||||
@ -60,10 +48,6 @@ const nextConfig: NextConfig = {
|
|||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: 'html.tailus.io',
|
hostname: 'html.tailus.io',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: 'service.firecrawl.dev',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -82,9 +66,3 @@ const withNextIntl = createNextIntlPlugin();
|
|||||||
const withMDX = createMDX();
|
const withMDX = createMDX();
|
||||||
|
|
||||||
export default withMDX(withNextIntl(nextConfig));
|
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();
|
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
|
||||||
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({
|
|
||||||
|
|
||||||
});
|
|
14
package.json
14
package.json
@ -4,7 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"cf-dev": "next dev -p 8787",
|
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"postinstall": "fumadocs-mdx",
|
"postinstall": "fumadocs-mdx",
|
||||||
@ -31,7 +30,6 @@
|
|||||||
"@ai-sdk/fireworks": "^1.0.0",
|
"@ai-sdk/fireworks": "^1.0.0",
|
||||||
"@ai-sdk/google": "^2.0.0",
|
"@ai-sdk/google": "^2.0.0",
|
||||||
"@ai-sdk/openai": "^2.0.0",
|
"@ai-sdk/openai": "^2.0.0",
|
||||||
"@ai-sdk/react": "^2.0.22",
|
|
||||||
"@ai-sdk/replicate": "^1.0.0",
|
"@ai-sdk/replicate": "^1.0.0",
|
||||||
"@base-ui-components/react": "1.0.0-beta.0",
|
"@base-ui-components/react": "1.0.0-beta.0",
|
||||||
"@better-fetch/fetch": "^1.1.18",
|
"@better-fetch/fetch": "^1.1.18",
|
||||||
@ -76,7 +74,6 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.2",
|
"@radix-ui/react-toggle": "^1.1.2",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
|
||||||
"@react-email/components": "0.0.33",
|
"@react-email/components": "0.0.33",
|
||||||
"@react-email/render": "1.0.5",
|
"@react-email/render": "1.0.5",
|
||||||
"@stripe/stripe-js": "^5.6.0",
|
"@stripe/stripe-js": "^5.6.0",
|
||||||
@ -87,6 +84,7 @@
|
|||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@vercel/analytics": "^1.5.0",
|
"@vercel/analytics": "^1.5.0",
|
||||||
"@vercel/speed-insights": "^1.2.0",
|
"@vercel/speed-insights": "^1.2.0",
|
||||||
|
"@widgetbot/react-embed": "^1.9.0",
|
||||||
"ai": "^5.0.0",
|
"ai": "^5.0.0",
|
||||||
"better-auth": "^1.1.19",
|
"better-auth": "^1.1.19",
|
||||||
"canvas-confetti": "^1.9.3",
|
"canvas-confetti": "^1.9.3",
|
||||||
@ -112,8 +110,6 @@
|
|||||||
"next-intl": "^4.0.0",
|
"next-intl": "^4.0.0",
|
||||||
"next-safe-action": "^7.10.4",
|
"next-safe-action": "^7.10.4",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"pg": "^8.16.0",
|
|
||||||
"nuqs": "^2.5.1",
|
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"radix-ui": "^1.4.2",
|
"radix-ui": "^1.4.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@ -122,7 +118,6 @@
|
|||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-remove-scroll": "^2.6.3",
|
"react-remove-scroll": "^2.6.3",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"react-syntax-highlighter": "^15.6.3",
|
|
||||||
"react-tweet": "^3.2.2",
|
"react-tweet": "^3.2.2",
|
||||||
"react-use-measure": "^2.1.7",
|
"react-use-measure": "^2.1.7",
|
||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.1",
|
||||||
@ -130,7 +125,6 @@
|
|||||||
"s3mini": "^0.2.0",
|
"s3mini": "^0.2.0",
|
||||||
"shiki": "^2.4.2",
|
"shiki": "^2.4.2",
|
||||||
"sonner": "^2.0.0",
|
"sonner": "^2.0.0",
|
||||||
"streamdown": "^1.0.12",
|
|
||||||
"stripe": "^17.6.0",
|
"stripe": "^17.6.0",
|
||||||
"swiper": "^11.2.5",
|
"swiper": "^11.2.5",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
@ -138,14 +132,12 @@
|
|||||||
"tw-animate-css": "^1.2.4",
|
"tw-animate-css": "^1.2.4",
|
||||||
"use-intl": "^3.26.5",
|
"use-intl": "^3.26.5",
|
||||||
"use-media": "^1.5.0",
|
"use-media": "^1.5.0",
|
||||||
"use-stick-to-bottom": "^1.1.1",
|
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.0.17",
|
"zod": "^4.0.17",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
"@opennextjs/cloudflare": "^1.6.5",
|
|
||||||
"@tailwindcss/postcss": "^4.0.14",
|
"@tailwindcss/postcss": "^4.0.14",
|
||||||
"@tanstack/eslint-plugin-query": "^5.83.1",
|
"@tanstack/eslint-plugin-query": "^5.83.1",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
@ -153,14 +145,12 @@
|
|||||||
"@types/pg": "^8.11.11",
|
"@types/pg": "^8.11.11",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
|
||||||
"drizzle-kit": "^0.30.4",
|
"drizzle-kit": "^0.30.4",
|
||||||
"knip": "^5.61.2",
|
"knip": "^5.61.2",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"react-email": "3.0.7",
|
"react-email": "3.0.7",
|
||||||
"tailwindcss": "^4.0.14",
|
"tailwindcss": "^4.0.14",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3"
|
||||||
"wrangler": "^4.28.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
4926
pnpm-lock.yaml
generated
4926
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,2 +0,0 @@
|
|||||||
/_next/static/*
|
|
||||||
Cache-Control: public,max-age=31536000,immutable
|
|
129
public/sw.js
Normal file
129
public/sw.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
// 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')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
19
scripts/gateway.ts
Normal file
19
scripts/gateway.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { streamText } from 'ai';
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const result = streamText({
|
||||||
|
model: 'openai/gpt-4.1',
|
||||||
|
prompt: 'Invent a new holiday and describe its traditions.',
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const textPart of result.textStream) {
|
||||||
|
process.stdout.write(textPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log('Token usage:', await result.usage);
|
||||||
|
console.log('Finish reason:', await result.finishReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
@ -15,7 +15,6 @@ export const docs = defineDocs({
|
|||||||
schema: frontmatterSchema.extend({
|
schema: frontmatterSchema.extend({
|
||||||
preview: z.string().optional(),
|
preview: z.string().optional(),
|
||||||
index: z.boolean().default(false),
|
index: z.boolean().default(false),
|
||||||
premium: z.boolean().optional(),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
@ -86,7 +85,7 @@ export const category = defineCollections({
|
|||||||
/**
|
/**
|
||||||
* Blog posts
|
* Blog posts
|
||||||
*
|
*
|
||||||
* title is required, but description is optional in frontmatter
|
* dtitle is required, but description is optional in frontmatter
|
||||||
*/
|
*/
|
||||||
export const blog = defineCollections({
|
export const blog = defineCollections({
|
||||||
type: 'doc',
|
type: 'doc',
|
||||||
@ -95,7 +94,6 @@ export const blog = defineCollections({
|
|||||||
image: z.string(),
|
image: z.string(),
|
||||||
date: z.string().date(),
|
date: z.string().date(),
|
||||||
published: z.boolean().default(true),
|
published: z.boolean().default(true),
|
||||||
premium: z.boolean().optional(),
|
|
||||||
categories: z.array(z.string()),
|
categories: z.array(z.string()),
|
||||||
author: z.string(),
|
author: z.string(),
|
||||||
}),
|
}),
|
||||||
|
37
src/actions/check-web-content-analysis-credits.ts
Normal file
37
src/actions/check-web-content-analysis-credits.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { getWebContentAnalysisCost } from '@/ai/text/utils/web-content-analyzer-config';
|
||||||
|
import { getUserCredits, hasEnoughCredits } from '@/credits/credits';
|
||||||
|
import type { User } from '@/lib/auth-types';
|
||||||
|
import { userActionClient } from '@/lib/safe-action';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has enough credits for web content analysis
|
||||||
|
*/
|
||||||
|
export const checkWebContentAnalysisCreditsAction = userActionClient.action(
|
||||||
|
async ({ ctx }) => {
|
||||||
|
const currentUser = (ctx as { user: User }).user;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requiredCredits = getWebContentAnalysisCost();
|
||||||
|
const currentCredits = await getUserCredits(currentUser.id);
|
||||||
|
const hasCredits = await hasEnoughCredits({
|
||||||
|
userId: currentUser.id,
|
||||||
|
requiredCredits,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
hasEnoughCredits: hasCredits,
|
||||||
|
currentCredits,
|
||||||
|
requiredCredits,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('check web content analysis credits error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Something went wrong',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
@ -48,7 +48,7 @@ export const createCreditCheckoutSession = userActionClient
|
|||||||
...metadata,
|
...metadata,
|
||||||
type: 'credit_purchase',
|
type: 'credit_purchase',
|
||||||
packageId,
|
packageId,
|
||||||
credits: creditPackage.amount.toString(),
|
credits: creditPackage.credits.toString(),
|
||||||
userId: currentUser.id,
|
userId: currentUser.id,
|
||||||
userName: currentUser.name,
|
userName: currentUser.name,
|
||||||
};
|
};
|
||||||
|
@ -9,19 +9,8 @@ import { userActionClient } from '@/lib/safe-action';
|
|||||||
*/
|
*/
|
||||||
export const getCreditBalanceAction = userActionClient.action(
|
export const getCreditBalanceAction = userActionClient.action(
|
||||||
async ({ ctx }) => {
|
async ({ ctx }) => {
|
||||||
try {
|
const currentUser = (ctx as { user: User }).user;
|
||||||
const currentUser = (ctx as { user: User }).user;
|
const credits = await getUserCredits(currentUser.id);
|
||||||
const credits = await getUserCredits(currentUser.id);
|
return { success: true, credits };
|
||||||
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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -3,10 +3,11 @@
|
|||||||
import { getDb } from '@/db';
|
import { getDb } from '@/db';
|
||||||
import { creditTransaction } from '@/db/schema';
|
import { creditTransaction } from '@/db/schema';
|
||||||
import type { User } from '@/lib/auth-types';
|
import type { User } from '@/lib/auth-types';
|
||||||
import { CREDITS_EXPIRATION_DAYS } from '@/lib/constants';
|
|
||||||
import { userActionClient } from '@/lib/safe-action';
|
import { userActionClient } from '@/lib/safe-action';
|
||||||
import { addDays } from 'date-fns';
|
import { addDays } from 'date-fns';
|
||||||
import { and, eq, gt, gte, isNotNull, lte, sum } from 'drizzle-orm';
|
import { and, eq, gte, isNotNull, lte, sql, sum } from 'drizzle-orm';
|
||||||
|
|
||||||
|
const CREDITS_EXPIRATION_DAYS = 31;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get credit statistics for a user
|
* Get credit statistics for a user
|
||||||
@ -17,14 +18,12 @@ export const getCreditStatsAction = userActionClient.action(async ({ ctx }) => {
|
|||||||
const userId = currentUser.id;
|
const userId = currentUser.id;
|
||||||
|
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const now = new Date();
|
// Get credits expiring in the next CREDITS_EXPIRATION_DAYS days
|
||||||
// Get credits expiring in the next 30 days
|
const expirationDaysFromNow = addDays(new Date(), CREDITS_EXPIRATION_DAYS);
|
||||||
const expirationDaysFromNow = addDays(now, CREDITS_EXPIRATION_DAYS);
|
const expiringCredits = await db
|
||||||
|
|
||||||
// Get total credits expiring in the next 30 days
|
|
||||||
const expiringCreditsResult = await db
|
|
||||||
.select({
|
.select({
|
||||||
totalAmount: sum(creditTransaction.remainingAmount),
|
amount: sum(creditTransaction.remainingAmount),
|
||||||
|
earliestExpiration: sql<Date>`MIN(${creditTransaction.expirationDate})`,
|
||||||
})
|
})
|
||||||
.from(creditTransaction)
|
.from(creditTransaction)
|
||||||
.where(
|
.where(
|
||||||
@ -32,20 +31,18 @@ export const getCreditStatsAction = userActionClient.action(async ({ ctx }) => {
|
|||||||
eq(creditTransaction.userId, userId),
|
eq(creditTransaction.userId, userId),
|
||||||
isNotNull(creditTransaction.expirationDate),
|
isNotNull(creditTransaction.expirationDate),
|
||||||
isNotNull(creditTransaction.remainingAmount),
|
isNotNull(creditTransaction.remainingAmount),
|
||||||
gt(creditTransaction.remainingAmount, 0),
|
gte(creditTransaction.remainingAmount, 1),
|
||||||
lte(creditTransaction.expirationDate, expirationDaysFromNow),
|
lte(creditTransaction.expirationDate, expirationDaysFromNow),
|
||||||
gte(creditTransaction.expirationDate, now)
|
gte(creditTransaction.expirationDate, new Date())
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalExpiringCredits =
|
|
||||||
Number(expiringCreditsResult[0]?.totalAmount) || 0;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
expiringCredits: {
|
expiringCredits: {
|
||||||
amount: totalExpiringCredits,
|
amount: Number(expiringCredits[0]?.amount) || 0,
|
||||||
|
earliestExpiration: expiringCredits[0]?.earliestExpiration || null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -44,30 +44,17 @@ export const getCreditTransactionsAction = userActionClient
|
|||||||
const { pageIndex, pageSize, search, sorting } = parsedInput;
|
const { pageIndex, pageSize, search, sorting } = parsedInput;
|
||||||
const currentUser = (ctx as { user: User }).user;
|
const currentUser = (ctx as { user: User }).user;
|
||||||
|
|
||||||
// Search logic: text fields use ilike, and if search is a number, also search amount fields
|
// search by type, amount, paymentId, description, and restrict to current user
|
||||||
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
|
const where = search
|
||||||
? and(
|
? and(
|
||||||
eq(creditTransaction.userId, currentUser.id),
|
eq(creditTransaction.userId, currentUser.id),
|
||||||
or(...searchConditions)
|
or(
|
||||||
|
ilike(creditTransaction.type, `%${search}%`),
|
||||||
|
ilike(creditTransaction.amount, `%${search}%`),
|
||||||
|
ilike(creditTransaction.remainingAmount, `%${search}%`),
|
||||||
|
ilike(creditTransaction.paymentId, `%${search}%`),
|
||||||
|
ilike(creditTransaction.description, `%${search}%`)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
: eq(creditTransaction.userId, currentUser.id);
|
: eq(creditTransaction.userId, currentUser.id);
|
||||||
|
|
||||||
|
@ -1,181 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -76,9 +76,9 @@ export function ImagePlayground({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-background py-8 px-4 sm:px-6 lg:px-8">
|
<div className="rounded-lg bg-background py-8 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* header */}
|
{/* header */}
|
||||||
{/* <ImageGeneratorHeader /> */}
|
<ImageGeneratorHeader />
|
||||||
|
|
||||||
{/* input prompt */}
|
{/* input prompt */}
|
||||||
<PromptInput
|
<PromptInput
|
||||||
|
57
src/ai/text/components/consume-credit-card.tsx
Normal file
57
src/ai/text/components/consume-credit-card.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CreditsBalanceButton } from '@/components/layout/credits-balance-button';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useConsumeCredits, useCreditBalance } from '@/hooks/use-credits';
|
||||||
|
import { CoinsIcon } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const CONSUME_CREDITS = 50;
|
||||||
|
|
||||||
|
export function ConsumeCreditCard() {
|
||||||
|
const { data: balance = 0, isLoading: isLoadingBalance } = useCreditBalance();
|
||||||
|
const consumeCreditsMutation = useConsumeCredits();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const hasEnoughCredits = (amount: number) => balance >= amount;
|
||||||
|
|
||||||
|
const handleConsume = async () => {
|
||||||
|
if (!hasEnoughCredits(CONSUME_CREDITS)) {
|
||||||
|
toast.error('Insufficient credits, please buy more credits.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await consumeCreditsMutation.mutateAsync({
|
||||||
|
amount: CONSUME_CREDITS,
|
||||||
|
description: `AI Text Credit Consumption (${CONSUME_CREDITS} credits)`,
|
||||||
|
});
|
||||||
|
toast.success(`${CONSUME_CREDITS} credits have been consumed.`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to consume credits, please try again later.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-8 p-4 border rounded-lg">
|
||||||
|
<div className="w-full flex flex-row items-center justify-end">
|
||||||
|
<CreditsBalanceButton />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConsume}
|
||||||
|
disabled={
|
||||||
|
loading || isLoadingBalance || consumeCreditsMutation.isPending
|
||||||
|
}
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
>
|
||||||
|
<CoinsIcon className="size-4" />
|
||||||
|
<span>Consume {CONSUME_CREDITS} credits</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -34,6 +34,7 @@ interface ErrorDisplayProps {
|
|||||||
const errorIcons = {
|
const errorIcons = {
|
||||||
[ErrorType.VALIDATION]: AlertCircleIcon,
|
[ErrorType.VALIDATION]: AlertCircleIcon,
|
||||||
[ErrorType.NETWORK]: WifiOffIcon,
|
[ErrorType.NETWORK]: WifiOffIcon,
|
||||||
|
[ErrorType.CREDITS]: CreditCardIcon,
|
||||||
[ErrorType.SCRAPING]: ServerIcon,
|
[ErrorType.SCRAPING]: ServerIcon,
|
||||||
[ErrorType.ANALYSIS]: HelpCircleIcon,
|
[ErrorType.ANALYSIS]: HelpCircleIcon,
|
||||||
[ErrorType.TIMEOUT]: ClockIcon,
|
[ErrorType.TIMEOUT]: ClockIcon,
|
||||||
@ -83,6 +84,7 @@ const severityColors = {
|
|||||||
const errorTitles = {
|
const errorTitles = {
|
||||||
[ErrorType.VALIDATION]: 'Invalid Input',
|
[ErrorType.VALIDATION]: 'Invalid Input',
|
||||||
[ErrorType.NETWORK]: 'Connection Error',
|
[ErrorType.NETWORK]: 'Connection Error',
|
||||||
|
[ErrorType.CREDITS]: 'Insufficient Credits',
|
||||||
[ErrorType.SCRAPING]: 'Unable to Access Website',
|
[ErrorType.SCRAPING]: 'Unable to Access Website',
|
||||||
[ErrorType.ANALYSIS]: 'Analysis Failed',
|
[ErrorType.ANALYSIS]: 'Analysis Failed',
|
||||||
[ErrorType.TIMEOUT]: 'Request Timed Out',
|
[ErrorType.TIMEOUT]: 'Request Timed Out',
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
export { AnalysisResults } from './analysis-results';
|
export { AnalysisResults } from './analysis-results';
|
||||||
|
export { ConsumeCreditCard } from './consume-credit-card';
|
||||||
export { LoadingStates } from './loading-states';
|
export { LoadingStates } from './loading-states';
|
||||||
export { UrlInputForm } from './url-input-form';
|
export { UrlInputForm } from './url-input-form';
|
||||||
export { WebContentAnalyzer } from './web-content-analyzer';
|
export { WebContentAnalyzer } from './web-content-analyzer';
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { checkWebContentAnalysisCreditsAction } from '@/actions/check-web-content-analysis-credits';
|
||||||
import type { UrlInputFormProps } from '@/ai/text/utils/web-content-analyzer';
|
import type { UrlInputFormProps } from '@/ai/text/utils/web-content-analyzer';
|
||||||
import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-config';
|
import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-config';
|
||||||
|
import { LoginWrapper } from '@/components/auth/login-wrapper';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@ -18,10 +20,21 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useLocalePathname } from '@/i18n/navigation';
|
||||||
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { LinkIcon, Loader2Icon, SparklesIcon } from 'lucide-react';
|
import {
|
||||||
|
AlertCircleIcon,
|
||||||
|
CoinsIcon,
|
||||||
|
LinkIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
LogInIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useDebounce } from '../utils/performance';
|
import { useDebounce } from '../utils/performance';
|
||||||
|
|
||||||
@ -39,9 +52,19 @@ export function UrlInputForm({
|
|||||||
modelProvider,
|
modelProvider,
|
||||||
setModelProvider,
|
setModelProvider,
|
||||||
}: UrlInputFormProps) {
|
}: UrlInputFormProps) {
|
||||||
|
const [creditInfo, setCreditInfo] = useState<{
|
||||||
|
hasEnoughCredits: boolean;
|
||||||
|
currentCredits: number;
|
||||||
|
requiredCredits: number;
|
||||||
|
} | null>(null);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
// Prevent hydration mismatch by only rendering content after mount
|
// Get authentication status and current path for callback
|
||||||
|
const { data: session, isPending: isAuthLoading } = authClient.useSession();
|
||||||
|
const isAuthenticated = !!session?.user;
|
||||||
|
const currentPath = useLocalePathname();
|
||||||
|
|
||||||
|
// Prevent hydration mismatch by only rendering auth-dependent content after mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
@ -61,6 +84,42 @@ export function UrlInputForm({
|
|||||||
webContentAnalyzerConfig.performance.urlInputDebounceMs
|
webContentAnalyzerConfig.performance.urlInputDebounceMs
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { execute: checkCredits, isExecuting: isCheckingCredits } = useAction(
|
||||||
|
checkWebContentAnalysisCreditsAction,
|
||||||
|
{
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.data?.success) {
|
||||||
|
setCreditInfo({
|
||||||
|
hasEnoughCredits: result.data.hasEnoughCredits ?? false,
|
||||||
|
currentCredits: result.data.currentCredits ?? 0,
|
||||||
|
requiredCredits: result.data.requiredCredits ?? 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Only show error toast if it's not an auth error
|
||||||
|
if (result.data?.error !== 'Unauthorized') {
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.error(result.data?.error || 'Failed to check credits');
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Credit check error:', error);
|
||||||
|
// Only show error toast for non-auth errors
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.error('Failed to check credits');
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check credits only when user is authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && !isAuthLoading) {
|
||||||
|
checkCredits();
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isAuthLoading, checkCredits]);
|
||||||
|
|
||||||
// Debounced URL validation effect
|
// Debounced URL validation effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debouncedUrl && debouncedUrl !== urlValue) {
|
if (debouncedUrl && debouncedUrl !== urlValue) {
|
||||||
@ -70,12 +129,23 @@ export function UrlInputForm({
|
|||||||
}, [debouncedUrl, urlValue, form]);
|
}, [debouncedUrl, urlValue, form]);
|
||||||
|
|
||||||
const handleSubmit = (data: UrlFormData) => {
|
const handleSubmit = (data: UrlFormData) => {
|
||||||
|
// For authenticated users, check credits before submitting
|
||||||
|
if (creditInfo && !creditInfo.hasEnoughCredits) {
|
||||||
|
// Defer toast to avoid flushSync during render
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.error(
|
||||||
|
`Insufficient credits. You need ${creditInfo.requiredCredits} credits but only have ${creditInfo.currentCredits}.`
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
onSubmit(data.url ?? '', modelProvider);
|
onSubmit(data.url ?? '', modelProvider);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormSubmit = form.handleSubmit(handleSubmit);
|
const handleFormSubmit = form.handleSubmit(handleSubmit);
|
||||||
|
|
||||||
const isFormDisabled = isLoading || disabled;
|
const isInsufficientCredits = creditInfo && !creditInfo.hasEnoughCredits;
|
||||||
|
const isFormDisabled = isLoading || disabled || !!isInsufficientCredits;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -91,10 +161,10 @@ export function UrlInputForm({
|
|||||||
<SelectValue placeholder="Select model" />
|
<SelectValue placeholder="Select model" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="openrouter">OpenRouter</SelectItem>
|
|
||||||
<SelectItem value="openai">OpenAI GPT-4o</SelectItem>
|
<SelectItem value="openai">OpenAI GPT-4o</SelectItem>
|
||||||
<SelectItem value="gemini">Google Gemini</SelectItem>
|
<SelectItem value="gemini">Google Gemini</SelectItem>
|
||||||
<SelectItem value="deepseek">DeepSeek R1</SelectItem>
|
<SelectItem value="deepseek">DeepSeek</SelectItem>
|
||||||
|
<SelectItem value="openrouter">OpenRouter</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@ -124,20 +194,67 @@ export function UrlInputForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Credit Information - Only show for authenticated users */}
|
||||||
|
{isAuthenticated && creditInfo && (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CoinsIcon className="size-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Cost: {creditInfo.requiredCredits} credits
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
creditInfo.hasEnoughCredits
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: 'text-red-600 dark:text-red-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Balance: {creditInfo.currentCredits}
|
||||||
|
</span>
|
||||||
|
{!creditInfo.hasEnoughCredits && (
|
||||||
|
<AlertCircleIcon className="size-4 text-red-600 dark:text-red-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Insufficient Credits Warning */}
|
||||||
|
{isAuthenticated && isInsufficientCredits && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-400">
|
||||||
|
<AlertCircleIcon className="size-4 flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
Insufficient credits. You need {creditInfo.requiredCredits}{' '}
|
||||||
|
credits but only have {creditInfo.currentCredits}.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!mounted ? (
|
{!mounted ? (
|
||||||
// Show loading state during hydration to prevent mismatch
|
// Show loading state during hydration to prevent mismatch
|
||||||
<Button type="button" disabled className="w-full" size="lg">
|
<Button type="button" disabled className="w-full" size="lg">
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
<span>Loading...</span>
|
<span>Loading...</span>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : isAuthenticated ? (
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isFormDisabled || !urlValue?.trim()}
|
disabled={isFormDisabled || !urlValue?.trim()}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isAuthLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
<span>Loading...</span>
|
||||||
|
</>
|
||||||
|
) : isCheckingCredits ? (
|
||||||
|
<>
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
<span>Checking Credits...</span>
|
||||||
|
</>
|
||||||
|
) : isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
<span>Analyzing...</span>
|
<span>Analyzing...</span>
|
||||||
@ -145,10 +262,24 @@ export function UrlInputForm({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<SparklesIcon className="size-4" />
|
<SparklesIcon className="size-4" />
|
||||||
<span>Analyze Website</span>
|
<span>
|
||||||
|
Analyze Website
|
||||||
|
{creditInfo && ` (${creditInfo.requiredCredits} credits)`}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<LoginWrapper mode="modal" asChild callbackUrl={currentPath}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<LogInIcon className="size-4" />
|
||||||
|
<span>Sign In First</span>
|
||||||
|
</Button>
|
||||||
|
</LoginWrapper>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
@ -194,8 +194,7 @@ export function WebContentAnalyzer({ className }: WebContentAnalyzerProps) {
|
|||||||
const [state, dispatch] = useReducer(analysisReducer, initialState);
|
const [state, dispatch] = useReducer(analysisReducer, initialState);
|
||||||
|
|
||||||
// Model provider state
|
// Model provider state
|
||||||
const [modelProvider, setModelProvider] =
|
const [modelProvider, setModelProvider] = useState<ModelProvider>('openai');
|
||||||
useState<ModelProvider>('openrouter');
|
|
||||||
|
|
||||||
// Enhanced error state
|
// Enhanced error state
|
||||||
const [analyzedError, setAnalyzedError] =
|
const [analyzedError, setAnalyzedError] =
|
||||||
@ -233,6 +232,16 @@ export function WebContentAnalyzer({ className }: WebContentAnalyzerProps) {
|
|||||||
errorType = ErrorType.VALIDATION;
|
errorType = ErrorType.VALIDATION;
|
||||||
retryable = false;
|
retryable = false;
|
||||||
break;
|
break;
|
||||||
|
case 401:
|
||||||
|
errorType = ErrorType.AUTHENTICATION;
|
||||||
|
severity = ErrorSeverity.HIGH;
|
||||||
|
retryable = false;
|
||||||
|
break;
|
||||||
|
case 402:
|
||||||
|
errorType = ErrorType.CREDITS;
|
||||||
|
severity = ErrorSeverity.HIGH;
|
||||||
|
retryable = false;
|
||||||
|
break;
|
||||||
case 408:
|
case 408:
|
||||||
errorType = ErrorType.TIMEOUT;
|
errorType = ErrorType.TIMEOUT;
|
||||||
break;
|
break;
|
||||||
|
@ -9,6 +9,7 @@ import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-c
|
|||||||
export enum ErrorType {
|
export enum ErrorType {
|
||||||
VALIDATION = 'validation',
|
VALIDATION = 'validation',
|
||||||
NETWORK = 'network',
|
NETWORK = 'network',
|
||||||
|
CREDITS = 'credits',
|
||||||
SCRAPING = 'scraping',
|
SCRAPING = 'scraping',
|
||||||
ANALYSIS = 'analysis',
|
ANALYSIS = 'analysis',
|
||||||
TIMEOUT = 'timeout',
|
TIMEOUT = 'timeout',
|
||||||
@ -95,6 +96,22 @@ export function classifyError(error: unknown): WebContentAnalyzerError {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Credit errors
|
||||||
|
if (
|
||||||
|
message.includes('credit') ||
|
||||||
|
message.includes('insufficient') ||
|
||||||
|
message.includes('balance')
|
||||||
|
) {
|
||||||
|
return new WebContentAnalyzerError(
|
||||||
|
ErrorType.CREDITS,
|
||||||
|
error.message,
|
||||||
|
'Insufficient credits to perform analysis. Please purchase more credits.',
|
||||||
|
ErrorSeverity.HIGH,
|
||||||
|
false,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Scraping errors
|
// Scraping errors
|
||||||
if (
|
if (
|
||||||
message.includes('scrape') ||
|
message.includes('scrape') ||
|
||||||
@ -261,6 +278,16 @@ export function getRecoveryActions(error: WebContentAnalyzerError): Array<{
|
|||||||
{ label: 'Try Simpler URL', action: 'simplify_url' },
|
{ label: 'Try Simpler URL', action: 'simplify_url' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
case ErrorType.CREDITS:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Purchase Credits',
|
||||||
|
action: 'purchase_credits',
|
||||||
|
primary: true,
|
||||||
|
},
|
||||||
|
{ label: 'Check Balance', action: 'check_balance' },
|
||||||
|
];
|
||||||
|
|
||||||
case ErrorType.SCRAPING:
|
case ErrorType.SCRAPING:
|
||||||
return [
|
return [
|
||||||
{ label: 'Try Again', action: 'retry', primary: true },
|
{ label: 'Try Again', action: 'retry', primary: true },
|
||||||
|
@ -6,6 +6,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const webContentAnalyzerConfig = {
|
export const webContentAnalyzerConfig = {
|
||||||
|
/**
|
||||||
|
* Credit cost for performing a web content analysis
|
||||||
|
*/
|
||||||
|
creditsCost: 100,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum content length for AI analysis (in characters)
|
* Maximum content length for AI analysis (in characters)
|
||||||
* Optimized to prevent token limit issues while maintaining quality
|
* Optimized to prevent token limit issues while maintaining quality
|
||||||
@ -113,15 +118,21 @@ export const webContentAnalyzerConfig = {
|
|||||||
maxTokens: 2000,
|
maxTokens: 2000,
|
||||||
},
|
},
|
||||||
openrouter: {
|
openrouter: {
|
||||||
// model: 'openrouter/horizon-beta',
|
model: 'openrouter/horizon-beta',
|
||||||
// model: 'x-ai/grok-3-beta',
|
// model: 'x-ai/grok-3-beta',
|
||||||
// model: 'openai/gpt-4o-mini',
|
// model: 'openai/gpt-4o-mini',
|
||||||
model: 'deepseek/deepseek-r1:free',
|
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
maxTokens: 2000,
|
maxTokens: 2000,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the credit cost for web content analysis
|
||||||
|
*/
|
||||||
|
export function getWebContentAnalysisCost(): number {
|
||||||
|
return webContentAnalyzerConfig.creditsCost;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates if the Firecrawl API key is configured
|
* Validates if the Firecrawl API key is configured
|
||||||
*/
|
*/
|
||||||
@ -140,6 +151,8 @@ export function validateFirecrawlConfig(): boolean {
|
|||||||
*/
|
*/
|
||||||
export function validateWebContentAnalyzerConfig(): boolean {
|
export function validateWebContentAnalyzerConfig(): boolean {
|
||||||
return (
|
return (
|
||||||
|
typeof webContentAnalyzerConfig.creditsCost === 'number' &&
|
||||||
|
webContentAnalyzerConfig.creditsCost > 0 &&
|
||||||
typeof webContentAnalyzerConfig.maxContentLength === 'number' &&
|
typeof webContentAnalyzerConfig.maxContentLength === 'number' &&
|
||||||
webContentAnalyzerConfig.maxContentLength > 0 &&
|
webContentAnalyzerConfig.maxContentLength > 0 &&
|
||||||
typeof webContentAnalyzerConfig.timeoutMillis === 'number' &&
|
typeof webContentAnalyzerConfig.timeoutMillis === 'number' &&
|
||||||
|
@ -67,7 +67,7 @@ export interface AnalysisState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Component Props Interfaces
|
// Component Props Interfaces
|
||||||
export type ModelProvider = 'openai' | 'gemini' | 'deepseek' | 'openrouter';
|
export type ModelProvider = 'openai' | 'gemini' | 'deepseek';
|
||||||
|
|
||||||
export interface WebContentAnalyzerProps {
|
export interface WebContentAnalyzerProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import Container from '@/components/layout/container';
|
import Container from '@/components/layout/container';
|
||||||
|
import { BlurFadeDemo } from '@/components/magicui/example/blur-fade-example';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Button, buttonVariants } from '@/components/ui/button';
|
import { Button, buttonVariants } from '@/components/ui/button';
|
||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
@ -97,6 +98,9 @@ export default async function AboutPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* image section */}
|
||||||
|
{/* <BlurFadeDemo /> */}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
import Container from '@/components/layout/container';
|
|
||||||
import { ConsumeCreditsCard } from '@/components/test/consume-credits-card';
|
|
||||||
|
|
||||||
export default async function TestPage() {
|
|
||||||
return (
|
|
||||||
<Container className="py-16 px-4">
|
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
|
||||||
{/* credits test */}
|
|
||||||
<ConsumeCreditsCard />
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
@ -42,6 +42,10 @@ export default async function AIAudioPage() {
|
|||||||
<div className="size-32 text-muted-foreground" />
|
<div className="size-32 text-muted-foreground" />
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl text-foreground">{t('content')}</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,46 +0,0 @@
|
|||||||
import ChatBot from '@/ai/chat/components/ChatBot';
|
|
||||||
import { constructMetadata } from '@/lib/metadata';
|
|
||||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
|
||||||
import { ZapIcon } from 'lucide-react';
|
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import type { Locale } from 'next-intl';
|
|
||||||
import { getTranslations } from 'next-intl/server';
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ locale: Locale }>;
|
|
||||||
}): Promise<Metadata | undefined> {
|
|
||||||
const { locale } = await params;
|
|
||||||
const t = await getTranslations({ locale, namespace: 'Metadata' });
|
|
||||||
const pt = await getTranslations({ locale, namespace: 'AIChatPage' });
|
|
||||||
|
|
||||||
return constructMetadata({
|
|
||||||
title: pt('title') + ' | ' + t('title'),
|
|
||||||
description: pt('description'),
|
|
||||||
canonicalUrl: getUrlWithLocale('/ai/chat', locale),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AIChatPage() {
|
|
||||||
const t = await getTranslations('AIChatPage');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-muted/50 rounded-lg">
|
|
||||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
|
||||||
{/* Header Section */}
|
|
||||||
<div className="text-center space-y-6 mb-12">
|
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium">
|
|
||||||
<ZapIcon className="size-4" />
|
|
||||||
{t('title')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chat Bot */}
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<ChatBot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -2,7 +2,6 @@ import { ImagePlayground } from '@/ai/image/components/ImagePlayground';
|
|||||||
import { getRandomSuggestions } from '@/ai/image/lib/suggestions';
|
import { getRandomSuggestions } from '@/ai/image/lib/suggestions';
|
||||||
import { constructMetadata } from '@/lib/metadata';
|
import { constructMetadata } from '@/lib/metadata';
|
||||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||||
import { ImageIcon } from 'lucide-react';
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import type { Locale } from 'next-intl';
|
import type { Locale } from 'next-intl';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
@ -27,21 +26,8 @@ export default async function AIImagePage() {
|
|||||||
const t = await getTranslations('AIImagePage');
|
const t = await getTranslations('AIImagePage');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/50 rounded-lg">
|
<div className="mx-auto space-y-8">
|
||||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
<ImagePlayground suggestions={getRandomSuggestions(5)} />
|
||||||
{/* Header Section */}
|
|
||||||
<div className="text-center space-y-6 mb-12">
|
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium">
|
|
||||||
<ImageIcon className="size-4" />
|
|
||||||
{t('title')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Image Playground Component */}
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<ImagePlayground suggestions={getRandomSuggestions(5)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,7 @@ export default async function AITextPage() {
|
|||||||
const t = await getTranslations('AITextPage');
|
const t = await getTranslations('AITextPage');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/50 rounded-lg">
|
<div className="min-h-screen bg-background rounded-lg">
|
||||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div className="text-center space-y-6 mb-12">
|
<div className="text-center space-y-6 mb-12">
|
||||||
|
@ -42,6 +42,10 @@ export default async function AIVideoPage() {
|
|||||||
<div className="size-32 text-muted-foreground" />
|
<div className="size-32 text-muted-foreground" />
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl text-foreground">{t('content')}</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
16
src/app/[locale]/(marketing)/blocks/[category]/layout.tsx
Normal file
16
src/app/[locale]/(marketing)/blocks/[category]/layout.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { categories } from '@/components/tailark/blocks';
|
||||||
|
import BlocksNav from '@/components/tailark/blocks-nav';
|
||||||
|
import type { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The locale inconsistency issue has been fixed in the BlocksNav component
|
||||||
|
*/
|
||||||
|
export default function BlockCategoryLayout({ children }: PropsWithChildren) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BlocksNav categories={categories} />
|
||||||
|
|
||||||
|
<main>{children}</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
54
src/app/[locale]/(marketing)/blocks/[category]/page.tsx
Normal file
54
src/app/[locale]/(marketing)/blocks/[category]/page.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import BlockPreview from '@/components/tailark/block-preview';
|
||||||
|
import { blocks, categories } from '@/components/tailark/blocks';
|
||||||
|
import { constructMetadata } from '@/lib/metadata';
|
||||||
|
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import type { Locale } from 'next-intl';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
return categories.map((category) => ({
|
||||||
|
category: category,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: Locale; category: string }>;
|
||||||
|
}): Promise<Metadata | undefined> {
|
||||||
|
const { locale, category } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: 'Metadata' });
|
||||||
|
return constructMetadata({
|
||||||
|
title: category + ' | ' + t('title'),
|
||||||
|
description: t('description'),
|
||||||
|
canonicalUrl: getUrlWithLocale(`/blocks/${category}`, locale),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BlockCategoryPageProps {
|
||||||
|
params: Promise<{ category: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlockCategoryPage({
|
||||||
|
params,
|
||||||
|
}: BlockCategoryPageProps) {
|
||||||
|
const { category } = await params;
|
||||||
|
const categoryBlocks = blocks.filter((b) => b.category === category);
|
||||||
|
|
||||||
|
if (categoryBlocks.length === 0) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{categoryBlocks.map((block, index) => (
|
||||||
|
<BlockPreview {...block} key={index} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -2,14 +2,10 @@ import AllPostsButton from '@/components/blog/all-posts-button';
|
|||||||
import BlogGrid from '@/components/blog/blog-grid';
|
import BlogGrid from '@/components/blog/blog-grid';
|
||||||
import { getMDXComponents } from '@/components/docs/mdx-components';
|
import { getMDXComponents } from '@/components/docs/mdx-components';
|
||||||
import { NewsletterCard } from '@/components/newsletter/newsletter-card';
|
import { NewsletterCard } from '@/components/newsletter/newsletter-card';
|
||||||
import { PremiumBadge } from '@/components/premium/premium-badge';
|
|
||||||
import { PremiumGuard } from '@/components/premium/premium-guard';
|
|
||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
import { LocaleLink } from '@/i18n/navigation';
|
import { LocaleLink } from '@/i18n/navigation';
|
||||||
import { formatDate } from '@/lib/formatter';
|
import { formatDate } from '@/lib/formatter';
|
||||||
import { constructMetadata } from '@/lib/metadata';
|
import { constructMetadata } from '@/lib/metadata';
|
||||||
import { checkPremiumAccess } from '@/lib/premium-access';
|
|
||||||
import { getSession } from '@/lib/server';
|
|
||||||
import {
|
import {
|
||||||
type BlogType,
|
type BlogType,
|
||||||
authorSource,
|
authorSource,
|
||||||
@ -17,7 +13,6 @@ import {
|
|||||||
categorySource,
|
categorySource,
|
||||||
} from '@/lib/source';
|
} from '@/lib/source';
|
||||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||||
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
|
|
||||||
import { CalendarIcon, FileTextIcon } from 'lucide-react';
|
import { CalendarIcon, FileTextIcon } from 'lucide-react';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import type { Locale } from 'next-intl';
|
import type { Locale } from 'next-intl';
|
||||||
@ -26,6 +21,7 @@ import Image from 'next/image';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
import '@/styles/mdx.css';
|
import '@/styles/mdx.css';
|
||||||
|
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get related posts, random pick from all posts with same locale, different slug,
|
* get related posts, random pick from all posts with same locale, different slug,
|
||||||
@ -87,8 +83,7 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { date, title, description, image, author, categories, premium } =
|
const { date, title, description, image, author, categories } = post.data;
|
||||||
post.data;
|
|
||||||
const publishDate = formatDate(new Date(date));
|
const publishDate = formatDate(new Date(date));
|
||||||
|
|
||||||
const blogAuthor = authorSource.getPage([author], locale);
|
const blogAuthor = authorSource.getPage([author], locale);
|
||||||
@ -96,13 +91,6 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
|||||||
.getPages(locale)
|
.getPages(locale)
|
||||||
.filter((category) => categories.includes(category.slugs[0] ?? ''));
|
.filter((category) => categories.includes(category.slugs[0] ?? ''));
|
||||||
|
|
||||||
// Check premium access for premium posts
|
|
||||||
const session = await getSession();
|
|
||||||
const hasPremiumAccess =
|
|
||||||
premium && session?.user?.id
|
|
||||||
? await checkPremiumAccess(session.user.id)
|
|
||||||
: !premium; // Non-premium posts are always accessible
|
|
||||||
|
|
||||||
const MDX = post.data.body;
|
const MDX = post.data.body;
|
||||||
|
|
||||||
// getTranslations may cause error DYNAMIC_SERVER_USAGE, so we set dynamic to force-static
|
// getTranslations may cause error DYNAMIC_SERVER_USAGE, so we set dynamic to force-static
|
||||||
@ -133,7 +121,7 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* blog post date and premium badge */}
|
{/* blog post date */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CalendarIcon className="size-4 text-muted-foreground" />
|
<CalendarIcon className="size-4 text-muted-foreground" />
|
||||||
@ -141,8 +129,6 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
|||||||
{publishDate}
|
{publishDate}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{premium && <PremiumBadge size="sm" />}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* blog post title */}
|
{/* blog post title */}
|
||||||
@ -155,14 +141,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
|||||||
{/* blog post content */}
|
{/* blog post content */}
|
||||||
{/* in order to make the mdx.css work, we need to add the className prose to the div */}
|
{/* in order to make the mdx.css work, we need to add the className prose to the div */}
|
||||||
{/* https://github.com/tailwindlabs/tailwindcss-typography */}
|
{/* https://github.com/tailwindlabs/tailwindcss-typography */}
|
||||||
<div className="mt-8">
|
<div className="mt-8 max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg">
|
||||||
<PremiumGuard
|
<MDX components={getMDXComponents()} />
|
||||||
isPremium={!!premium}
|
|
||||||
canAccess={hasPremiumAccess}
|
|
||||||
className="max-w-none"
|
|
||||||
>
|
|
||||||
<MDX components={getMDXComponents()} />
|
|
||||||
</PremiumGuard>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-start my-16">
|
<div className="flex items-center justify-start my-16">
|
||||||
@ -232,8 +212,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
|||||||
{relatedPosts && relatedPosts.length > 0 && (
|
{relatedPosts && relatedPosts.length > 0 && (
|
||||||
<div className="flex flex-col gap-8 mt-8">
|
<div className="flex flex-col gap-8 mt-8">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileTextIcon className="size-4 text-primary" />
|
<FileTextIcon className="size-4 text-muted-foreground" />
|
||||||
<h2 className="text-lg tracking-wider font-semibold text-primary">
|
<h2 className="text-lg tracking-wider font-semibold text-gradient_indigo-purple">
|
||||||
{t('morePosts')}
|
{t('morePosts')}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
5
src/app/[locale]/(protected)/admin/users/loading.tsx
Normal file
5
src/app/[locale]/(protected)/admin/users/loading.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Loader2Icon } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||||
|
}
|
5
src/app/[locale]/(protected)/loading.tsx
Normal file
5
src/app/[locale]/(protected)/loading.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Loader2Icon } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import { Loader2Icon } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import { Loader2Icon } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import { Loader2Icon } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import { Loader2Icon } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import { Loader2Icon } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||||
|
}
|
@ -1,7 +1,5 @@
|
|||||||
import * as Preview from '@/components/docs';
|
import * as Preview from '@/components/docs';
|
||||||
import { getMDXComponents } from '@/components/docs/mdx-components';
|
import { getMDXComponents } from '@/components/docs/mdx-components';
|
||||||
import { PremiumBadge } from '@/components/premium/premium-badge';
|
|
||||||
import { PremiumGuard } from '@/components/premium/premium-guard';
|
|
||||||
import {
|
import {
|
||||||
HoverCard,
|
HoverCard,
|
||||||
HoverCardContent,
|
HoverCardContent,
|
||||||
@ -9,8 +7,6 @@ import {
|
|||||||
} from '@/components/ui/hover-card';
|
} from '@/components/ui/hover-card';
|
||||||
import { LOCALES } from '@/i18n/routing';
|
import { LOCALES } from '@/i18n/routing';
|
||||||
import { constructMetadata } from '@/lib/metadata';
|
import { constructMetadata } from '@/lib/metadata';
|
||||||
import { checkPremiumAccess } from '@/lib/premium-access';
|
|
||||||
import { getSession } from '@/lib/server';
|
|
||||||
import { source } from '@/lib/source';
|
import { source } from '@/lib/source';
|
||||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||||
import Link from 'fumadocs-core/link';
|
import Link from 'fumadocs-core/link';
|
||||||
@ -90,14 +86,6 @@ export default async function DocPage({ params }: DocPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const preview = page.data.preview;
|
const preview = page.data.preview;
|
||||||
const { premium } = page.data;
|
|
||||||
|
|
||||||
// Check premium access for premium docs
|
|
||||||
const session = await getSession();
|
|
||||||
const hasPremiumAccess =
|
|
||||||
premium && session?.user?.id
|
|
||||||
? await checkPremiumAccess(session.user.id)
|
|
||||||
: !premium; // Non-premium docs are always accessible
|
|
||||||
|
|
||||||
const MDX = page.data.body;
|
const MDX = page.data.body;
|
||||||
|
|
||||||
@ -110,54 +98,44 @@ export default async function DocPage({ params }: DocPageProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DocsTitle>{page.data.title}</DocsTitle>
|
<DocsTitle>{page.data.title}</DocsTitle>
|
||||||
{premium && <PremiumBadge size="sm" className="mt-2" />}
|
|
||||||
<DocsDescription>{page.data.description}</DocsDescription>
|
<DocsDescription>{page.data.description}</DocsDescription>
|
||||||
<DocsBody>
|
<DocsBody>
|
||||||
{/* Preview Rendered Component */}
|
{/* Preview Rendered Component */}
|
||||||
{preview ? <PreviewRenderer preview={preview} /> : null}
|
{preview ? <PreviewRenderer preview={preview} /> : null}
|
||||||
|
|
||||||
{/* MDX Content */}
|
{/* MDX Content */}
|
||||||
<PremiumGuard
|
<MDX
|
||||||
isPremium={!!premium}
|
components={getMDXComponents({
|
||||||
canAccess={hasPremiumAccess}
|
a: ({ href, ...props }: { href?: string; [key: string]: any }) => {
|
||||||
className="max-w-none"
|
const found = source.getPageByHref(href ?? '', {
|
||||||
>
|
dir: page.file.dirname,
|
||||||
<MDX
|
});
|
||||||
components={getMDXComponents({
|
|
||||||
a: ({
|
|
||||||
href,
|
|
||||||
...props
|
|
||||||
}: { href?: string; [key: string]: any }) => {
|
|
||||||
const found = source.getPageByHref(href ?? '', {
|
|
||||||
dir: page.file.dirname,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!found) return <Link href={href} {...props} />;
|
if (!found) return <Link href={href} {...props} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCard>
|
<HoverCard>
|
||||||
<HoverCardTrigger asChild>
|
<HoverCardTrigger asChild>
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
found.hash
|
found.hash
|
||||||
? `${found.page.url}#${found.hash}`
|
? `${found.page.url}#${found.hash}`
|
||||||
: found.page.url
|
: found.page.url
|
||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent className="text-sm">
|
<HoverCardContent className="text-sm">
|
||||||
<p className="font-medium">{found.page.data.title}</p>
|
<p className="font-medium">{found.page.data.title}</p>
|
||||||
<p className="text-fd-muted-foreground">
|
<p className="text-fd-muted-foreground">
|
||||||
{found.page.data.description}
|
{found.page.data.description}
|
||||||
</p>
|
</p>
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</PremiumGuard>
|
|
||||||
</DocsBody>
|
</DocsBody>
|
||||||
</DocsPage>
|
</DocsPage>
|
||||||
);
|
);
|
||||||
|
@ -12,7 +12,6 @@ import { routing } from '@/i18n/routing';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { type Locale, NextIntlClientProvider, hasLocale } from 'next-intl';
|
import { type Locale, NextIntlClientProvider, hasLocale } from 'next-intl';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { Providers } from './providers';
|
import { Providers } from './providers';
|
||||||
@ -58,17 +57,15 @@ export default async function LocaleLayout({
|
|||||||
fontBricolageGrotesque.variable
|
fontBricolageGrotesque.variable
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<NuqsAdapter>
|
<NextIntlClientProvider>
|
||||||
<NextIntlClientProvider>
|
<Providers locale={locale}>
|
||||||
<Providers locale={locale}>
|
{children}
|
||||||
{children}
|
|
||||||
|
|
||||||
<Toaster richColors position="top-right" offset={64} />
|
<Toaster richColors position="top-right" offset={64} />
|
||||||
<TailwindIndicator />
|
<TailwindIndicator />
|
||||||
<Analytics />
|
<Analytics />
|
||||||
</Providers>
|
</Providers>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</NuqsAdapter>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
@ -29,7 +29,7 @@ interface ProvidersProps {
|
|||||||
*/
|
*/
|
||||||
export function Providers({ children, locale }: ProvidersProps) {
|
export function Providers({ children, locale }: ProvidersProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const defaultMode = websiteConfig.ui.mode?.defaultMode ?? 'system';
|
const defaultMode = websiteConfig.metadata.mode?.defaultMode ?? 'system';
|
||||||
|
|
||||||
// available languages that will be displayed in the docs UI
|
// available languages that will be displayed in the docs UI
|
||||||
// make sure `locale` is consistent with your i18n config
|
// make sure `locale` is consistent with your i18n config
|
||||||
|
@ -13,9 +13,12 @@ import {
|
|||||||
validateUrl,
|
validateUrl,
|
||||||
} from '@/ai/text/utils/web-content-analyzer';
|
} from '@/ai/text/utils/web-content-analyzer';
|
||||||
import {
|
import {
|
||||||
|
getWebContentAnalysisCost,
|
||||||
validateFirecrawlConfig,
|
validateFirecrawlConfig,
|
||||||
webContentAnalyzerConfig,
|
webContentAnalyzerConfig,
|
||||||
} from '@/ai/text/utils/web-content-analyzer-config';
|
} from '@/ai/text/utils/web-content-analyzer-config';
|
||||||
|
import { consumeCredits, hasEnoughCredits } from '@/credits/credits';
|
||||||
|
import { getSession } from '@/lib/server';
|
||||||
import { createDeepSeek } from '@ai-sdk/deepseek';
|
import { createDeepSeek } from '@ai-sdk/deepseek';
|
||||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||||
import { createOpenAI } from '@ai-sdk/openai';
|
import { createOpenAI } from '@ai-sdk/openai';
|
||||||
@ -27,6 +30,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
// Constants from configuration
|
// Constants from configuration
|
||||||
const TIMEOUT_MILLIS = webContentAnalyzerConfig.timeoutMillis;
|
const TIMEOUT_MILLIS = webContentAnalyzerConfig.timeoutMillis;
|
||||||
|
const CREDITS_COST = getWebContentAnalysisCost();
|
||||||
const MAX_CONTENT_LENGTH = webContentAnalyzerConfig.maxContentLength;
|
const MAX_CONTENT_LENGTH = webContentAnalyzerConfig.maxContentLength;
|
||||||
|
|
||||||
// Initialize Firecrawl client
|
// Initialize Firecrawl client
|
||||||
@ -357,6 +361,28 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
const authError = new WebContentAnalyzerError(
|
||||||
|
ErrorType.AUTHENTICATION,
|
||||||
|
'Authentication required',
|
||||||
|
'Please sign in to analyze web content.',
|
||||||
|
ErrorSeverity.HIGH,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
logError(authError, { requestId });
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: authError.userMessage,
|
||||||
|
} satisfies AnalyzeContentResponse,
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if Firecrawl is configured
|
// Check if Firecrawl is configured
|
||||||
if (!validateFirecrawlConfig()) {
|
if (!validateFirecrawlConfig()) {
|
||||||
const configError = new WebContentAnalyzerError(
|
const configError = new WebContentAnalyzerError(
|
||||||
@ -378,7 +404,39 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Starting analysis [requestId=${requestId}, url=${url}]`);
|
// Check if user has sufficient credits before starting analysis
|
||||||
|
const hasCredits = await hasEnoughCredits({
|
||||||
|
userId: session.user.id,
|
||||||
|
requiredCredits: CREDITS_COST,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasCredits) {
|
||||||
|
const creditError = new WebContentAnalyzerError(
|
||||||
|
ErrorType.CREDITS,
|
||||||
|
'Insufficient credits to perform analysis',
|
||||||
|
"You don't have enough credits to analyze this webpage. Please purchase more credits.",
|
||||||
|
ErrorSeverity.HIGH,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
logError(creditError, {
|
||||||
|
requestId,
|
||||||
|
userId: session.user.id,
|
||||||
|
requiredCredits: CREDITS_COST,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: creditError.userMessage,
|
||||||
|
} satisfies AnalyzeContentResponse,
|
||||||
|
{ status: 402 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Starting analysis [requestId=${requestId}, url=${url}, userId=${session.user.id}]`
|
||||||
|
);
|
||||||
|
|
||||||
// Perform analysis with timeout and enhanced error handling
|
// Perform analysis with timeout and enhanced error handling
|
||||||
const analysisPromise = (async () => {
|
const analysisPromise = (async () => {
|
||||||
@ -389,6 +447,13 @@ export async function POST(req: NextRequest) {
|
|||||||
// Step 2: Analyze content with AI (pass provider)
|
// Step 2: Analyze content with AI (pass provider)
|
||||||
const analysis = await analyzeContent(content, url, modelProvider);
|
const analysis = await analyzeContent(content, url, modelProvider);
|
||||||
|
|
||||||
|
// Step 3: Consume credits (only on successful analysis)
|
||||||
|
await consumeCredits({
|
||||||
|
userId: session.user.id,
|
||||||
|
amount: CREDITS_COST,
|
||||||
|
description: `Web content analysis: ${url}`,
|
||||||
|
});
|
||||||
|
|
||||||
return { analysis, screenshot };
|
return { analysis, screenshot };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If it's already a WebContentAnalyzerError, just re-throw
|
// If it's already a WebContentAnalyzerError, just re-throw
|
||||||
@ -412,6 +477,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result,
|
data: result,
|
||||||
|
creditsConsumed: CREDITS_COST,
|
||||||
} satisfies AnalyzeContentResponse);
|
} satisfies AnalyzeContentResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
|
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
|
||||||
@ -433,6 +499,12 @@ export async function POST(req: NextRequest) {
|
|||||||
case ErrorType.VALIDATION:
|
case ErrorType.VALIDATION:
|
||||||
statusCode = 400;
|
statusCode = 400;
|
||||||
break;
|
break;
|
||||||
|
case ErrorType.AUTHENTICATION:
|
||||||
|
statusCode = 401;
|
||||||
|
break;
|
||||||
|
case ErrorType.CREDITS:
|
||||||
|
statusCode = 402;
|
||||||
|
break;
|
||||||
case ErrorType.TIMEOUT:
|
case ErrorType.TIMEOUT:
|
||||||
statusCode = 408;
|
statusCode = 408;
|
||||||
break;
|
break;
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
import { type UIMessage, convertToModelMessages, streamText } from 'ai';
|
|
||||||
|
|
||||||
// Allow streaming responses up to 30 seconds
|
|
||||||
export const maxDuration = 30;
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const {
|
|
||||||
messages,
|
|
||||||
model,
|
|
||||||
webSearch,
|
|
||||||
}: { messages: UIMessage[]; model: string; webSearch: boolean } =
|
|
||||||
await req.json();
|
|
||||||
|
|
||||||
const result = streamText({
|
|
||||||
model: webSearch ? 'perplexity/sonar' : model,
|
|
||||||
messages: convertToModelMessages(messages),
|
|
||||||
system:
|
|
||||||
'You are a helpful assistant that can answer questions and help with tasks',
|
|
||||||
});
|
|
||||||
|
|
||||||
// send sources and reasoning back to the client
|
|
||||||
return result.toUIMessageStreamResponse({
|
|
||||||
sendSources: true,
|
|
||||||
sendReasoning: true,
|
|
||||||
});
|
|
||||||
}
|
|
@ -4,56 +4,31 @@ import { UsersTable } from '@/components/admin/users-table';
|
|||||||
import { useUsers } from '@/hooks/use-users';
|
import { useUsers } from '@/hooks/use-users';
|
||||||
import type { SortingState } from '@tanstack/react-table';
|
import type { SortingState } from '@tanstack/react-table';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import {
|
import { useState } from 'react';
|
||||||
parseAsIndex,
|
|
||||||
parseAsInteger,
|
|
||||||
parseAsString,
|
|
||||||
useQueryStates,
|
|
||||||
} from 'nuqs';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
export function UsersPageClient() {
|
export function UsersPageClient() {
|
||||||
const t = useTranslations('Dashboard.admin.users');
|
const t = useTranslations('Dashboard.admin.users');
|
||||||
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
|
{ id: 'createdAt', desc: true },
|
||||||
|
]);
|
||||||
|
|
||||||
const [{ page, pageSize, search, sortId, sortDesc }, setQueryStates] =
|
const { data, isLoading } = useUsers(pageIndex, pageSize, search, sorting);
|
||||||
useQueryStates({
|
|
||||||
page: parseAsIndex.withDefault(0), // parseAsIndex adds +1 to URL, so 0-based internally, 1-based in URL
|
|
||||||
pageSize: parseAsInteger.withDefault(10),
|
|
||||||
search: parseAsString.withDefault(''),
|
|
||||||
sortId: parseAsString.withDefault('createdAt'),
|
|
||||||
sortDesc: parseAsInteger.withDefault(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
const sorting: SortingState = useMemo(
|
|
||||||
() => [{ id: sortId, desc: Boolean(sortDesc) }],
|
|
||||||
[sortId, sortDesc]
|
|
||||||
);
|
|
||||||
|
|
||||||
// page is already 0-based internally thanks to parseAsIndex
|
|
||||||
const { data, isLoading } = useUsers(page, pageSize, search, sorting);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UsersTable
|
<UsersTable
|
||||||
data={data?.items || []}
|
data={data?.items || []}
|
||||||
total={data?.total || 0}
|
total={data?.total || 0}
|
||||||
pageIndex={page}
|
pageIndex={pageIndex}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
search={search}
|
search={search}
|
||||||
sorting={sorting}
|
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
onSearch={(newSearch) => setQueryStates({ search: newSearch, page: 0 })}
|
onSearch={setSearch}
|
||||||
onPageChange={(newPageIndex) => setQueryStates({ page: newPageIndex })}
|
onPageChange={setPageIndex}
|
||||||
onPageSizeChange={(newPageSize) =>
|
onPageSizeChange={setPageSize}
|
||||||
setQueryStates({ pageSize: newPageSize, page: 0 })
|
onSortingChange={setSorting}
|
||||||
}
|
|
||||||
onSortingChange={(newSorting) => {
|
|
||||||
if (newSorting.length > 0) {
|
|
||||||
setQueryStates({
|
|
||||||
sortId: newSorting[0].id,
|
|
||||||
sortDesc: newSorting[0].desc ? 1 : 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -59,7 +59,6 @@ import { useState } from 'react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
import { Skeleton } from '../ui/skeleton';
|
|
||||||
|
|
||||||
interface DataTableColumnHeaderProps<TData, TValue>
|
interface DataTableColumnHeaderProps<TData, TValue>
|
||||||
extends React.HTMLAttributes<HTMLDivElement> {
|
extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
@ -117,27 +116,12 @@ function DataTableColumnHeader<TData, TValue>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableRowSkeleton({ columns }: { columns: number }) {
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
{Array.from({ length: columns }).map((_, index) => (
|
|
||||||
<TableCell key={index} className="py-4">
|
|
||||||
<div className="flex items-center gap-2 pl-3">
|
|
||||||
<Skeleton className="h-6 w-full max-w-32" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsersTableProps {
|
interface UsersTableProps {
|
||||||
data: User[];
|
data: User[];
|
||||||
total: number;
|
total: number;
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
search: string;
|
search: string;
|
||||||
sorting?: SortingState;
|
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onSearch: (search: string) => void;
|
onSearch: (search: string) => void;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
@ -154,7 +138,6 @@ export function UsersTable({
|
|||||||
pageIndex,
|
pageIndex,
|
||||||
pageSize,
|
pageSize,
|
||||||
search,
|
search,
|
||||||
sorting = [{ id: 'createdAt', desc: true }],
|
|
||||||
loading,
|
loading,
|
||||||
onSearch,
|
onSearch,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
@ -163,6 +146,9 @@ export function UsersTable({
|
|||||||
}: UsersTableProps) {
|
}: UsersTableProps) {
|
||||||
const t = useTranslations('Dashboard.admin.users');
|
const t = useTranslations('Dashboard.admin.users');
|
||||||
const tTable = useTranslations('Common.table');
|
const tTable = useTranslations('Common.table');
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
|
{ id: 'createdAt', desc: true },
|
||||||
|
]);
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||||
|
|
||||||
@ -365,6 +351,7 @@ export function UsersTable({
|
|||||||
},
|
},
|
||||||
onSortingChange: (updater) => {
|
onSortingChange: (updater) => {
|
||||||
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
||||||
|
setSorting(next);
|
||||||
onSortingChange?.(next);
|
onSortingChange?.(next);
|
||||||
},
|
},
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
@ -457,12 +444,7 @@ export function UsersTable({
|
|||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{table.getRowModel().rows?.length ? (
|
||||||
// Show skeleton rows while loading
|
|
||||||
Array.from({ length: pageSize }).map((_, index) => (
|
|
||||||
<TableRowSkeleton key={index} columns={columns.length} />
|
|
||||||
))
|
|
||||||
) : table.getRowModel().rows?.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
@ -484,7 +466,7 @@ export function UsersTable({
|
|||||||
colSpan={columns.length}
|
colSpan={columns.length}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
{tTable('noResults')}
|
{loading ? tTable('loading') : tTable('noResults')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
@ -1,65 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { ComponentProps } from 'react';
|
|
||||||
|
|
||||||
export type ActionsProps = ComponentProps<'div'>;
|
|
||||||
|
|
||||||
export const Actions = ({ className, children, ...props }: ActionsProps) => (
|
|
||||||
<div className={cn('flex items-center gap-1', className)} {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ActionProps = ComponentProps<typeof Button> & {
|
|
||||||
tooltip?: string;
|
|
||||||
label?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Action = ({
|
|
||||||
tooltip,
|
|
||||||
children,
|
|
||||||
label,
|
|
||||||
className,
|
|
||||||
variant = 'ghost',
|
|
||||||
size = 'sm',
|
|
||||||
...props
|
|
||||||
}: ActionProps) => {
|
|
||||||
const button = (
|
|
||||||
<Button
|
|
||||||
className={cn(
|
|
||||||
'size-9 p-1.5 text-muted-foreground hover:text-foreground relative',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
size={size}
|
|
||||||
type="button"
|
|
||||||
variant={variant}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<span className="sr-only">{label || tooltip}</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tooltip) {
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{tooltip}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return button;
|
|
||||||
};
|
|
@ -1,212 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { UIMessage } from 'ai';
|
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
|
||||||
import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
|
|
||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
type BranchContextType = {
|
|
||||||
currentBranch: number;
|
|
||||||
totalBranches: number;
|
|
||||||
goToPrevious: () => void;
|
|
||||||
goToNext: () => void;
|
|
||||||
branches: ReactElement[];
|
|
||||||
setBranches: (branches: ReactElement[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const BranchContext = createContext<BranchContextType | null>(null);
|
|
||||||
|
|
||||||
const useBranch = () => {
|
|
||||||
const context = useContext(BranchContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('Branch components must be used within Branch');
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BranchProps = HTMLAttributes<HTMLDivElement> & {
|
|
||||||
defaultBranch?: number;
|
|
||||||
onBranchChange?: (branchIndex: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Branch = ({
|
|
||||||
defaultBranch = 0,
|
|
||||||
onBranchChange,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: BranchProps) => {
|
|
||||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
|
||||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
|
||||||
|
|
||||||
const handleBranchChange = (newBranch: number) => {
|
|
||||||
setCurrentBranch(newBranch);
|
|
||||||
onBranchChange?.(newBranch);
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToPrevious = () => {
|
|
||||||
const newBranch =
|
|
||||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
|
||||||
handleBranchChange(newBranch);
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToNext = () => {
|
|
||||||
const newBranch =
|
|
||||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
|
||||||
handleBranchChange(newBranch);
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextValue: BranchContextType = {
|
|
||||||
currentBranch,
|
|
||||||
totalBranches: branches.length,
|
|
||||||
goToPrevious,
|
|
||||||
goToNext,
|
|
||||||
branches,
|
|
||||||
setBranches,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BranchContext.Provider value={contextValue}>
|
|
||||||
<div
|
|
||||||
className={cn('grid w-full gap-2 [&>div]:pb-0', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</BranchContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
|
|
||||||
const { currentBranch, setBranches, branches } = useBranch();
|
|
||||||
const childrenArray = Array.isArray(children) ? children : [children];
|
|
||||||
|
|
||||||
// Use useEffect to update branches when they change
|
|
||||||
useEffect(() => {
|
|
||||||
if (branches.length !== childrenArray.length) {
|
|
||||||
setBranches(childrenArray);
|
|
||||||
}
|
|
||||||
}, [childrenArray, branches, setBranches]);
|
|
||||||
|
|
||||||
return childrenArray.map((branch, index) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'grid gap-2 overflow-hidden [&>div]:pb-0',
|
|
||||||
index === currentBranch ? 'block' : 'hidden'
|
|
||||||
)}
|
|
||||||
key={branch.key}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{branch}
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
|
||||||
from: UIMessage['role'];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BranchSelector = ({
|
|
||||||
className,
|
|
||||||
from,
|
|
||||||
...props
|
|
||||||
}: BranchSelectorProps) => {
|
|
||||||
const { totalBranches } = useBranch();
|
|
||||||
|
|
||||||
// Don't render if there's only one branch
|
|
||||||
if (totalBranches <= 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 self-end px-10',
|
|
||||||
from === 'assistant' ? 'justify-start' : 'justify-end',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BranchPreviousProps = ComponentProps<typeof Button>;
|
|
||||||
|
|
||||||
export const BranchPrevious = ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: BranchPreviousProps) => {
|
|
||||||
const { goToPrevious, totalBranches } = useBranch();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
aria-label="Previous branch"
|
|
||||||
className={cn(
|
|
||||||
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
|
|
||||||
'hover:bg-accent hover:text-foreground',
|
|
||||||
'disabled:pointer-events-none disabled:opacity-50',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
disabled={totalBranches <= 1}
|
|
||||||
onClick={goToPrevious}
|
|
||||||
size="icon"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? <ChevronLeftIcon size={14} />}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BranchNextProps = ComponentProps<typeof Button>;
|
|
||||||
|
|
||||||
export const BranchNext = ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: BranchNextProps) => {
|
|
||||||
const { goToNext, totalBranches } = useBranch();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
aria-label="Next branch"
|
|
||||||
className={cn(
|
|
||||||
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
|
|
||||||
'hover:bg-accent hover:text-foreground',
|
|
||||||
'disabled:pointer-events-none disabled:opacity-50',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
disabled={totalBranches <= 1}
|
|
||||||
onClick={goToNext}
|
|
||||||
size="icon"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? <ChevronRightIcon size={14} />}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
|
||||||
|
|
||||||
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
|
|
||||||
const { currentBranch, totalBranches } = useBranch();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'font-medium text-muted-foreground text-xs tabular-nums',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{currentBranch + 1} of {totalBranches}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,148 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { CheckIcon, CopyIcon } from 'lucide-react';
|
|
||||||
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
|
|
||||||
import { createContext, useContext, useState } from 'react';
|
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
||||||
import {
|
|
||||||
oneDark,
|
|
||||||
oneLight,
|
|
||||||
} from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
||||||
|
|
||||||
type CodeBlockContextType = {
|
|
||||||
code: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
|
||||||
code: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
|
||||||
code: string;
|
|
||||||
language: string;
|
|
||||||
showLineNumbers?: boolean;
|
|
||||||
children?: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CodeBlock = ({
|
|
||||||
code,
|
|
||||||
language,
|
|
||||||
showLineNumbers = false,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: CodeBlockProps) => (
|
|
||||||
<CodeBlockContext.Provider value={{ code }}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'relative w-full overflow-hidden rounded-md border bg-background text-foreground',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<SyntaxHighlighter
|
|
||||||
className="overflow-hidden dark:hidden"
|
|
||||||
codeTagProps={{
|
|
||||||
className: 'font-mono text-sm',
|
|
||||||
}}
|
|
||||||
customStyle={{
|
|
||||||
margin: 0,
|
|
||||||
padding: '1rem',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
background: 'hsl(var(--background))',
|
|
||||||
color: 'hsl(var(--foreground))',
|
|
||||||
}}
|
|
||||||
language={language}
|
|
||||||
lineNumberStyle={{
|
|
||||||
color: 'hsl(var(--muted-foreground))',
|
|
||||||
paddingRight: '1rem',
|
|
||||||
minWidth: '2.5rem',
|
|
||||||
}}
|
|
||||||
showLineNumbers={showLineNumbers}
|
|
||||||
style={oneLight}
|
|
||||||
>
|
|
||||||
{code}
|
|
||||||
</SyntaxHighlighter>
|
|
||||||
<SyntaxHighlighter
|
|
||||||
className="hidden overflow-hidden dark:block"
|
|
||||||
codeTagProps={{
|
|
||||||
className: 'font-mono text-sm',
|
|
||||||
}}
|
|
||||||
customStyle={{
|
|
||||||
margin: 0,
|
|
||||||
padding: '1rem',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
background: 'hsl(var(--background))',
|
|
||||||
color: 'hsl(var(--foreground))',
|
|
||||||
}}
|
|
||||||
language={language}
|
|
||||||
lineNumberStyle={{
|
|
||||||
color: 'hsl(var(--muted-foreground))',
|
|
||||||
paddingRight: '1rem',
|
|
||||||
minWidth: '2.5rem',
|
|
||||||
}}
|
|
||||||
showLineNumbers={showLineNumbers}
|
|
||||||
style={oneDark}
|
|
||||||
>
|
|
||||||
{code}
|
|
||||||
</SyntaxHighlighter>
|
|
||||||
{children && (
|
|
||||||
<div className="absolute top-2 right-2 flex items-center gap-2">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CodeBlockContext.Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
|
||||||
onCopy?: () => void;
|
|
||||||
onError?: (error: Error) => void;
|
|
||||||
timeout?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CodeBlockCopyButton = ({
|
|
||||||
onCopy,
|
|
||||||
onError,
|
|
||||||
timeout = 2000,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: CodeBlockCopyButtonProps) => {
|
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
|
||||||
const { code } = useContext(CodeBlockContext);
|
|
||||||
|
|
||||||
const copyToClipboard = async () => {
|
|
||||||
if (typeof window === 'undefined' || !navigator.clipboard.writeText) {
|
|
||||||
onError?.(new Error('Clipboard API not available'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(code);
|
|
||||||
setIsCopied(true);
|
|
||||||
onCopy?.();
|
|
||||||
setTimeout(() => setIsCopied(false), timeout);
|
|
||||||
} catch (error) {
|
|
||||||
onError?.(error as Error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={cn('shrink-0', className)}
|
|
||||||
onClick={copyToClipboard}
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? <Icon size={14} />}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,62 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { ArrowDownIcon } from 'lucide-react';
|
|
||||||
import type { ComponentProps } from 'react';
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
|
|
||||||
|
|
||||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
|
||||||
|
|
||||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
|
||||||
<StickToBottom
|
|
||||||
className={cn('relative flex-1 overflow-y-auto', className)}
|
|
||||||
initial="smooth"
|
|
||||||
resize="smooth"
|
|
||||||
role="log"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ConversationContentProps = ComponentProps<
|
|
||||||
typeof StickToBottom.Content
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const ConversationContent = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ConversationContentProps) => (
|
|
||||||
<StickToBottom.Content className={cn('p-4', className)} {...props} />
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
|
||||||
|
|
||||||
export const ConversationScrollButton = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ConversationScrollButtonProps) => {
|
|
||||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
|
||||||
|
|
||||||
const handleScrollToBottom = useCallback(() => {
|
|
||||||
scrollToBottom();
|
|
||||||
}, [scrollToBottom]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
!isAtBottom && (
|
|
||||||
<Button
|
|
||||||
className={cn(
|
|
||||||
'absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
onClick={handleScrollToBottom}
|
|
||||||
size="icon"
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ArrowDownIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,24 +0,0 @@
|
|||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { Experimental_GeneratedImage } from 'ai';
|
|
||||||
|
|
||||||
export type ImageProps = Experimental_GeneratedImage & {
|
|
||||||
className?: string;
|
|
||||||
alt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Image = ({
|
|
||||||
base64,
|
|
||||||
uint8Array,
|
|
||||||
mediaType,
|
|
||||||
...props
|
|
||||||
}: ImageProps) => (
|
|
||||||
<img
|
|
||||||
{...props}
|
|
||||||
alt={props.alt}
|
|
||||||
className={cn(
|
|
||||||
'h-auto max-w-full overflow-hidden rounded-md',
|
|
||||||
props.className
|
|
||||||
)}
|
|
||||||
src={`data:${mediaType};base64,${base64}`}
|
|
||||||
/>
|
|
||||||
);
|
|
@ -1,287 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import {
|
|
||||||
Carousel,
|
|
||||||
CarouselContent,
|
|
||||||
CarouselItem,
|
|
||||||
type CarouselApi,
|
|
||||||
} from '@/components/ui/carousel';
|
|
||||||
import {
|
|
||||||
HoverCard,
|
|
||||||
HoverCardContent,
|
|
||||||
HoverCardTrigger,
|
|
||||||
} from '@/components/ui/hover-card';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
type ComponentProps,
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
export type InlineCitationProps = ComponentProps<'span'>;
|
|
||||||
|
|
||||||
export const InlineCitation = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: InlineCitationProps) => (
|
|
||||||
<span
|
|
||||||
className={cn('group inline items-center gap-1', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type InlineCitationTextProps = ComponentProps<'span'>;
|
|
||||||
|
|
||||||
export const InlineCitationText = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: InlineCitationTextProps) => (
|
|
||||||
<span
|
|
||||||
className={cn('transition-colors group-hover:bg-accent', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
|
|
||||||
|
|
||||||
export const InlineCitationCard = (props: InlineCitationCardProps) => (
|
|
||||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
|
||||||
);
|
|
||||||
|
|
||||||
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
|
|
||||||
sources: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InlineCitationCardTrigger = ({
|
|
||||||
sources,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: InlineCitationCardTriggerProps) => (
|
|
||||||
<HoverCardTrigger asChild>
|
|
||||||
<Badge
|
|
||||||
className={cn('ml-1 rounded-full', className)}
|
|
||||||
variant="secondary"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{sources.length ? (
|
|
||||||
<>
|
|
||||||
{new URL(sources[0]).hostname}{' '}
|
|
||||||
{sources.length > 1 && `+${sources.length - 1}`}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'unknown'
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type InlineCitationCardBodyProps = ComponentProps<'div'>;
|
|
||||||
|
|
||||||
export const InlineCitationCardBody = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: InlineCitationCardBodyProps) => (
|
|
||||||
<HoverCardContent className={cn('relative w-80 p-0', className)} {...props} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
|
|
||||||
|
|
||||||
const useCarouselApi = () => {
|
|
||||||
const context = useContext(CarouselApiContext);
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
|
|
||||||
|
|
||||||
export const InlineCitationCarousel = ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: InlineCitationCarouselProps) => {
|
|
||||||
const [api, setApi] = useState<CarouselApi>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CarouselApiContext.Provider value={api}>
|
|
||||||
<Carousel className={cn('w-full', className)} setApi={setApi} {...props}>
|
|
||||||
{children}
|
|
||||||
</Carousel>
|
|
||||||
</CarouselApiContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InlineCitationCarouselContentProps = ComponentProps<'div'>;
|
|
||||||
|
|
||||||
export const InlineCitationCarouselContent = (
|
|
||||||
props: InlineCitationCarouselContentProps
|
|
||||||
) => <CarouselContent {...props} />;
|
|
||||||
|
|
||||||
export type InlineCitationCarouselItemProps = ComponentProps<'div'>;
|
|
||||||
|
|
||||||
export const InlineCitationCarouselItem = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: InlineCitationCarouselItemProps) => (
|
|
||||||
<CarouselItem
|
|
||||||
className={cn('w-full space-y-2 p-4 pl-8', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>;
|
|
||||||
|
|
||||||
export const InlineCitationCarouselHeader = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: InlineCitationCarouselHeaderProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type InlineCitationCarouselIndexProps = ComponentProps<'div'>;
|
|
||||||
|
|
||||||
export const InlineCitationCarouselIndex = ({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: InlineCitationCarouselIndexProps) => {
|
|
||||||
const api = useCarouselApi();
|
|
||||||
const [current, setCurrent] = useState(0);
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!api) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCount(api.scrollSnapList().length);
|
|
||||||
setCurrent(api.selectedScrollSnap() + 1);
|
|
||||||
|
|
||||||
api.on('select', () => {
|
|
||||||
setCurrent(api.selectedScrollSnap() + 1);
|
|
||||||
});
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? `${current}/${count}`}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InlineCitationCarouselPrevProps = ComponentProps<'button'>;
|
|
||||||
|
|
||||||
export const InlineCitationCarouselPrev = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: InlineCitationCarouselPrevProps) => {
|
|
||||||
const api = useCarouselApi();
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
if (api) {
|
|
||||||
api.scrollPrev();
|
|
||||||
}
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
aria-label="Previous"
|
|
||||||
className={cn('shrink-0', className)}
|
|
||||||
onClick={handleClick}
|
|
||||||
type="button"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="size-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InlineCitationCarouselNextProps = ComponentProps<'button'>;
|
|
||||||
|
|
||||||
export const InlineCitationCarouselNext = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: InlineCitationCarouselNextProps) => {
|
|
||||||
const api = useCarouselApi();
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
if (api) {
|
|
||||||
api.scrollNext();
|
|
||||||
}
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
aria-label="Next"
|
|
||||||
className={cn('shrink-0', className)}
|
|
||||||
onClick={handleClick}
|
|
||||||
type="button"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ArrowRightIcon className="size-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InlineCitationSourceProps = ComponentProps<'div'> & {
|
|
||||||
title?: string;
|
|
||||||
url?: string;
|
|
||||||
description?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InlineCitationSource = ({
|
|
||||||
title,
|
|
||||||
url,
|
|
||||||
description,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: InlineCitationSourceProps) => (
|
|
||||||
<div className={cn('space-y-1', className)} {...props}>
|
|
||||||
{title && (
|
|
||||||
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
|
|
||||||
)}
|
|
||||||
{url && (
|
|
||||||
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
|
|
||||||
)}
|
|
||||||
{description && (
|
|
||||||
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type InlineCitationQuoteProps = ComponentProps<'blockquote'>;
|
|
||||||
|
|
||||||
export const InlineCitationQuote = ({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: InlineCitationQuoteProps) => (
|
|
||||||
<blockquote
|
|
||||||
className={cn(
|
|
||||||
'border-muted border-l-2 pl-3 text-muted-foreground text-sm italic',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</blockquote>
|
|
||||||
);
|
|
@ -1,96 +0,0 @@
|
|||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
type LoaderIconProps = {
|
|
||||||
size?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
|
|
||||||
<svg
|
|
||||||
height={size}
|
|
||||||
strokeLinejoin="round"
|
|
||||||
style={{ color: 'currentcolor' }}
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
width={size}
|
|
||||||
>
|
|
||||||
<title>Loader</title>
|
|
||||||
<g clipPath="url(#clip0_2393_1490)">
|
|
||||||
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
|
|
||||||
<path
|
|
||||||
d="M8 16V12"
|
|
||||||
opacity="0.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M3.29773 1.52783L5.64887 4.7639"
|
|
||||||
opacity="0.9"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12.7023 1.52783L10.3511 4.7639"
|
|
||||||
opacity="0.1"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12.7023 14.472L10.3511 11.236"
|
|
||||||
opacity="0.4"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M3.29773 14.472L5.64887 11.236"
|
|
||||||
opacity="0.6"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M15.6085 5.52783L11.8043 6.7639"
|
|
||||||
opacity="0.2"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M0.391602 10.472L4.19583 9.23598"
|
|
||||||
opacity="0.7"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M15.6085 10.4722L11.8043 9.2361"
|
|
||||||
opacity="0.3"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M0.391602 5.52783L4.19583 6.7639"
|
|
||||||
opacity="0.8"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_2393_1490">
|
|
||||||
<rect fill="white" height="16" width="16" />
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
|
|
||||||
size?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'inline-flex animate-spin items-center justify-center',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<LoaderIcon size={size} />
|
|
||||||
</div>
|
|
||||||
);
|
|
@ -1,62 +0,0 @@
|
|||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from '@/components/ui/avatar';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { UIMessage } from 'ai';
|
|
||||||
import type { ComponentProps, HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
|
||||||
from: UIMessage['role'];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'group flex w-full items-end justify-end gap-2 py-4',
|
|
||||||
from === 'user' ? 'is-user' : 'is-assistant flex-row-reverse justify-end',
|
|
||||||
'[&>div]:max-w-[80%]',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const MessageContent = ({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: MessageContentProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm',
|
|
||||||
'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground',
|
|
||||||
'group-[.is-assistant]:bg-card group-[.is-assistant]:text-card-foreground',
|
|
||||||
'is-user:dark',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type MessageAvatarProps = ComponentProps<typeof Avatar> & {
|
|
||||||
src: string;
|
|
||||||
name?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MessageAvatar = ({
|
|
||||||
src,
|
|
||||||
name,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: MessageAvatarProps) => (
|
|
||||||
<Avatar className={cn('size-8 ring-1 ring-border', className)} {...props}>
|
|
||||||
<AvatarImage alt="" className="mt-0 mb-0" src={src} />
|
|
||||||
<AvatarFallback>{name?.slice(0, 2) || 'ME'}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
);
|
|
@ -1,230 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { ChatStatus } from 'ai';
|
|
||||||
import { Loader2Icon, SendIcon, SquareIcon, XIcon } from 'lucide-react';
|
|
||||||
import type {
|
|
||||||
ComponentProps,
|
|
||||||
HTMLAttributes,
|
|
||||||
KeyboardEventHandler,
|
|
||||||
} from 'react';
|
|
||||||
import { Children } from 'react';
|
|
||||||
|
|
||||||
export type PromptInputProps = HTMLAttributes<HTMLFormElement>;
|
|
||||||
|
|
||||||
export const PromptInput = ({ className, ...props }: PromptInputProps) => (
|
|
||||||
<form
|
|
||||||
className={cn(
|
|
||||||
'w-full divide-y overflow-hidden rounded-xl border bg-background shadow-sm',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type PromptInputTextareaProps = ComponentProps<typeof Textarea> & {
|
|
||||||
minHeight?: number;
|
|
||||||
maxHeight?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PromptInputTextarea = ({
|
|
||||||
onChange,
|
|
||||||
className,
|
|
||||||
placeholder = 'What would you like to know?',
|
|
||||||
minHeight = 48,
|
|
||||||
maxHeight = 164,
|
|
||||||
...props
|
|
||||||
}: PromptInputTextareaProps) => {
|
|
||||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
// Don't submit if IME composition is in progress
|
|
||||||
if (e.nativeEvent.isComposing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.shiftKey) {
|
|
||||||
// Allow newline
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Submit on Enter (without Shift)
|
|
||||||
e.preventDefault();
|
|
||||||
const form = e.currentTarget.form;
|
|
||||||
if (form) {
|
|
||||||
form.requestSubmit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Textarea
|
|
||||||
className={cn(
|
|
||||||
'w-full resize-none rounded-none border-none p-3 shadow-none outline-none ring-0',
|
|
||||||
'field-sizing-content max-h-[6lh] bg-transparent dark:bg-transparent',
|
|
||||||
'focus-visible:ring-0',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
name="message"
|
|
||||||
onChange={(e) => {
|
|
||||||
onChange?.(e);
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder={placeholder}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PromptInputToolbarProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const PromptInputToolbar = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: PromptInputToolbarProps) => (
|
|
||||||
<div
|
|
||||||
className={cn('flex items-center justify-between p-1', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const PromptInputTools = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: PromptInputToolsProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1',
|
|
||||||
'[&_button:first-child]:rounded-bl-xl',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type PromptInputButtonProps = ComponentProps<typeof Button>;
|
|
||||||
|
|
||||||
export const PromptInputButton = ({
|
|
||||||
variant = 'ghost',
|
|
||||||
className,
|
|
||||||
size,
|
|
||||||
...props
|
|
||||||
}: PromptInputButtonProps) => {
|
|
||||||
const newSize =
|
|
||||||
(size ?? Children.count(props.children) > 1) ? 'default' : 'icon';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={cn(
|
|
||||||
'shrink-0 gap-1.5 rounded-lg',
|
|
||||||
variant === 'ghost' && 'text-muted-foreground',
|
|
||||||
newSize === 'default' && 'px-3',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
size={newSize}
|
|
||||||
type="button"
|
|
||||||
variant={variant}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PromptInputSubmitProps = ComponentProps<typeof Button> & {
|
|
||||||
status?: ChatStatus;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PromptInputSubmit = ({
|
|
||||||
className,
|
|
||||||
variant = 'default',
|
|
||||||
size = 'icon',
|
|
||||||
status,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: PromptInputSubmitProps) => {
|
|
||||||
let Icon = <SendIcon className="size-4" />;
|
|
||||||
|
|
||||||
if (status === 'submitted') {
|
|
||||||
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
|
||||||
} else if (status === 'streaming') {
|
|
||||||
Icon = <SquareIcon className="size-4" />;
|
|
||||||
} else if (status === 'error') {
|
|
||||||
Icon = <XIcon className="size-4" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={cn('gap-1.5 rounded-lg', className)}
|
|
||||||
size={size}
|
|
||||||
type="submit"
|
|
||||||
variant={variant}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? Icon}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PromptInputModelSelectProps = ComponentProps<typeof Select>;
|
|
||||||
|
|
||||||
export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
|
|
||||||
<Select {...props} />
|
|
||||||
);
|
|
||||||
|
|
||||||
export type PromptInputModelSelectTriggerProps = ComponentProps<
|
|
||||||
typeof SelectTrigger
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const PromptInputModelSelectTrigger = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: PromptInputModelSelectTriggerProps) => (
|
|
||||||
<SelectTrigger
|
|
||||||
className={cn(
|
|
||||||
'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
|
|
||||||
'hover:bg-accent hover:text-foreground [&[aria-expanded="true"]]:bg-accent [&[aria-expanded="true"]]:text-foreground',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type PromptInputModelSelectContentProps = ComponentProps<
|
|
||||||
typeof SelectContent
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const PromptInputModelSelectContent = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: PromptInputModelSelectContentProps) => (
|
|
||||||
<SelectContent className={cn(className)} {...props} />
|
|
||||||
);
|
|
||||||
|
|
||||||
export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>;
|
|
||||||
|
|
||||||
export const PromptInputModelSelectItem = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: PromptInputModelSelectItemProps) => (
|
|
||||||
<SelectItem className={cn(className)} {...props} />
|
|
||||||
);
|
|
||||||
|
|
||||||
export type PromptInputModelSelectValueProps = ComponentProps<
|
|
||||||
typeof SelectValue
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const PromptInputModelSelectValue = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: PromptInputModelSelectValueProps) => (
|
|
||||||
<SelectValue className={cn(className)} {...props} />
|
|
||||||
);
|
|
@ -1,171 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useControllableState } from '@radix-ui/react-use-controllable-state';
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { BrainIcon, ChevronDownIcon } from 'lucide-react';
|
|
||||||
import type { ComponentProps } from 'react';
|
|
||||||
import { createContext, memo, useContext, useEffect, useState } from 'react';
|
|
||||||
import { Response } from './response';
|
|
||||||
|
|
||||||
type ReasoningContextValue = {
|
|
||||||
isStreaming: boolean;
|
|
||||||
isOpen: boolean;
|
|
||||||
setIsOpen: (open: boolean) => void;
|
|
||||||
duration: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
|
||||||
|
|
||||||
const useReasoning = () => {
|
|
||||||
const context = useContext(ReasoningContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('Reasoning components must be used within Reasoning');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
|
||||||
isStreaming?: boolean;
|
|
||||||
open?: boolean;
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
duration?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AUTO_CLOSE_DELAY = 1000;
|
|
||||||
const MS_IN_S = 1000;
|
|
||||||
|
|
||||||
export const Reasoning = memo(
|
|
||||||
({
|
|
||||||
className,
|
|
||||||
isStreaming = false,
|
|
||||||
open,
|
|
||||||
defaultOpen = true,
|
|
||||||
onOpenChange,
|
|
||||||
duration: durationProp,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: ReasoningProps) => {
|
|
||||||
const [isOpen, setIsOpen] = useControllableState({
|
|
||||||
prop: open,
|
|
||||||
defaultProp: defaultOpen,
|
|
||||||
onChange: onOpenChange,
|
|
||||||
});
|
|
||||||
const [duration, setDuration] = useControllableState({
|
|
||||||
prop: durationProp,
|
|
||||||
defaultProp: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [hasAutoClosedRef, setHasAutoClosedRef] = useState(false);
|
|
||||||
const [startTime, setStartTime] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// Track duration when streaming starts and ends
|
|
||||||
useEffect(() => {
|
|
||||||
if (isStreaming) {
|
|
||||||
if (startTime === null) {
|
|
||||||
setStartTime(Date.now());
|
|
||||||
}
|
|
||||||
} else if (startTime !== null) {
|
|
||||||
setDuration(Math.round((Date.now() - startTime) / MS_IN_S));
|
|
||||||
setStartTime(null);
|
|
||||||
}
|
|
||||||
}, [isStreaming, startTime, setDuration]);
|
|
||||||
|
|
||||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
|
||||||
useEffect(() => {
|
|
||||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosedRef) {
|
|
||||||
// Add a small delay before closing to allow user to see the content
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
setHasAutoClosedRef(true);
|
|
||||||
}, AUTO_CLOSE_DELAY);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosedRef]);
|
|
||||||
|
|
||||||
const handleOpenChange = (newOpen: boolean) => {
|
|
||||||
setIsOpen(newOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ReasoningContext.Provider
|
|
||||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
|
||||||
>
|
|
||||||
<Collapsible
|
|
||||||
className={cn('not-prose mb-4', className)}
|
|
||||||
onOpenChange={handleOpenChange}
|
|
||||||
open={isOpen}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Collapsible>
|
|
||||||
</ReasoningContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
|
||||||
|
|
||||||
export const ReasoningTrigger = memo(
|
|
||||||
({ className, children, ...props }: ReasoningTriggerProps) => {
|
|
||||||
const { isStreaming, isOpen, duration } = useReasoning();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CollapsibleTrigger
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 text-muted-foreground text-sm',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? (
|
|
||||||
<>
|
|
||||||
<BrainIcon className="size-4" />
|
|
||||||
{isStreaming || duration === 0 ? (
|
|
||||||
<p>Thinking...</p>
|
|
||||||
) : (
|
|
||||||
<p>Thought for {duration} seconds</p>
|
|
||||||
)}
|
|
||||||
<ChevronDownIcon
|
|
||||||
className={cn(
|
|
||||||
'size-4 text-muted-foreground transition-transform',
|
|
||||||
isOpen ? 'rotate-180' : 'rotate-0'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ReasoningContentProps = ComponentProps<
|
|
||||||
typeof CollapsibleContent
|
|
||||||
> & {
|
|
||||||
children: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ReasoningContent = memo(
|
|
||||||
({ className, children, ...props }: ReasoningContentProps) => (
|
|
||||||
<CollapsibleContent
|
|
||||||
className={cn(
|
|
||||||
'mt-4 text-sm',
|
|
||||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Response className="grid gap-2">{children}</Response>
|
|
||||||
</CollapsibleContent>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
Reasoning.displayName = 'Reasoning';
|
|
||||||
ReasoningTrigger.displayName = 'ReasoningTrigger';
|
|
||||||
ReasoningContent.displayName = 'ReasoningContent';
|
|
@ -1,22 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { type ComponentProps, memo } from 'react';
|
|
||||||
import { Streamdown } from 'streamdown';
|
|
||||||
|
|
||||||
type ResponseProps = ComponentProps<typeof Streamdown>;
|
|
||||||
|
|
||||||
export const Response = memo(
|
|
||||||
({ className, ...props }: ResponseProps) => (
|
|
||||||
<Streamdown
|
|
||||||
className={cn(
|
|
||||||
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
|
||||||
);
|
|
||||||
|
|
||||||
Response.displayName = 'Response';
|
|
@ -1,74 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { BookIcon, ChevronDownIcon } from 'lucide-react';
|
|
||||||
import type { ComponentProps } from 'react';
|
|
||||||
|
|
||||||
export type SourcesProps = ComponentProps<'div'>;
|
|
||||||
|
|
||||||
export const Sources = ({ className, ...props }: SourcesProps) => (
|
|
||||||
<Collapsible
|
|
||||||
className={cn('not-prose mb-4 text-primary text-xs', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SourcesTrigger = ({
|
|
||||||
className,
|
|
||||||
count,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: SourcesTriggerProps) => (
|
|
||||||
<CollapsibleTrigger className="flex items-center gap-2" {...props}>
|
|
||||||
{children ?? (
|
|
||||||
<>
|
|
||||||
<p className="font-medium">Used {count} sources</p>
|
|
||||||
<ChevronDownIcon className="h-4 w-4" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
|
|
||||||
|
|
||||||
export const SourcesContent = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: SourcesContentProps) => (
|
|
||||||
<CollapsibleContent
|
|
||||||
className={cn(
|
|
||||||
'mt-3 flex w-fit flex-col gap-2',
|
|
||||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type SourceProps = ComponentProps<'a'>;
|
|
||||||
|
|
||||||
export const Source = ({ href, title, children, ...props }: SourceProps) => (
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
href={href}
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? (
|
|
||||||
<>
|
|
||||||
<BookIcon className="h-4 w-4" />
|
|
||||||
<span className="block font-medium">{title}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
);
|
|
@ -1,74 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { BookIcon, ChevronDownIcon } from 'lucide-react';
|
|
||||||
import type { ComponentProps } from 'react';
|
|
||||||
|
|
||||||
export type SourcesProps = ComponentProps<'div'>;
|
|
||||||
|
|
||||||
export const Sources = ({ className, ...props }: SourcesProps) => (
|
|
||||||
<Collapsible
|
|
||||||
className={cn('not-prose mb-4 text-primary text-xs', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SourcesTrigger = ({
|
|
||||||
className,
|
|
||||||
count,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: SourcesTriggerProps) => (
|
|
||||||
<CollapsibleTrigger className={cn("flex items-center gap-2", className)} {...props}>
|
|
||||||
{children ?? (
|
|
||||||
<>
|
|
||||||
<p className="font-medium">Used {count} sources</p>
|
|
||||||
<ChevronDownIcon className="h-4 w-4" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
|
|
||||||
|
|
||||||
export const SourcesContent = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: SourcesContentProps) => (
|
|
||||||
<CollapsibleContent
|
|
||||||
className={cn(
|
|
||||||
'mt-3 flex w-fit flex-col gap-2',
|
|
||||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type SourceProps = ComponentProps<'a'>;
|
|
||||||
|
|
||||||
export const Source = ({ href, title, children, ...props }: SourceProps) => (
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
href={href}
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? (
|
|
||||||
<>
|
|
||||||
<BookIcon className="h-4 w-4" />
|
|
||||||
<span className="block font-medium">{title}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
);
|
|
@ -1,56 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
ScrollArea,
|
|
||||||
ScrollBar,
|
|
||||||
} from '@/components/ui/scroll-area';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { ComponentProps } from 'react';
|
|
||||||
|
|
||||||
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
|
|
||||||
|
|
||||||
export const Suggestions = ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: SuggestionsProps) => (
|
|
||||||
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
|
|
||||||
<div className={cn('flex w-max flex-nowrap items-center gap-2', className)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
<ScrollBar className="hidden" orientation="horizontal" />
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type SuggestionProps = Omit<ComponentProps<typeof Button>, 'onClick'> & {
|
|
||||||
suggestion: string;
|
|
||||||
onClick?: (suggestion: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Suggestion = ({
|
|
||||||
suggestion,
|
|
||||||
onClick,
|
|
||||||
className,
|
|
||||||
variant = 'outline',
|
|
||||||
size = 'sm',
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: SuggestionProps) => {
|
|
||||||
const handleClick = () => {
|
|
||||||
onClick?.(suggestion);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={cn('cursor-pointer rounded-full px-4', className)}
|
|
||||||
onClick={handleClick}
|
|
||||||
size={size}
|
|
||||||
type="button"
|
|
||||||
variant={variant}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children || suggestion}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,94 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { ChevronDownIcon, SearchIcon } from 'lucide-react';
|
|
||||||
import type { ComponentProps } from 'react';
|
|
||||||
|
|
||||||
export type TaskItemFileProps = ComponentProps<'div'>;
|
|
||||||
|
|
||||||
export const TaskItemFile = ({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: TaskItemFileProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type TaskItemProps = ComponentProps<'div'>;
|
|
||||||
|
|
||||||
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
|
|
||||||
<div className={cn('text-muted-foreground text-sm', className)} {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type TaskProps = ComponentProps<typeof Collapsible>;
|
|
||||||
|
|
||||||
export const Task = ({
|
|
||||||
defaultOpen = true,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: TaskProps) => (
|
|
||||||
<Collapsible
|
|
||||||
className={cn(
|
|
||||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
defaultOpen={defaultOpen}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TaskTrigger = ({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
title,
|
|
||||||
...props
|
|
||||||
}: TaskTriggerProps) => (
|
|
||||||
<CollapsibleTrigger asChild className={cn('group', className)} {...props}>
|
|
||||||
{children ?? (
|
|
||||||
<div className="flex cursor-pointer items-center gap-2 text-muted-foreground hover:text-foreground">
|
|
||||||
<SearchIcon className="size-4" />
|
|
||||||
<p className="text-sm">{title}</p>
|
|
||||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
|
|
||||||
|
|
||||||
export const TaskContent = ({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: TaskContentProps) => (
|
|
||||||
<CollapsibleContent
|
|
||||||
className={cn(
|
|
||||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
);
|
|
@ -1,142 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { ToolUIPart } from 'ai';
|
|
||||||
import {
|
|
||||||
CheckCircleIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
CircleIcon,
|
|
||||||
ClockIcon,
|
|
||||||
WrenchIcon,
|
|
||||||
XCircleIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type { ComponentProps, ReactNode } from 'react';
|
|
||||||
import { CodeBlock } from './code-block';
|
|
||||||
|
|
||||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
|
||||||
|
|
||||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
|
||||||
<Collapsible
|
|
||||||
className={cn('not-prose mb-4 w-full rounded-md border', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ToolHeaderProps = {
|
|
||||||
type: ToolUIPart['type'];
|
|
||||||
state: ToolUIPart['state'];
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (status: ToolUIPart['state']) => {
|
|
||||||
const labels = {
|
|
||||||
'input-streaming': 'Pending',
|
|
||||||
'input-available': 'Running',
|
|
||||||
'output-available': 'Completed',
|
|
||||||
'output-error': 'Error',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const icons = {
|
|
||||||
'input-streaming': <CircleIcon className="size-4" />,
|
|
||||||
'input-available': <ClockIcon className="size-4 animate-pulse" />,
|
|
||||||
'output-available': <CheckCircleIcon className="size-4 text-green-600" />,
|
|
||||||
'output-error': <XCircleIcon className="size-4 text-red-600" />,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
|
||||||
{icons[status]}
|
|
||||||
{labels[status]}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ToolHeader = ({
|
|
||||||
className,
|
|
||||||
type,
|
|
||||||
state,
|
|
||||||
...props
|
|
||||||
}: ToolHeaderProps) => (
|
|
||||||
<CollapsibleTrigger
|
|
||||||
className={cn(
|
|
||||||
'flex w-full items-center justify-between gap-4 p-3',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<WrenchIcon className="size-4 text-muted-foreground" />
|
|
||||||
<span className="font-medium text-sm">{type}</span>
|
|
||||||
{getStatusBadge(state)}
|
|
||||||
</div>
|
|
||||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
|
||||||
|
|
||||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
|
||||||
<CollapsibleContent
|
|
||||||
className={cn(
|
|
||||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ToolInputProps = ComponentProps<'div'> & {
|
|
||||||
input: ToolUIPart['input'];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
|
||||||
<div className={cn('space-y-2 overflow-hidden p-4', className)} {...props}>
|
|
||||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
|
||||||
Parameters
|
|
||||||
</h4>
|
|
||||||
<div className="rounded-md bg-muted/50">
|
|
||||||
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ToolOutputProps = ComponentProps<'div'> & {
|
|
||||||
output: ReactNode;
|
|
||||||
errorText: ToolUIPart['errorText'];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ToolOutput = ({
|
|
||||||
className,
|
|
||||||
output,
|
|
||||||
errorText,
|
|
||||||
...props
|
|
||||||
}: ToolOutputProps) => {
|
|
||||||
if (!(output || errorText)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('space-y-2 p-4', className)} {...props}>
|
|
||||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
|
||||||
{errorText ? 'Error' : 'Result'}
|
|
||||||
</h4>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'overflow-x-auto rounded-md text-xs [&_table]:w-full',
|
|
||||||
errorText
|
|
||||||
? 'bg-destructive/10 text-destructive'
|
|
||||||
: 'bg-muted/50 text-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{errorText && <div>{errorText}</div>}
|
|
||||||
{output && <div>{output}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,252 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { ChevronDownIcon } from 'lucide-react';
|
|
||||||
import type { ComponentProps, ReactNode } from 'react';
|
|
||||||
import { createContext, useContext, useState } from 'react';
|
|
||||||
|
|
||||||
export type WebPreviewContextValue = {
|
|
||||||
url: string;
|
|
||||||
setUrl: (url: string) => void;
|
|
||||||
consoleOpen: boolean;
|
|
||||||
setConsoleOpen: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
|
|
||||||
|
|
||||||
const useWebPreview = () => {
|
|
||||||
const context = useContext(WebPreviewContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('WebPreview components must be used within a WebPreview');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WebPreviewProps = ComponentProps<'div'> & {
|
|
||||||
defaultUrl?: string;
|
|
||||||
onUrlChange?: (url: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WebPreview = ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
defaultUrl = '',
|
|
||||||
onUrlChange,
|
|
||||||
...props
|
|
||||||
}: WebPreviewProps) => {
|
|
||||||
const [url, setUrl] = useState(defaultUrl);
|
|
||||||
const [consoleOpen, setConsoleOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleUrlChange = (newUrl: string) => {
|
|
||||||
setUrl(newUrl);
|
|
||||||
onUrlChange?.(newUrl);
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextValue: WebPreviewContextValue = {
|
|
||||||
url,
|
|
||||||
setUrl: handleUrlChange,
|
|
||||||
consoleOpen,
|
|
||||||
setConsoleOpen,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WebPreviewContext.Provider value={contextValue}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex size-full flex-col rounded-lg border bg-card',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</WebPreviewContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WebPreviewNavigationProps = ComponentProps<'div'>;
|
|
||||||
|
|
||||||
export const WebPreviewNavigation = ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: WebPreviewNavigationProps) => (
|
|
||||||
<div
|
|
||||||
className={cn('flex items-center gap-1 border-b p-2', className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
|
|
||||||
tooltip?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WebPreviewNavigationButton = ({
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
tooltip,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: WebPreviewNavigationButtonProps) => (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className="h-8 w-8 p-0 hover:text-foreground"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onClick}
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{tooltip}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
|
|
||||||
|
|
||||||
export const WebPreviewUrl = ({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
onKeyDown,
|
|
||||||
...props
|
|
||||||
}: WebPreviewUrlProps) => {
|
|
||||||
const { url, setUrl } = useWebPreview();
|
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
setUrl(target.value);
|
|
||||||
}
|
|
||||||
onKeyDown?.(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
className="h-8 flex-1 text-sm"
|
|
||||||
onChange={onChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Enter URL..."
|
|
||||||
value={value ?? url}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WebPreviewBodyProps = ComponentProps<'iframe'> & {
|
|
||||||
loading?: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WebPreviewBody = ({
|
|
||||||
className,
|
|
||||||
loading,
|
|
||||||
src,
|
|
||||||
...props
|
|
||||||
}: WebPreviewBodyProps) => {
|
|
||||||
const { url } = useWebPreview();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1">
|
|
||||||
<iframe
|
|
||||||
className={cn('size-full', className)}
|
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
|
|
||||||
src={(src ?? url) || undefined}
|
|
||||||
title="Preview"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
{loading}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WebPreviewConsoleProps = ComponentProps<'div'> & {
|
|
||||||
logs?: Array<{
|
|
||||||
level: 'log' | 'warn' | 'error';
|
|
||||||
message: string;
|
|
||||||
timestamp: Date;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WebPreviewConsole = ({
|
|
||||||
className,
|
|
||||||
logs = [],
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: WebPreviewConsoleProps) => {
|
|
||||||
const { consoleOpen, setConsoleOpen } = useWebPreview();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Collapsible
|
|
||||||
className={cn('border-t bg-muted/50 font-mono text-sm', className)}
|
|
||||||
onOpenChange={setConsoleOpen}
|
|
||||||
open={consoleOpen}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
Console
|
|
||||||
<ChevronDownIcon
|
|
||||||
className={cn(
|
|
||||||
'h-4 w-4 transition-transform duration-200',
|
|
||||||
consoleOpen && 'rotate-180'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent
|
|
||||||
className={cn(
|
|
||||||
'px-4 pb-4',
|
|
||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
|
||||||
{logs.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground">No console output</p>
|
|
||||||
) : (
|
|
||||||
logs.map((log, index) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'text-xs',
|
|
||||||
log.level === 'error' && 'text-destructive',
|
|
||||||
log.level === 'warn' && 'text-yellow-600',
|
|
||||||
log.level === 'log' && 'text-foreground'
|
|
||||||
)}
|
|
||||||
key={`${log.timestamp.getTime()}-${index}`}
|
|
||||||
>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{log.timestamp.toLocaleTimeString()}
|
|
||||||
</span>{' '}
|
|
||||||
{log.message}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
};
|
|
@ -17,7 +17,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
import { LocaleLink } from '@/i18n/navigation';
|
import { LocaleLink } from '@/i18n/navigation';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
|
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@ -45,10 +45,10 @@ export const LoginForm = ({
|
|||||||
const paramCallbackUrl = searchParams.get('callbackUrl');
|
const paramCallbackUrl = searchParams.get('callbackUrl');
|
||||||
// Use prop callback URL or param callback URL if provided, otherwise use the default login redirect
|
// Use prop callback URL or param callback URL if provided, otherwise use the default login redirect
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const defaultCallbackUrl = getUrlWithLocale(DEFAULT_LOGIN_REDIRECT, locale);
|
const defaultCallbackUrl = getUrlWithLocaleInCallbackUrl(
|
||||||
// console.log('login form, propCallbackUrl', propCallbackUrl);
|
DEFAULT_LOGIN_REDIRECT,
|
||||||
// console.log('login form, paramCallbackUrl', paramCallbackUrl);
|
locale
|
||||||
// console.log('login form, defaultCallbackUrl', defaultCallbackUrl);
|
);
|
||||||
const callbackUrl = propCallbackUrl || paramCallbackUrl || defaultCallbackUrl;
|
const callbackUrl = propCallbackUrl || paramCallbackUrl || defaultCallbackUrl;
|
||||||
console.log('login form, callbackUrl', callbackUrl);
|
console.log('login form, callbackUrl', callbackUrl);
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls';
|
||||||
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
|
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react';
|
import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react';
|
||||||
@ -40,10 +40,10 @@ export const RegisterForm = ({
|
|||||||
const paramCallbackUrl = searchParams.get('callbackUrl');
|
const paramCallbackUrl = searchParams.get('callbackUrl');
|
||||||
// Use prop callback URL or param callback URL if provided, otherwise use the default login redirect
|
// Use prop callback URL or param callback URL if provided, otherwise use the default login redirect
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const defaultCallbackUrl = getUrlWithLocale(DEFAULT_LOGIN_REDIRECT, locale);
|
const defaultCallbackUrl = getUrlWithLocaleInCallbackUrl(
|
||||||
// console.log('register form, propCallbackUrl', propCallbackUrl);
|
DEFAULT_LOGIN_REDIRECT,
|
||||||
// console.log('register form, paramCallbackUrl', paramCallbackUrl);
|
locale
|
||||||
// console.log('register form, defaultCallbackUrl', defaultCallbackUrl);
|
);
|
||||||
const callbackUrl = propCallbackUrl || paramCallbackUrl || defaultCallbackUrl;
|
const callbackUrl = propCallbackUrl || paramCallbackUrl || defaultCallbackUrl;
|
||||||
console.log('register form, callbackUrl', callbackUrl);
|
console.log('register form, callbackUrl', callbackUrl);
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import { GoogleIcon } from '@/components/icons/google';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls';
|
||||||
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
|
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
|
||||||
import { Loader2Icon } from 'lucide-react';
|
import { Loader2Icon } from 'lucide-react';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
@ -37,7 +37,10 @@ export const SocialLoginButton = ({
|
|||||||
const paramCallbackUrl = searchParams.get('callbackUrl');
|
const paramCallbackUrl = searchParams.get('callbackUrl');
|
||||||
// Use prop callback URL or param callback URL if provided, otherwise use the default login redirect
|
// Use prop callback URL or param callback URL if provided, otherwise use the default login redirect
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const defaultCallbackUrl = getUrlWithLocale(DEFAULT_LOGIN_REDIRECT, locale);
|
const defaultCallbackUrl = getUrlWithLocaleInCallbackUrl(
|
||||||
|
DEFAULT_LOGIN_REDIRECT,
|
||||||
|
locale
|
||||||
|
);
|
||||||
const callbackUrl = propCallbackUrl || paramCallbackUrl || defaultCallbackUrl;
|
const callbackUrl = propCallbackUrl || paramCallbackUrl || defaultCallbackUrl;
|
||||||
const [isLoading, setIsLoading] = useState<'google' | 'github' | null>(null);
|
const [isLoading, setIsLoading] = useState<'google' | 'github' | null>(null);
|
||||||
console.log('social login button, callbackUrl', callbackUrl);
|
console.log('social login button, callbackUrl', callbackUrl);
|
||||||
|
@ -66,7 +66,7 @@ export default function FeaturesSection() {
|
|||||||
<div className="grid gap-12 sm:px-12 lg:grid-cols-12 lg:gap-24 lg:px-0">
|
<div className="grid gap-12 sm:px-12 lg:grid-cols-12 lg:gap-24 lg:px-0">
|
||||||
<div className="lg:col-span-5 flex flex-col gap-8">
|
<div className="lg:col-span-5 flex flex-col gap-8">
|
||||||
<div className="lg:pr-0 text-left">
|
<div className="lg:pr-0 text-left">
|
||||||
<h3 className="text-3xl font-semibold lg:text-4xl text-foreground leading-normal py-1">
|
<h3 className="text-3xl font-semibold lg:text-4xl text-gradient_indigo-purple leading-normal py-1">
|
||||||
{t('title')}
|
{t('title')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-4 text-muted-foreground">{t('description')}</p>
|
<p className="mt-4 text-muted-foreground">{t('description')}</p>
|
||||||
|
@ -56,13 +56,14 @@ export default function HeroSection() {
|
|||||||
<AnimatedGroup variants={transitionVariants}>
|
<AnimatedGroup variants={transitionVariants}>
|
||||||
<LocaleLink
|
<LocaleLink
|
||||||
href={linkIntroduction}
|
href={linkIntroduction}
|
||||||
className="hover:bg-accent group mx-auto flex w-fit items-center gap-2 rounded-full border p-1 pl-4"
|
className="hover:bg-background group mx-auto flex w-fit items-center gap-4 rounded-full border p-1 pl-4 shadow-md shadow-zinc-950/5 transition-colors duration-300 dark:shadow-zinc-950"
|
||||||
>
|
>
|
||||||
<span className="text-foreground text-sm">
|
<span className="text-foreground text-sm">
|
||||||
{t('introduction')}
|
{t('introduction')}
|
||||||
</span>
|
</span>
|
||||||
|
{/* <span className="dark:border-background block h-4 w-0.5 border-l bg-white dark:bg-zinc-700"></span> */}
|
||||||
|
|
||||||
<div className="size-6 overflow-hidden rounded-full duration-500">
|
<div className="bg-background group-hover:bg-muted size-6 overflow-hidden rounded-full duration-500">
|
||||||
<div className="flex w-12 -translate-x-1/2 duration-500 ease-in-out group-hover:translate-x-0">
|
<div className="flex w-12 -translate-x-1/2 duration-500 ease-in-out group-hover:translate-x-0">
|
||||||
<span className="flex size-6">
|
<span className="flex size-6">
|
||||||
<ArrowRight className="m-auto size-3" />
|
<ArrowRight className="m-auto size-3" />
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { LocaleLink } from '@/i18n/navigation';
|
import { LocaleLink } from '@/i18n/navigation';
|
||||||
|
import { PLACEHOLDER_IMAGE } from '@/lib/constants';
|
||||||
import { formatDate } from '@/lib/formatter';
|
import { formatDate } from '@/lib/formatter';
|
||||||
import { type BlogType, authorSource, categorySource } from '@/lib/source';
|
import { type BlogType, authorSource, categorySource } from '@/lib/source';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { PremiumBadge } from '../premium/premium-badge';
|
|
||||||
import BlogImage from './blog-image';
|
|
||||||
|
|
||||||
interface BlogCardProps {
|
interface BlogCardProps {
|
||||||
locale: string;
|
locale: string;
|
||||||
@ -21,46 +20,56 @@ export default function BlogCard({ locale, post }: BlogCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LocaleLink href={`/blog/${post.slugs}`} className="block h-full">
|
<LocaleLink href={`/blog/${post.slugs}`} className="block h-full">
|
||||||
<div className="group flex flex-col border border-border rounded-lg overflow-hidden h-full transition-all duration-300 ease-in-out hover:border-primary hover:shadow-lg hover:shadow-primary/20">
|
<div className="group flex flex-col border rounded-lg overflow-hidden h-full">
|
||||||
{/* Image container - fixed aspect ratio */}
|
{/* Image container - fixed aspect ratio */}
|
||||||
<div className="group overflow-hidden relative aspect-16/9 w-full">
|
<div className="group overflow-hidden relative aspect-16/9 w-full">
|
||||||
<div className="relative w-full h-full">
|
{image && (
|
||||||
<BlogImage
|
<div className="relative w-full h-full">
|
||||||
src={image}
|
<Image
|
||||||
alt={title || 'image for blog post'}
|
src={image}
|
||||||
title={title || 'image for blog post'}
|
alt={title || 'image for blog post'}
|
||||||
/>
|
title={title || 'image for blog post'}
|
||||||
|
className="object-cover hover:scale-105 transition-transform duration-300"
|
||||||
|
placeholder="blur"
|
||||||
|
blurDataURL={PLACEHOLDER_IMAGE}
|
||||||
|
fill
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Premium badge - top right */}
|
{blogCategories && blogCategories.length > 0 && (
|
||||||
{post.data.premium && (
|
<div className="absolute left-2 bottom-2 opacity-100 transition-opacity duration-300">
|
||||||
<div className="absolute top-2 right-2 z-20">
|
<div className="flex flex-wrap gap-1">
|
||||||
<PremiumBadge size="sm" />
|
{blogCategories.map((category, index) => (
|
||||||
</div>
|
<span
|
||||||
)}
|
key={`${category?.slugs[0]}-${index}`}
|
||||||
|
className="text-xs font-medium text-white bg-black/50 bg-opacity-50 px-2 py-1 rounded-md"
|
||||||
{/* categories */}
|
>
|
||||||
{blogCategories && blogCategories.length > 0 && (
|
{category?.data.name}
|
||||||
<div className="absolute left-2 bottom-2 opacity-100 transition-opacity duration-300 z-20">
|
</span>
|
||||||
<div className="flex flex-wrap gap-1">
|
))}
|
||||||
{blogCategories.map((category, index) => (
|
</div>
|
||||||
<span
|
|
||||||
key={`${category?.slugs[0]}-${index}`}
|
|
||||||
className="text-xs font-medium text-white bg-black/50 bg-opacity-50 px-2 py-1 rounded-md"
|
|
||||||
>
|
|
||||||
{category?.data.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Post info container */}
|
{/* Post info container */}
|
||||||
<div className="flex flex-col justify-between p-4 flex-1">
|
<div className="flex flex-col justify-between p-4 flex-1">
|
||||||
<div>
|
<div>
|
||||||
{/* Post title */}
|
{/* Post title */}
|
||||||
<h3 className="text-lg line-clamp-2 font-medium">{title}</h3>
|
<h3 className="text-lg line-clamp-2 font-medium">
|
||||||
|
<span
|
||||||
|
className="bg-linear-to-r from-green-200 to-green-100
|
||||||
|
bg-[length:0px_10px] bg-left-bottom bg-no-repeat
|
||||||
|
transition-[background-size]
|
||||||
|
duration-500
|
||||||
|
hover:bg-[length:100%_3px]
|
||||||
|
group-hover:bg-[length:100%_10px]
|
||||||
|
dark:from-purple-800 dark:to-purple-900"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
{/* Post excerpt */}
|
{/* Post excerpt */}
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@ -102,7 +111,12 @@ export function BlogCardSkeleton() {
|
|||||||
return (
|
return (
|
||||||
<div className="border border-gray-200 dark:border-gray-800 rounded-lg overflow-hidden h-full">
|
<div className="border border-gray-200 dark:border-gray-800 rounded-lg overflow-hidden h-full">
|
||||||
<div className="overflow-hidden relative aspect-16/9 w-full">
|
<div className="overflow-hidden relative aspect-16/9 w-full">
|
||||||
<Skeleton className="h-full w-full rounded-b-none" />
|
<Image
|
||||||
|
src={PLACEHOLDER_IMAGE}
|
||||||
|
alt="Loading placeholder"
|
||||||
|
className="object-cover"
|
||||||
|
fill
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 flex flex-col justify-between flex-1">
|
<div className="p-4 flex flex-col justify-between flex-1">
|
||||||
<div>
|
<div>
|
||||||
|
@ -1,40 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
interface BlogImageProps {
|
|
||||||
src: string;
|
|
||||||
alt: string;
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BlogImage({ src, alt, title }: BlogImageProps) {
|
|
||||||
const [imageLoading, setImageLoading] = useState(true);
|
|
||||||
|
|
||||||
const handleImageLoad = () => {
|
|
||||||
setImageLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full">
|
|
||||||
{/* loading skeleton */}
|
|
||||||
{imageLoading && (
|
|
||||||
<Skeleton className="absolute inset-0 h-full w-full rounded-b-none z-10" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* actual image */}
|
|
||||||
<Image
|
|
||||||
src={src}
|
|
||||||
alt={alt}
|
|
||||||
title={title || alt}
|
|
||||||
className={`object-cover hover:scale-105 transition-transform duration-300 ${
|
|
||||||
imageLoading ? 'opacity-0' : 'opacity-100'
|
|
||||||
}`}
|
|
||||||
fill
|
|
||||||
onLoad={handleImageLoad}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -12,7 +12,7 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { useSidebarLinks } from '@/config/sidebar-config';
|
import { getSidebarLinks } from '@/config/sidebar-config';
|
||||||
import { LocaleLink } from '@/i18n/navigation';
|
import { LocaleLink } from '@/i18n/navigation';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { Routes } from '@/routes';
|
import { Routes } from '@/routes';
|
||||||
@ -35,7 +35,7 @@ export function DashboardSidebar({
|
|||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
// console.log('sidebar currentUser:', currentUser);
|
// console.log('sidebar currentUser:', currentUser);
|
||||||
|
|
||||||
const sidebarLinks = useSidebarLinks();
|
const sidebarLinks = getSidebarLinks();
|
||||||
const filteredSidebarLinks = sidebarLinks.filter((link) => {
|
const filteredSidebarLinks = sidebarLinks.filter((link) => {
|
||||||
if (link.authorizeOnly) {
|
if (link.authorizeOnly) {
|
||||||
return link.authorizeOnly.includes(currentUser?.role || '');
|
return link.authorizeOnly.includes(currentUser?.role || '');
|
||||||
|
@ -71,7 +71,7 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const showModeSwitch = websiteConfig.ui.mode?.enableSwitch ?? false;
|
const showModeSwitch = websiteConfig.metadata.mode?.enableSwitch ?? false;
|
||||||
const showLocaleSwitch = LOCALES.length > 1;
|
const showLocaleSwitch = LOCALES.length > 1;
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
|
@ -35,13 +35,7 @@ export function UpgradeCard() {
|
|||||||
const isMember =
|
const isMember =
|
||||||
paymentData?.currentPlan?.isLifetime || !!paymentData?.subscription;
|
paymentData?.currentPlan?.isLifetime || !!paymentData?.subscription;
|
||||||
|
|
||||||
// Ensure the upgrade card is only shown when the data is loaded
|
if (!mounted || isLoading || isMember) {
|
||||||
if (!mounted || isLoading || !paymentData) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the user is a member, don't show the upgrade card
|
|
||||||
if (isMember) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { ImageWrapper } from '@/components/docs/image-wrapper';
|
import { ImageWrapper } from '@/components/docs/image-wrapper';
|
||||||
import { Wrapper } from '@/components/docs/wrapper';
|
import { Wrapper } from '@/components/docs/wrapper';
|
||||||
import { YoutubeVideo } from '@/components/docs/youtube-video';
|
import { YoutubeVideo } from '@/components/docs/youtube-video';
|
||||||
import { PremiumContent } from '@/components/premium/premium-content';
|
|
||||||
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||||
import { Callout } from 'fumadocs-ui/components/callout';
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
import { File, Files, Folder } from 'fumadocs-ui/components/files';
|
import { File, Files, Folder } from 'fumadocs-ui/components/files';
|
||||||
@ -24,7 +23,6 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
|
|||||||
...LucideIcons,
|
...LucideIcons,
|
||||||
// ...((await import('lucide-react')) as unknown as MDXComponents),
|
// ...((await import('lucide-react')) as unknown as MDXComponents),
|
||||||
YoutubeVideo,
|
YoutubeVideo,
|
||||||
PremiumContent,
|
|
||||||
Tabs,
|
Tabs,
|
||||||
Tab,
|
Tab,
|
||||||
TypeTable,
|
TypeTable,
|
||||||
|
@ -10,7 +10,7 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
const COOKIE_NAME = 'active_theme';
|
const COOKIE_NAME = 'active_theme';
|
||||||
const DEFAULT_THEME = websiteConfig.ui.theme?.defaultTheme ?? 'default';
|
const DEFAULT_THEME = websiteConfig.metadata.theme?.defaultTheme ?? 'default';
|
||||||
|
|
||||||
function setThemeCookie(theme: string) {
|
function setThemeCookie(theme: string) {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
@ -4,8 +4,8 @@ import Container from '@/components/layout/container';
|
|||||||
import { Logo } from '@/components/layout/logo';
|
import { Logo } from '@/components/layout/logo';
|
||||||
import { ModeSwitcherHorizontal } from '@/components/layout/mode-switcher-horizontal';
|
import { ModeSwitcherHorizontal } from '@/components/layout/mode-switcher-horizontal';
|
||||||
import BuiltWithButton from '@/components/shared/built-with-button';
|
import BuiltWithButton from '@/components/shared/built-with-button';
|
||||||
import { useFooterLinks } from '@/config/footer-config';
|
import { getFooterLinks } from '@/config/footer-config';
|
||||||
import { useSocialLinks } from '@/config/social-config';
|
import { getSocialLinks } from '@/config/social-config';
|
||||||
import { LocaleLink } from '@/i18n/navigation';
|
import { LocaleLink } from '@/i18n/navigation';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
@ -13,8 +13,8 @@ import type React from 'react';
|
|||||||
|
|
||||||
export function Footer({ className }: React.HTMLAttributes<HTMLElement>) {
|
export function Footer({ className }: React.HTMLAttributes<HTMLElement>) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const footerLinks = useFooterLinks();
|
const footerLinks = getFooterLinks();
|
||||||
const socialLinks = useSocialLinks();
|
const socialLinks = getSocialLinks();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className={cn('border-t', className)}>
|
<footer className={cn('border-t', className)}>
|
||||||
|
@ -43,7 +43,7 @@ export function HeaderSection({
|
|||||||
{title ? (
|
{title ? (
|
||||||
<TitleComponent
|
<TitleComponent
|
||||||
className={cn(
|
className={cn(
|
||||||
'uppercase tracking-wider text-primary font-semibold font-mono',
|
'uppercase tracking-wider text-gradient_indigo-purple font-semibold font-mono',
|
||||||
titleClassName
|
titleClassName
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -12,7 +12,7 @@ import { useEffect, useState } from 'react';
|
|||||||
* Mode switcher component, used in the footer
|
* Mode switcher component, used in the footer
|
||||||
*/
|
*/
|
||||||
export function ModeSwitcherHorizontal() {
|
export function ModeSwitcherHorizontal() {
|
||||||
if (!websiteConfig.ui.mode?.enableSwitch) {
|
if (!websiteConfig.metadata.mode?.enableSwitch) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ import { useTheme } from 'next-themes';
|
|||||||
* Mode switcher component, used in the navbar
|
* Mode switcher component, used in the navbar
|
||||||
*/
|
*/
|
||||||
export function ModeSwitcher() {
|
export function ModeSwitcher() {
|
||||||
if (!websiteConfig.ui.mode?.enableSwitch) {
|
if (!websiteConfig.metadata.mode?.enableSwitch) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from '@/components/ui/collapsible';
|
} from '@/components/ui/collapsible';
|
||||||
import { useNavbarLinks } from '@/config/navbar-config';
|
import { getNavbarLinks } from '@/config/navbar-config';
|
||||||
import { LocaleLink, useLocalePathname } from '@/i18n/navigation';
|
import { LocaleLink, useLocalePathname } from '@/i18n/navigation';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -146,7 +146,7 @@ interface MainMobileMenuProps {
|
|||||||
function MainMobileMenu({ userLoggedIn, onLinkClicked }: MainMobileMenuProps) {
|
function MainMobileMenu({ userLoggedIn, onLinkClicked }: MainMobileMenuProps) {
|
||||||
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
|
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const menuLinks = useNavbarLinks();
|
const menuLinks = getNavbarLinks();
|
||||||
const localePathname = useLocalePathname();
|
const localePathname = useLocalePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -16,7 +16,7 @@ import {
|
|||||||
NavigationMenuTrigger,
|
NavigationMenuTrigger,
|
||||||
navigationMenuTriggerStyle,
|
navigationMenuTriggerStyle,
|
||||||
} from '@/components/ui/navigation-menu';
|
} from '@/components/ui/navigation-menu';
|
||||||
import { useNavbarLinks } from '@/config/navbar-config';
|
import { getNavbarLinks } from '@/config/navbar-config';
|
||||||
import { useScroll } from '@/hooks/use-scroll';
|
import { useScroll } from '@/hooks/use-scroll';
|
||||||
import { LocaleLink, useLocalePathname } from '@/i18n/navigation';
|
import { LocaleLink, useLocalePathname } from '@/i18n/navigation';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
@ -37,14 +37,14 @@ const customNavigationMenuTriggerStyle = cn(
|
|||||||
'relative bg-transparent text-muted-foreground cursor-pointer',
|
'relative bg-transparent text-muted-foreground cursor-pointer',
|
||||||
'hover:bg-accent hover:text-accent-foreground',
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
'focus:bg-accent focus:text-accent-foreground',
|
'focus:bg-accent focus:text-accent-foreground',
|
||||||
'data-active:font-semibold data-active:bg-transparent data-active:text-accent-foreground',
|
'data-active:font-semibold data-active:bg-transparent data-active:text-foreground',
|
||||||
'data-[state=open]:bg-transparent data-[state=open]:text-accent-foreground'
|
'data-[state=open]:bg-transparent data-[state=open]:text-foreground'
|
||||||
);
|
);
|
||||||
|
|
||||||
export function Navbar({ scroll }: NavBarProps) {
|
export function Navbar({ scroll }: NavBarProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const scrolled = useScroll(50);
|
const scrolled = useScroll(50);
|
||||||
const menuLinks = useNavbarLinks();
|
const menuLinks = getNavbarLinks();
|
||||||
const localePathname = useLocalePathname();
|
const localePathname = useLocalePathname();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const { data: session, isPending } = authClient.useSession();
|
const { data: session, isPending } = authClient.useSession();
|
||||||
@ -132,10 +132,10 @@ export function Navbar({ scroll }: NavBarProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex size-8 shrink-0 items-center justify-center transition-colors',
|
'flex size-8 shrink-0 items-center justify-center transition-colors',
|
||||||
'bg-transparent text-muted-foreground',
|
'bg-transparent text-muted-foreground',
|
||||||
'group-hover:bg-transparent group-hover:text-accent-foreground',
|
'group-hover:bg-transparent group-hover:text-foreground',
|
||||||
'group-focus:bg-transparent group-focus:text-accent-foreground',
|
'group-focus:bg-transparent group-focus:text-foreground',
|
||||||
isSubItemActive &&
|
isSubItemActive &&
|
||||||
'bg-transparent text-accent-foreground'
|
'bg-transparent text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{subItem.icon ? subItem.icon : null}
|
{subItem.icon ? subItem.icon : null}
|
||||||
@ -144,10 +144,10 @@ export function Navbar({ scroll }: NavBarProps) {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-sm font-medium text-muted-foreground',
|
'text-sm font-medium text-muted-foreground',
|
||||||
'group-hover:bg-transparent group-hover:text-accent-foreground',
|
'group-hover:bg-transparent group-hover:text-foreground',
|
||||||
'group-focus:bg-transparent group-focus:text-accent-foreground',
|
'group-focus:bg-transparent group-focus:text-foreground',
|
||||||
isSubItemActive &&
|
isSubItemActive &&
|
||||||
'bg-transparent text-accent-foreground'
|
'bg-transparent text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{subItem.title}
|
{subItem.title}
|
||||||
@ -156,10 +156,10 @@ export function Navbar({ scroll }: NavBarProps) {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-sm text-muted-foreground',
|
'text-sm text-muted-foreground',
|
||||||
'group-hover:bg-transparent group-hover:text-accent-foreground/80',
|
'group-hover:bg-transparent group-hover:text-foreground/80',
|
||||||
'group-focus:bg-transparent group-focus:text-accent-foreground/80',
|
'group-focus:bg-transparent group-focus:text-foreground/80',
|
||||||
isSubItemActive &&
|
isSubItemActive &&
|
||||||
'bg-transparent text-accent-foreground/80'
|
'bg-transparent text-foreground/80'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{subItem.description}
|
{subItem.description}
|
||||||
@ -170,10 +170,10 @@ export function Navbar({ scroll }: NavBarProps) {
|
|||||||
<ArrowUpRightIcon
|
<ArrowUpRightIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-4 shrink-0 text-muted-foreground',
|
'size-4 shrink-0 text-muted-foreground',
|
||||||
'group-hover:bg-transparent group-hover:text-accent-foreground',
|
'group-hover:bg-transparent group-hover:text-foreground',
|
||||||
'group-focus:bg-transparent group-focus:text-accent-foreground',
|
'group-focus:bg-transparent group-focus:text-foreground',
|
||||||
isSubItemActive &&
|
isSubItemActive &&
|
||||||
'bg-transparent text-accent-foreground'
|
'bg-transparent text-foreground'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -21,7 +21,7 @@ import { useThemeConfig } from './active-theme-provider';
|
|||||||
* https://github.com/TheOrcDev/orcish-dashboard/blob/main/components/theme-selector.tsx
|
* https://github.com/TheOrcDev/orcish-dashboard/blob/main/components/theme-selector.tsx
|
||||||
*/
|
*/
|
||||||
export function ThemeSelector() {
|
export function ThemeSelector() {
|
||||||
if (!websiteConfig.ui.theme?.enableSwitch) {
|
if (!websiteConfig.metadata.theme?.enableSwitch) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ import {
|
|||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
DrawerTrigger,
|
DrawerTrigger,
|
||||||
} from '@/components/ui/drawer';
|
} from '@/components/ui/drawer';
|
||||||
import { useAvatarLinks } from '@/config/avatar-config';
|
import { getAvatarLinks } from '@/config/avatar-config';
|
||||||
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
|
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
import type { User } from 'better-auth';
|
import type { User } from 'better-auth';
|
||||||
@ -25,7 +25,7 @@ interface UserButtonProps {
|
|||||||
|
|
||||||
export function UserButtonMobile({ user }: UserButtonProps) {
|
export function UserButtonMobile({ user }: UserButtonProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const avatarLinks = useAvatarLinks();
|
const avatarLinks = getAvatarLinks();
|
||||||
const localeRouter = useLocaleRouter();
|
const localeRouter = useLocaleRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const closeDrawer = () => {
|
const closeDrawer = () => {
|
||||||
|
@ -8,7 +8,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useAvatarLinks } from '@/config/avatar-config';
|
import { getAvatarLinks } from '@/config/avatar-config';
|
||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
import { useLocaleRouter } from '@/i18n/navigation';
|
import { useLocaleRouter } from '@/i18n/navigation';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
@ -25,7 +25,7 @@ interface UserButtonProps {
|
|||||||
|
|
||||||
export function UserButton({ user }: UserButtonProps) {
|
export function UserButton({ user }: UserButtonProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const avatarLinks = useAvatarLinks();
|
const avatarLinks = getAvatarLinks();
|
||||||
const localeRouter = useLocaleRouter();
|
const localeRouter = useLocaleRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
|
@ -1,47 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { CrownIcon } from 'lucide-react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
|
|
||||||
interface PremiumBadgeProps {
|
|
||||||
className?: string;
|
|
||||||
variant?: 'default' | 'outline' | 'secondary';
|
|
||||||
size?: 'sm' | 'default' | 'lg';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PremiumBadge({
|
|
||||||
className,
|
|
||||||
variant = 'default',
|
|
||||||
size = 'default',
|
|
||||||
}: PremiumBadgeProps) {
|
|
||||||
const t = useTranslations('Common');
|
|
||||||
|
|
||||||
const sizeClasses = {
|
|
||||||
sm: 'text-xs h-5',
|
|
||||||
default: 'text-xs h-6',
|
|
||||||
lg: 'text-sm h-7',
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconSizes = {
|
|
||||||
sm: 'size-3',
|
|
||||||
default: 'size-3',
|
|
||||||
lg: 'size-4',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
variant={variant}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1 font-medium',
|
|
||||||
'bg-orange-400 text-white border-0',
|
|
||||||
sizeClasses[size],
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CrownIcon className={iconSizes[size]} />
|
|
||||||
{t('premium')}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user