Compare commits

..

1 Commits

Author SHA1 Message Date
javayhu
e78a992dd6 chore: vercel ai gateway demo 2025-08-22 10:20:55 +08:00
148 changed files with 1837 additions and 16285 deletions

View File

@ -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
View File

@ -41,9 +41,6 @@ certificates
# claude code # claude code
.claude .claude
# conductor
.conductor
# kiro # kiro
.kiro .kiro

View File

@ -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
} }
} }

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1 +0,0 @@
NEXTJS_ENV=development

View File

@ -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=""

View File

@ -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"
} }
} }

View File

@ -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": "正在开发中"
} }
} }

View File

@ -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();

View File

@ -1,6 +0,0 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig({
});

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +0,0 @@
/_next/static/*
Cache-Control: public,max-age=31536000,immutable

129
public/sw.js Normal file
View 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
View 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);

View File

@ -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(),
}), }),

View 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',
};
}
}
);

View File

@ -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,
}; };

View File

@ -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',
};
}
} }
); );

View File

@ -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,
}, },
}, },
}; };

View File

@ -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);

View File

@ -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>
);
}

View File

@ -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

View 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>
);
}

View File

@ -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',

View File

@ -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';

View File

@ -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>

View File

@ -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;

View File

@ -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 },

View File

@ -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' &&

View File

@ -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;

View File

@ -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>
); );

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
); );
} }

View File

@ -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">

View File

@ -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>

View 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>
</>
);
}

View 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} />
))}
</>
);
}

View File

@ -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>

View 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" />;
}

View 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" />;
}

View 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" />;
}

View 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" />;
}

View 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" />;
}

View 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" />;
}

View 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" />;
}

View File

@ -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>
); );

View File

@ -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>
); );

View File

@ -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

View File

@ -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;

View File

@ -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,
});
}

View File

@ -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,
});
}
}}
/> />
); );
} }

View File

@ -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>
)} )}

View File

@ -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;
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
)
);
};

View File

@ -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}`}
/>
);

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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} />
);

View File

@ -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';

View File

@ -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';

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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>

View File

@ -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" />

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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 || '');

View File

@ -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 () => {

View File

@ -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;
} }

View File

@ -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,

View File

@ -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;

View File

@ -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)}>

View File

@ -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
)} )}
> >

View File

@ -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;
} }

View File

@ -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;
} }

View File

@ -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 (

View File

@ -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'
)} )}
/> />
)} )}

View File

@ -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;
} }

View File

@ -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 = () => {

View File

@ -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 () => {

View File

@ -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