Compare commits

..

No commits in common. "cloudflare" and "dev/credits-v2" have entirely different histories.

218 changed files with 5767 additions and 24979 deletions

View File

@ -1,7 +1,4 @@
.cursor
.claude
.conductor
.kiro
.github
.next
.open-next
@ -13,4 +10,4 @@
node_modules
**/node_modules
Dockerfile
LICENSE
LICENSE

41
.gitattributes vendored
View File

@ -1,41 +0,0 @@
# Set default behavior to automatically normalize line endings
* text=auto
# Force LF line endings for text files
*.js text eol=lf
*.jsx text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.json text eol=lf
*.md text eol=lf
*.mdx text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.html text eol=lf
*.xml text eol=lf
*.txt text eol=lf
*.sh text eol=lf
# Ensure these files are always treated as text and get LF line endings
.gitignore text eol=lf
.gitattributes text eol=lf
.editorconfig text eol=lf
*.config.js text eol=lf
*.config.ts text eol=lf
# Binary files should be left untouched
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.svg binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.pdf binary
*.zip binary
*.tar.gz binary

6
.gitignore vendored
View File

@ -41,12 +41,6 @@ certificates
# claude code
.claude
# conductor
.conductor
# kiro
.kiro
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@ -4,7 +4,6 @@
"bradlc.vscode-tailwindcss",
"Lokalise.i18n-ally",
"unifiedjs.vscode-mdx",
"eamodio.gitlens",
"editorconfig.editorconfig"
"eamodio.gitlens"
]
}

10
.vscode/settings.json vendored
View File

@ -24,12 +24,6 @@
".next": true,
".source": true,
".wrangler": true,
".open-next": true,
".vscode": true,
".cursor": true,
".claude": true,
".conductor": true,
".kiro": true,
".github": true
".open-next": 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)
- 🗓️ roadmap: [mksaas roadmap](https://mksaas.link/roadmap)
- 👨‍💻 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

View File

@ -9,12 +9,7 @@
"ignoreUnknown": true,
"ignore": [
".next/**",
".open-next/**",
".wrangler/**",
".cursor/**",
".claude/**",
".kiro/**",
".conductor/**",
".vscode/**",
".source/**",
"node_modules/**",
@ -26,11 +21,11 @@
"src/components/magicui/*.tsx",
"src/components/animate-ui/*.tsx",
"src/components/tailark/*.tsx",
"src/components/ai-elements/*.tsx",
"src/app/[[]locale]/preview/**",
"src/payment/types.ts",
"src/credits/types.ts",
"src/types/index.d.ts"
"src/types/index.d.ts",
"public/sw.js"
]
},
"formatter": {
@ -38,8 +33,7 @@
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80,
"formatWithErrors": true,
"useEditorconfig": true
"formatWithErrors": true
},
"organizeImports": {
"enabled": true
@ -74,12 +68,7 @@
},
"ignore": [
".next/**",
".open-next/**",
".wrangler/**",
".cursor/**",
".claude/**",
".conductor/**",
".kiro/**",
".vscode/**",
".source/**",
"node_modules/**",
@ -91,11 +80,11 @@
"src/components/magicui/*.tsx",
"src/components/animate-ui/*.tsx",
"src/components/tailark/*.tsx",
"src/components/ai-elements/*.tsx",
"src/app/[[]locale]/preview/**",
"src/payment/types.ts",
"src/credits/types.ts",
"src/types/index.d.ts"
"src/types/index.d.ts",
"public/sw.js"
]
},
"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
description: Introducing Fumadocs, a docs framework that you can break.
icon: CircleHelp
premium: true
---
Fumadocs was created because I wanted a more customisable experience for building docs, to be a docs framework that is not opinionated, **a "framework" that you can break**.
@ -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.
Instead, we use a much more flexible approach inspired by Shadcn UI — [Fumadocs CLI](/docs/cli), so we can iterate our design quick, and welcome for more feedback about the UI.
<PremiumContent>
## Why Fumadocs
Fumadocs is designed with flexibility in mind.
@ -59,5 +56,3 @@ docs easier, with less boilerplate.
Fumadocs is maintained by Fuma and many contributors, with care on the maintainability of codebase.
While we don't aim to offer every functionality people wanted, we're more focused on making basic features perfect and well-maintained.
You can also help Fumadocs to be more useful by contributing!
</PremiumContent>

View File

@ -2,7 +2,6 @@
title: 什么是 Fumadocs
description: 介绍 Fumadocs一个可以打破常规的文档框架
icon: CircleHelp
premium: true
---
Fumadocs 的创建是因为我想要一种更加可定制化的文档构建体验,一个不固执己见的文档框架,**一个你可以"打破"的"框架"**。
@ -19,8 +18,6 @@ Fumadocs 的创建是因为我想要一种更加可定制化的文档构建体
**对 UI 有自己的看法:** Fumadocs UI默认主题提供的唯一东西是**用户界面**。UI 的设计理念是提供更好的移动响应性和用户体验。
相反,我们使用受 Shadcn UI 启发的更灵活的方法 — [Fumadocs CLI](/docs/cli),这样我们可以快速迭代设计,并欢迎更多关于 UI 的反馈。
<PremiumContent>
## 为什么选择 Fumadocs
Fumadocs 的设计考虑了灵活性。
@ -56,6 +53,4 @@ Fumadocs 为 Next.js 提供了额外的工具,包括语法高亮、文档搜
Fumadocs 由 Fuma 和许多贡献者维护,关注代码库的可维护性。
虽然我们不打算提供人们想要的每一项功能,但我们更专注于使基本功能完美且维护良好。
您也可以通过贡献来帮助 Fumadocs 变得更加有用!
</PremiumContent>
您也可以通过贡献来帮助 Fumadocs 变得更加有用!

View File

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

View File

@ -8,13 +8,13 @@ NEXT_PUBLIC_BASE_URL="http://localhost:3000"
# -----------------------------------------------------------------------------
# Database
# https://mksaas.com/docs/database
# https://mksaas.com/docs/database#setup
# -----------------------------------------------------------------------------
DATABASE_URL=""
# -----------------------------------------------------------------------------
# Better Auth
# https://mksaas.com/docs/auth
# https://mksaas.com/docs/auth#setup
# Generate a random string for the secret key using `openssl rand -base64 32`
# -----------------------------------------------------------------------------
BETTER_AUTH_SECRET=""
@ -39,8 +39,8 @@ GOOGLE_CLIENT_SECRET=""
# -----------------------------------------------------------------------------
# Email / Newsletter (Resend)
# https://mksaas.com/docs/email
# https://mksaas.com/docs/newsletter
# https://mksaas.com/docs/email#setup
# https://mksaas.com/docs/newsletter#setup
# Get API key and audience id from https://resend.com
# -----------------------------------------------------------------------------
RESEND_API_KEY=""
@ -48,7 +48,7 @@ RESEND_AUDIENCE_ID=""
# -----------------------------------------------------------------------------
# Storage (Cloudflare R2 or S3-compatible service of your choice)
# https://mksaas.com/docs/storage
# https://mksaas.com/docs/storage#setup
# Cloudflare R2: https://www.cloudflare.com/developer-platform/products/r2
# -----------------------------------------------------------------------------
STORAGE_REGION="auto"
@ -60,9 +60,10 @@ STORAGE_PUBLIC_URL=""
# -----------------------------------------------------------------------------
# Payment (Stripe)
# https://mksaas.com/docs/payment
# https://mksaas.com/docs/payment#setup
# Get Stripe key and secret from https://dashboard.stripe.com
# -----------------------------------------------------------------------------
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=""
STRIPE_SECRET_KEY=""
STRIPE_WEBHOOK_SECRET=""
# Pro plan - monthly subscription
@ -84,16 +85,13 @@ NEXT_PUBLIC_STRIPE_PRICE_CREDITS_ENTERPRISE=""
# Configurations
# -----------------------------------------------------------------------------
# Disable image optimization, check out next.config.ts for more details
# -----------------------------------------------------------------------------
DISABLE_IMAGE_OPTIMIZATION=false
# -----------------------------------------------------------------------------
# Run this website as demo website, in most cases, you should set this to false
# -----------------------------------------------------------------------------
NEXT_PUBLIC_DEMO_WEBSITE=false
# -----------------------------------------------------------------------------
# Analytics
# https://mksaas.com/docs/analytics
# https://mksaas.com/docs/analytics#setup
# -----------------------------------------------------------------------------
# Google Analytics (https://analytics.google.com)
# https://mksaas.com/docs/analytics#google
@ -159,39 +157,22 @@ NEXT_PUBLIC_AFFILIATE_PROMOTEKIT_ID=""
# -----------------------------------------------------------------------------
# Captcha (Cloudflare Turnstile)
# https://mksaas.com/docs/captcha
# https://mksaas.com/docs/captcha#setup
# -----------------------------------------------------------------------------
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
TURNSTILE_SECRET_KEY=""
# -----------------------------------------------------------------------------
# Crisp
# https://mksaas.com/docs/chat
# Inngest
# https://mksaas.com/docs/jobs#setup
# -----------------------------------------------------------------------------
NEXT_PUBLIC_CRISP_WEBSITE_ID=""
# -----------------------------------------------------------------------------
# Cron Jobs
# https://mksaas.com/docs/cronjobs
# -----------------------------------------------------------------------------
CRON_JOBS_USERNAME=""
CRON_JOBS_PASSWORD=""
INNGEST_SIGNING_KEY=""
# -----------------------------------------------------------------------------
# AI
# https://mksaas.com/docs/ai
# -----------------------------------------------------------------------------
AI_GATEWAY_API_KEY=""
FAL_API_KEY=""
FIREWORKS_API_KEY=""
OPENAI_API_KEY=""
REPLICATE_API_TOKEN=""
GOOGLE_GENERATIVE_AI_API_KEY=""
DEEPSEEK_API_KEY=""
OPENROUTER_API_KEY=""
# -----------------------------------------------------------------------------
# Web Content Analyzer (Firecrawl)
# https://firecrawl.dev/
# -----------------------------------------------------------------------------
FIRECRAWL_API_KEY=""

View File

@ -5,7 +5,6 @@
"description": "MkSaaS is the best AI SaaS boilerplate. Make AI SaaS in days, simply and effortlessly"
},
"Common": {
"premium": "Premium",
"login": "Log in",
"logout": "Log out",
"signUp": "Sign up",
@ -220,9 +219,7 @@
"hidePassword": "Hide password",
"or": "Or continue with",
"emailRequired": "Please enter your email",
"passwordRequired": "Please enter your password",
"captchaInvalid": "Captcha verification failed",
"captchaError": "Captcha verification error"
"passwordRequired": "Please enter your password"
},
"register": {
"title": "Register",
@ -293,20 +290,8 @@
"nextPage": "Next",
"chooseLanguage": "Select language",
"title": "MkSaaS Docs",
"homepage": "Homepage"
},
"PremiumContent": {
"title": "Unlock Premium Content",
"description": "Subscribe to our Pro plan to access all premium content and exclusive content.",
"upgradeCta": "Upgrade Now",
"benefit1": "All premium content",
"benefit2": "Exclusive content",
"benefit3": "Cancel anytime",
"signIn": "Sign In",
"loginRequired": "Sign in to continue reading",
"loginDescription": "This is premium content. Sign in to your account to access the full content.",
"checkingAccess": "Checking access...",
"loadingContent": "Loading full content..."
"homepage": "Homepage",
"blog": "Blog"
},
"Marketing": {
"navbar": {
@ -333,10 +318,6 @@
"title": "AI Image",
"description": "Show how to use AI to generate beautiful images"
},
"chat": {
"title": "AI Chat",
"description": "Show how to use AI to chat with your customers"
},
"video": {
"title": "AI Video",
"description": "Show how to use AI to generate amazing videos"
@ -591,7 +572,7 @@
},
"price": "Price:",
"periodStartDate": "Period start date:",
"periodEndDate": "Period end date:",
"nextBillingDate": "Next billing date:",
"trialEnds": "Trial ends:",
"freePlanMessage": "You are currently on the free plan with limited features",
"lifetimeMessage": "You have lifetime access to all premium features",
@ -605,10 +586,6 @@
"credits": {
"title": "Credits",
"description": "Manage your credit transactions",
"tabs": {
"balance": "Balance",
"transactions": "Transactions"
},
"balance": {
"title": "Credit Balance",
"description": "Your credit balance",
@ -617,8 +594,9 @@
"creditsExpired": "Credits expired",
"creditsAdded": "Credits have been added to your account",
"viewTransactions": "View Credit Transactions",
"retry": "Retry",
"expiringCredits": "{credits} credits expiring in the next {days} days"
"subscriptionCredits": "{credits} credits from subscription this month",
"lifetimeCredits": "{credits} credits from lifetime plan this month",
"expiringCredits": "{credits} credits expiring on {date}"
},
"packages": {
"title": "Credit Packages",
@ -1007,68 +985,23 @@
}
},
"AITextPage": {
"title": "AI Text Demo",
"description": "Analyze web content with AI to extract key information, features, and insights",
"content": "Web Content Analyzer",
"subtitle": "Enter a website URL to get AI-powered analysis of its content",
"analyzer": {
"title": "Web Content Analyzer",
"description": "Analyze any website content using AI to extract structured information",
"placeholder": "Enter website URL (e.g., https://example.com)",
"button": "Analyze Website",
"loading": {
"scraping": "Scraping website content...",
"analyzing": "Analyzing content with AI..."
},
"results": {
"title": "Analysis Results",
"newAnalysis": "Analyze Another Website",
"sections": {
"title": "Title",
"description": "Description",
"introduction": "Introduction",
"features": "Features",
"pricing": "Pricing",
"useCases": "Use Cases",
"screenshot": "Website Screenshot"
}
},
"errors": {
"invalidUrl": "Please enter a valid URL starting with http:// or https://",
"analysisError": "Failed to analyze website. Please try again.",
"networkError": "Network error. Please check your connection and try again.",
"insufficientCredits": "Insufficient credits. Please purchase more credits to continue."
}
},
"features": {
"scraping": {
"title": "Smart Web Scraping",
"description": "Advanced web scraping technology extracts clean, structured content from any website"
},
"analysis": {
"title": "AI-Powered Analysis",
"description": "Intelligent AI analysis extracts key insights, features, and structured information"
},
"results": {
"title": "Structured Results",
"description": "Get organized, easy-to-read results with clear sections and actionable insights"
}
}
"title": "AI Text",
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
"content": "Working in progress"
},
"AIImagePage": {
"title": "AI Image",
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
},
"AIChatPage": {
"title": "AI Chat",
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
"content": "Working in progress"
},
"AIVideoPage": {
"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": {
"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简单且毫不费力。"
},
"Common": {
"premium": "付费文章",
"login": "登录",
"logout": "退出",
"signUp": "注册",
@ -220,9 +219,7 @@
"hidePassword": "隐藏密码",
"or": "或以社媒账号登录",
"emailRequired": "请输入邮箱",
"passwordRequired": "请输入密码",
"captchaInvalid": "验证码验证失败",
"captchaError": "验证码验证出错"
"passwordRequired": "请输入密码"
},
"register": {
"title": "注册",
@ -293,20 +290,8 @@
"nextPage": "下一页",
"chooseLanguage": "选择语言",
"title": "MkSaaS文档",
"homepage": "首页"
},
"PremiumContent": {
"title": "解锁付费内容",
"description": "订阅我们的付费计划,访问所有付费内容和独家内容。",
"upgradeCta": "立即升级",
"benefit1": "所有内容",
"benefit2": "独家内容",
"benefit3": "随时取消",
"signIn": "登录",
"loginRequired": "登录以继续阅读",
"loginDescription": "这是一篇付费内容,请登录您的账户以访问完整内容。",
"checkingAccess": "检查阅读权限...",
"loadingContent": "加载完整内容..."
"homepage": "首页",
"blog": "博客"
},
"Marketing": {
"navbar": {
@ -333,10 +318,6 @@
"title": "AI 图像",
"description": "展示如何使用 AI 生成精美图像"
},
"chat": {
"title": "AI 聊天",
"description": "展示如何使用 AI 与客户聊天"
},
"video": {
"title": "AI 视频",
"description": "展示如何使用 AI 生成惊人视频"
@ -591,7 +572,7 @@
},
"price": "价格:",
"periodStartDate": "周期开始日期:",
"periodEndDate": "周期结束日期:",
"nextBillingDate": "下次账单日期:",
"trialEnds": "试用结束日期:",
"freePlanMessage": "您当前使用的是功能有限的免费方案",
"lifetimeMessage": "您拥有所有高级功能的终身使用权限",
@ -605,10 +586,6 @@
"credits": {
"title": "积分",
"description": "管理您的积分交易",
"tabs": {
"balance": "积分余额",
"transactions": "交易记录"
},
"balance": {
"title": "积分余额",
"description": "您的积分余额",
@ -617,8 +594,9 @@
"creditsExpired": "积分已过期",
"creditsAdded": "积分已添加到您的账户",
"viewTransactions": "查看积分记录",
"retry": "重试",
"expiringCredits": "{credits} 积分将在 {days} 天内过期"
"subscriptionCredits": "本月订阅获得 {credits} 积分",
"lifetimeCredits": "本月终身会员获得 {credits} 积分",
"expiringCredits": "{credits} 积分将在 {date} 过期"
},
"packages": {
"title": "积分套餐",
@ -1008,67 +986,22 @@
},
"AITextPage": {
"title": "AI 文本",
"description": "使用 AI 分析网页内容,提取关键信息、功能和见解",
"content": "网页内容分析器",
"subtitle": "输入网站 URL使用 AI 分析其内容",
"analyzer": {
"title": "网页内容分析器",
"description": "使用 AI 分析任何网站的内容,提取结构化信息",
"placeholder": "输入网站 URL例如https://example.com",
"button": "分析网站",
"loading": {
"scraping": "正在抓取网站内容...",
"analyzing": "正在使用 AI 分析内容..."
},
"results": {
"title": "分析结果",
"newAnalysis": "分析其他网站",
"sections": {
"title": "标题",
"description": "描述",
"introduction": "介绍",
"features": "功能",
"pricing": "定价",
"useCases": "使用场景",
"screenshot": "网站截图"
}
},
"errors": {
"invalidUrl": "请输入以 http:// 或 https:// 开头的有效 URL",
"analysisError": "分析网站失败,请重试。",
"networkError": "网络错误,请检查您的连接并重试。",
"insufficientCredits": "积分不足,请购买更多积分以继续。"
}
},
"features": {
"scraping": {
"title": "智能网页抓取",
"description": "先进的网页抓取技术从任何网站提取干净、结构化的内容"
},
"analysis": {
"title": "AI 驱动分析",
"description": "智能 AI 分析提取关键见解、功能和结构化信息"
},
"results": {
"title": "结构化结果",
"description": "获得有组织、易于阅读的结果,包含清晰的部分和可操作的见解"
}
}
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS简单且毫不费力",
"content": "正在开发中"
},
"AIImagePage": {
"title": "AI 图片",
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS简单且毫不费力"
},
"AIChatPage": {
"title": "AI 聊天",
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS简单且毫不费力"
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS简单且毫不费力",
"content": "正在开发中"
},
"AIVideoPage": {
"title": "AI 视频",
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS简单且毫不费力"
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS简单且毫不费力",
"content": "正在开发中"
},
"AIAudioPage": {
"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',
},
// 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: {
// https://vercel.com/docs/image-optimization/managing-image-optimization-costs#minimizing-image-optimization-costs
// https://nextjs.org/docs/app/api-reference/components/image#unoptimized
@ -60,10 +48,6 @@ const nextConfig: NextConfig = {
protocol: 'https',
hostname: 'html.tailus.io',
},
{
protocol: 'https',
hostname: 'service.firecrawl.dev',
},
],
},
};
@ -82,9 +66,3 @@ const withNextIntl = createNextIntlPlugin();
const withMDX = createMDX();
export default withMDX(withNextIntl(nextConfig));
// https://opennext.js.org/cloudflare/get-started#12-develop-locally
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
// during local development, to access in any of your server code, local versions of Cloudflare bindings
initOpenNextCloudflareForDev();

View File

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

View File

@ -4,7 +4,6 @@
"private": true,
"scripts": {
"dev": "next dev",
"cf-dev": "next dev -p 8787",
"build": "next build",
"start": "next start",
"postinstall": "fumadocs-mdx",
@ -16,7 +15,6 @@
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"list-contacts": "tsx scripts/list-contacts.ts",
"list-users": "tsx scripts/list-users.ts",
"content": "fumadocs-mdx",
"email": "email dev --dir src/mail/templates --port 3333",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
@ -26,25 +24,21 @@
"knip": "knip"
},
"dependencies": {
"@ai-sdk/deepseek": "^1.0.0",
"@ai-sdk/fal": "^1.0.0",
"@ai-sdk/fireworks": "^1.0.0",
"@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.0",
"@ai-sdk/react": "^2.0.22",
"@ai-sdk/replicate": "^1.0.0",
"@ai-sdk/fal": "^0.1.12",
"@ai-sdk/fireworks": "^0.2.14",
"@ai-sdk/google-vertex": "^2.2.24",
"@ai-sdk/openai": "^1.1.13",
"@ai-sdk/replicate": "^0.2.8",
"@base-ui-components/react": "1.0.0-beta.0",
"@better-fetch/fetch": "^1.1.18",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.1",
"@hookform/resolvers": "^4.1.0",
"@marsidev/react-turnstile": "^1.1.0",
"@mendable/firecrawl-js": "^1.29.1",
"@next/third-parties": "^15.3.0",
"@openpanel/nextjs": "^1.0.7",
"@openrouter/ai-sdk-provider": "^1.0.0-beta.6",
"@orama/orama": "^3.1.4",
"@orama/tokenizers": "^3.1.4",
"@radix-ui/react-accordion": "^1.2.3",
@ -76,25 +70,22 @@
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@react-email/components": "0.0.33",
"@react-email/render": "1.0.5",
"@stripe/stripe-js": "^5.6.0",
"@tabler/icons-react": "^3.31.0",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-query-devtools": "^5.85.5",
"@tanstack/react-table": "^8.21.2",
"@types/canvas-confetti": "^1.9.0",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
"ai": "^5.0.0",
"@widgetbot/react-embed": "^1.9.0",
"ai": "^4.1.45",
"better-auth": "^1.1.19",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"cookie": "^1.0.2",
"crisp-sdk-web": "^1.0.25",
"date-fns": "^4.1.0",
"deepmerge": "^4.3.1",
"dotenv": "^16.4.7",
@ -102,9 +93,10 @@
"drizzle-orm": "^0.39.3",
"embla-carousel-react": "^8.5.2",
"framer-motion": "^12.4.7",
"fumadocs-core": "^15.6.7",
"fumadocs-mdx": "^11.7.3",
"fumadocs-ui": "^15.6.7",
"fumadocs-core": "^15.5.3",
"fumadocs-mdx": "^11.6.8",
"fumadocs-ui": "^15.5.3",
"inngest": "^3.40.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.483.0",
"motion": "^12.4.3",
@ -112,17 +104,14 @@
"next-intl": "^4.0.0",
"next-safe-action": "^7.10.4",
"next-themes": "^0.4.4",
"pg": "^8.16.0",
"nuqs": "^2.5.1",
"postgres": "^3.4.5",
"radix-ui": "^1.4.2",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.62.0",
"react-hook-form": "^7.54.2",
"react-remove-scroll": "^2.6.3",
"react-resizable-panels": "^2.1.7",
"react-syntax-highlighter": "^15.6.3",
"react-tweet": "^3.2.2",
"react-use-measure": "^2.1.7",
"recharts": "^2.15.1",
@ -130,7 +119,6 @@
"s3mini": "^0.2.0",
"shiki": "^2.4.2",
"sonner": "^2.0.0",
"streamdown": "^1.0.12",
"stripe": "^17.6.0",
"swiper": "^11.2.5",
"tailwind-merge": "^3.0.2",
@ -138,29 +126,24 @@
"tw-animate-css": "^1.2.4",
"use-intl": "^3.26.5",
"use-media": "^1.5.0",
"use-stick-to-bottom": "^1.1.1",
"vaul": "^1.1.2",
"zod": "^4.0.17",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@opennextjs/cloudflare": "^1.6.5",
"@tailwindcss/postcss": "^4.0.14",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@types/mdx": "^2.0.13",
"@types/node": "^20.19.0",
"@types/pg": "^8.11.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-syntax-highlighter": "^15.5.13",
"drizzle-kit": "^0.30.4",
"knip": "^5.61.2",
"postcss": "^8",
"react-email": "3.0.7",
"tailwindcss": "^4.0.14",
"tsx": "^4.19.3",
"typescript": "^5.8.3",
"wrangler": "^4.28.1"
"typescript": "^5.8.3"
}
}

7959
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')
})
}
}
})

View File

@ -1,24 +0,0 @@
import dotenv from 'dotenv';
import { getDb } from '../src/db/index.js';
import { user } from '../src/db/schema.js';
dotenv.config();
export default async function listUsers() {
const db = await getDb();
try {
const users = await db.select({ email: user.email }).from(user);
// Extract emails from users
const emails: string[] = users.map((user) => user.email);
console.log(`Total users: ${emails.length}`);
// Output all emails joined with comma
console.log(emails.join(', '));
} catch (error) {
console.error('Error fetching users:', error);
}
}
listUsers();

View File

@ -15,7 +15,6 @@ export const docs = defineDocs({
schema: frontmatterSchema.extend({
preview: z.string().optional(),
index: z.boolean().default(false),
premium: z.boolean().optional(),
}),
},
meta: {
@ -86,7 +85,7 @@ export const category = defineCollections({
/**
* Blog posts
*
* title is required, but description is optional in frontmatter
* dtitle is required, but description is optional in frontmatter
*/
export const blog = defineCollections({
type: 'doc',
@ -95,7 +94,6 @@ export const blog = defineCollections({
image: z.string(),
date: z.string().date(),
published: z.boolean().default(true),
premium: z.boolean().optional(),
categories: z.array(z.string()),
author: z.string(),
}),

View File

@ -1,16 +1,19 @@
'use server';
import { userActionClient } from '@/lib/safe-action';
import { isSubscribed } from '@/newsletter';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Newsletter schema for validation
const newsletterSchema = z.object({
email: z.email({ error: 'Please enter a valid email address' }),
email: z.string().email({ message: 'Please enter a valid email address' }),
});
// Create a safe action to check if a user is subscribed to the newsletter
export const checkNewsletterStatusAction = userActionClient
export const checkNewsletterStatusAction = actionClient
.schema(newsletterSchema)
.action(async ({ parsedInput: { email } }) => {
try {

View File

@ -1,10 +1,12 @@
'use server';
import { consumeCredits } from '@/credits/credits';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { getSession } from '@/lib/server';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
const actionClient = createSafeActionClient();
// consume credits schema
const consumeSchema = z.object({
amount: z.number().min(1),
@ -14,17 +16,21 @@ const consumeSchema = z.object({
/**
* Consume credits
*/
export const consumeCreditsAction = userActionClient
export const consumeCreditsAction = actionClient
.schema(consumeSchema)
.action(async ({ parsedInput, ctx }) => {
const { amount, description } = parsedInput;
const currentUser = (ctx as { user: User }).user;
.action(async ({ parsedInput }) => {
const session = await getSession();
if (!session) {
console.warn('unauthorized request to consume credits');
return { success: false, error: 'Unauthorized' };
}
try {
await consumeCredits({
userId: currentUser.id,
amount,
description: description || `Consume credits: ${amount}`,
userId: session.user.id,
amount: parsedInput.amount,
description:
parsedInput.description || `Consume credits: ${parsedInput.amount}`,
});
return { success: true };
} catch (error) {

View File

@ -1,34 +1,59 @@
'use server';
import { websiteConfig } from '@/config/website';
import type { User } from '@/lib/auth-types';
import { findPlanByPlanId } from '@/lib/price-plan';
import { userActionClient } from '@/lib/safe-action';
import { getSession } from '@/lib/server';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { createCheckout } from '@/payment';
import type { CreateCheckoutParams } from '@/payment/types';
import { Routes } from '@/routes';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { cookies } from 'next/headers';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Checkout schema for validation
// metadata is optional, and may contain referral information if you need
const checkoutSchema = z.object({
userId: z.string().min(1, { error: 'User ID is required' }),
planId: z.string().min(1, { error: 'Plan ID is required' }),
priceId: z.string().min(1, { error: 'Price ID is required' }),
metadata: z.record(z.string(), z.string()).optional(),
userId: z.string().min(1, { message: 'User ID is required' }),
planId: z.string().min(1, { message: 'Plan ID is required' }),
priceId: z.string().min(1, { message: 'Price ID is required' }),
metadata: z.record(z.string()).optional(),
});
/**
* Create a checkout session for a price plan
*/
export const createCheckoutAction = userActionClient
export const createCheckoutAction = actionClient
.schema(checkoutSchema)
.action(async ({ parsedInput, ctx }) => {
const { planId, priceId, metadata } = parsedInput;
const currentUser = (ctx as { user: User }).user;
.action(async ({ parsedInput }) => {
const { userId, planId, priceId, metadata } = parsedInput;
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to create checkout session for user ${userId}`
);
return {
success: false,
error: 'Unauthorized',
};
}
// Only allow users to create their own checkout session
if (session.user.id !== userId) {
console.warn(
`current user ${session.user.id} is not authorized to create checkout session for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
try {
// Get the current locale from the request
@ -46,8 +71,8 @@ export const createCheckoutAction = userActionClient
// Add user id to metadata, so we can get it in the webhook event
const customMetadata: Record<string, string> = {
...metadata,
userId: currentUser.id,
userName: currentUser.name,
userId: session.user.id,
userName: session.user.name,
};
// https://datafa.st/docs/stripe-checkout-api
@ -69,7 +94,7 @@ export const createCheckoutAction = userActionClient
const params: CreateCheckoutParams = {
planId,
priceId,
customerEmail: currentUser.email,
customerEmail: session.user.email,
metadata: customMetadata,
successUrl,
cancelUrl,

View File

@ -2,33 +2,58 @@
import { websiteConfig } from '@/config/website';
import { getCreditPackageById } from '@/credits/server';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { getSession } from '@/lib/server';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { createCreditCheckout } from '@/payment';
import type { CreateCreditCheckoutParams } from '@/payment/types';
import { Routes } from '@/routes';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { cookies } from 'next/headers';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Credit checkout schema for validation
// metadata is optional, and may contain referral information if you need
const creditCheckoutSchema = z.object({
userId: z.string().min(1, { error: 'User ID is required' }),
packageId: z.string().min(1, { error: 'Package ID is required' }),
priceId: z.string().min(1, { error: 'Price ID is required' }),
metadata: z.record(z.string(), z.string()).optional(),
userId: z.string().min(1, { message: 'User ID is required' }),
packageId: z.string().min(1, { message: 'Package ID is required' }),
priceId: z.string().min(1, { message: 'Price ID is required' }),
metadata: z.record(z.string()).optional(),
});
/**
* Create a checkout session for a credit package
*/
export const createCreditCheckoutSession = userActionClient
export const createCreditCheckoutSession = actionClient
.schema(creditCheckoutSchema)
.action(async ({ parsedInput, ctx }) => {
const { packageId, priceId, metadata } = parsedInput;
const currentUser = (ctx as { user: User }).user;
.action(async ({ parsedInput }) => {
const { userId, packageId, priceId, metadata } = parsedInput;
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to create credit checkout session for user ${userId}`
);
return {
success: false,
error: 'Unauthorized',
};
}
// Only allow users to create their own checkout session
if (session.user.id !== userId) {
console.warn(
`current user ${session.user.id} is not authorized to create credit checkout session for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
try {
// Get the current locale from the request
@ -48,9 +73,9 @@ export const createCreditCheckoutSession = userActionClient
...metadata,
type: 'credit_purchase',
packageId,
credits: creditPackage.amount.toString(),
userId: currentUser.id,
userName: currentUser.name,
credits: creditPackage.credits.toString(),
userId: session.user.id,
userName: session.user.name,
};
// https://datafa.st/docs/stripe-checkout-api
@ -65,15 +90,15 @@ export const createCreditCheckoutSession = userActionClient
// Create checkout session with credit-specific URLs
const successUrl = getUrlWithLocale(
`${Routes.SettingsCredits}?credits_session_id={CHECKOUT_SESSION_ID}`,
`${Routes.SettingsBilling}?session_id={CHECKOUT_SESSION_ID}`,
locale
);
const cancelUrl = getUrlWithLocale(Routes.SettingsCredits, locale);
const cancelUrl = getUrlWithLocale(Routes.SettingsBilling, locale);
const params: CreateCreditCheckoutParams = {
packageId,
priceId,
customerEmail: currentUser.email,
customerEmail: session.user.email,
metadata: customMetadata,
successUrl,
cancelUrl,

View File

@ -2,32 +2,57 @@
import { getDb } from '@/db';
import { user } from '@/db/schema';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { getSession } from '@/lib/server';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { createCustomerPortal } from '@/payment';
import type { CreatePortalParams } from '@/payment/types';
import { eq } from 'drizzle-orm';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Portal schema for validation
const portalSchema = z.object({
userId: z.string().min(1, { error: 'User ID is required' }),
userId: z.string().min(1, { message: 'User ID is required' }),
returnUrl: z
.string()
.url({ error: 'Return URL must be a valid URL' })
.url({ message: 'Return URL must be a valid URL' })
.optional(),
});
/**
* Create a customer portal session
*/
export const createPortalAction = userActionClient
export const createPortalAction = actionClient
.schema(portalSchema)
.action(async ({ parsedInput, ctx }) => {
const { returnUrl } = parsedInput;
const currentUser = (ctx as { user: User }).user;
.action(async ({ parsedInput }) => {
const { userId, returnUrl } = parsedInput;
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to create portal session for user ${userId}`
);
return {
success: false,
error: 'Unauthorized',
};
}
// Only allow users to create their own portal session
if (session.user.id !== userId) {
console.warn(
`current user ${session.user.id} is not authorized to create portal session for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
try {
// Get the user's customer ID from the database
@ -35,11 +60,11 @@ export const createPortalAction = userActionClient
const customerResult = await db
.select({ customerId: user.customerId })
.from(user)
.where(eq(user.id, currentUser.id))
.where(eq(user.id, session.user.id))
.limit(1);
if (customerResult.length <= 0 || !customerResult[0].customerId) {
console.error(`No customer found for user ${currentUser.id}`);
console.error(`No customer found for user ${session.user.id}`);
return {
success: false,
error: 'No customer found for user',

View File

@ -1,13 +1,16 @@
'use server';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { getSession } from '@/lib/server';
import { getSubscriptions } from '@/payment';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Input schema
const schema = z.object({
userId: z.string().min(1, { error: 'User ID is required' }),
userId: z.string().min(1, { message: 'User ID is required' }),
});
/**
@ -16,27 +19,38 @@ const schema = z.object({
* If the user has multiple subscriptions,
* it returns the most recent active or trialing one
*/
export const getActiveSubscriptionAction = userActionClient
export const getActiveSubscriptionAction = actionClient
.schema(schema)
.action(async ({ ctx }) => {
const currentUser = (ctx as { user: User }).user;
.action(async ({ parsedInput }) => {
const { userId } = parsedInput;
// Check if Stripe environment variables are configured
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!stripeSecretKey || !stripeWebhookSecret) {
console.log('Stripe environment variables not configured, return');
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to get active subscription for user ${userId}`
);
return {
success: true,
data: null, // No subscription = free plan
success: false,
error: 'Unauthorized',
};
}
// Only allow users to check their own status unless they're admins
if (session.user.id !== userId && session.user.role !== 'admin') {
console.warn(
`current user ${session.user.id} is not authorized to get active subscription for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
try {
// Find the user's most recent active subscription
const subscriptions = await getSubscriptions({
userId: currentUser.id,
userId: session.user.id,
});
// console.log('get user subscriptions:', subscriptions);
@ -50,16 +64,16 @@ export const getActiveSubscriptionAction = userActionClient
// If found, use it
if (activeSubscription) {
console.log('find active subscription for userId:', currentUser.id);
console.log('find active subscription for userId:', session.user.id);
subscriptionData = activeSubscription;
} else {
console.log(
'no active subscription found for userId:',
currentUser.id
session.user.id
);
}
} else {
console.log('no subscriptions found for userId:', currentUser.id);
console.log('no subscriptions found for userId:', session.user.id);
}
return {

View File

@ -1,27 +1,21 @@
'use server';
import { getUserCredits } from '@/credits/credits';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { getSession } from '@/lib/server';
import { createSafeActionClient } from 'next-safe-action';
const actionClient = createSafeActionClient();
/**
* Get current user's credits
*/
export const getCreditBalanceAction = userActionClient.action(
async ({ ctx }) => {
try {
const currentUser = (ctx as { user: User }).user;
const credits = await getUserCredits(currentUser.id);
return { success: true, credits };
} catch (error) {
console.error('get credit balance error:', error);
return {
success: false,
error:
error instanceof Error
? error.message
: 'Failed to fetch credit balance',
};
}
export const getCreditBalanceAction = actionClient.action(async () => {
const session = await getSession();
if (!session) {
console.warn('unauthorized request to get credit balance');
return { success: false, error: 'Unauthorized' };
}
);
const credits = await getUserCredits(session.user.id);
return { success: true, credits };
});

View File

@ -1,30 +1,42 @@
'use server';
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
import { getDb } from '@/db';
import { creditTransaction } from '@/db/schema';
import type { User } from '@/lib/auth-types';
import { CREDITS_EXPIRATION_DAYS } from '@/lib/constants';
import { userActionClient } from '@/lib/safe-action';
import { getSession } from '@/lib/server';
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';
import { createSafeActionClient } from 'next-safe-action';
const CREDITS_EXPIRATION_DAYS = 31;
const CREDITS_MONTHLY_DAYS = 31;
// Create a safe action client
const actionClient = createSafeActionClient();
/**
* Get credit statistics for a user
*/
export const getCreditStatsAction = userActionClient.action(async ({ ctx }) => {
export const getCreditStatsAction = actionClient.action(async () => {
try {
const currentUser = (ctx as { user: User }).user;
const userId = currentUser.id;
const session = await getSession();
if (!session) {
console.warn('unauthorized request to get credit stats');
return {
success: false,
error: 'Unauthorized',
};
}
const db = await getDb();
const now = new Date();
// Get credits expiring in the next 30 days
const expirationDaysFromNow = addDays(now, CREDITS_EXPIRATION_DAYS);
const userId = session.user.id;
// Get total credits expiring in the next 30 days
const expiringCreditsResult = await db
// Get credits expiring in the next CREDITS_EXPIRATION_DAYS days
const expirationDaysFromNow = addDays(new Date(), CREDITS_EXPIRATION_DAYS);
const expiringCredits = await db
.select({
totalAmount: sum(creditTransaction.remainingAmount),
amount: sum(creditTransaction.remainingAmount),
earliestExpiration: sql<Date>`MIN(${creditTransaction.expirationDate})`,
})
.from(creditTransaction)
.where(
@ -32,20 +44,56 @@ export const getCreditStatsAction = userActionClient.action(async ({ ctx }) => {
eq(creditTransaction.userId, userId),
isNotNull(creditTransaction.expirationDate),
isNotNull(creditTransaction.remainingAmount),
gt(creditTransaction.remainingAmount, 0),
gte(creditTransaction.remainingAmount, 1),
lte(creditTransaction.expirationDate, expirationDaysFromNow),
gte(creditTransaction.expirationDate, now)
gte(creditTransaction.expirationDate, new Date())
)
);
const totalExpiringCredits =
Number(expiringCreditsResult[0]?.totalAmount) || 0;
// Get credits from subscription renewals (recent CREDITS_MONTHLY_DAYS days)
const monthlyRefreshDaysAgo = addDays(new Date(), -CREDITS_MONTHLY_DAYS);
const subscriptionCredits = await db
.select({
amount: sum(creditTransaction.amount),
})
.from(creditTransaction)
.where(
and(
eq(creditTransaction.userId, userId),
eq(
creditTransaction.type,
CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL
),
gte(creditTransaction.createdAt, monthlyRefreshDaysAgo)
)
);
// Get credits from monthly lifetime distribution (recent CREDITS_MONTHLY_DAYS days)
const lifetimeCredits = await db
.select({
amount: sum(creditTransaction.amount),
})
.from(creditTransaction)
.where(
and(
eq(creditTransaction.userId, userId),
eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY),
gte(creditTransaction.createdAt, monthlyRefreshDaysAgo)
)
);
return {
success: true,
data: {
expiringCredits: {
amount: totalExpiringCredits,
amount: Number(expiringCredits[0]?.amount) || 0,
earliestExpiration: expiringCredits[0]?.earliestExpiration || null,
},
subscriptionCredits: {
amount: Number(subscriptionCredits[0]?.amount) || 0,
},
lifetimeCredits: {
amount: Number(lifetimeCredits[0]?.amount) || 0,
},
},
};

View File

@ -2,11 +2,14 @@
import { getDb } from '@/db';
import { creditTransaction } from '@/db/schema';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { getSession } from '@/lib/server';
import { and, asc, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Define the schema for getCreditTransactions parameters
const getCreditTransactionsSchema = z.object({
pageIndex: z.number().min(0).default(0),
@ -37,39 +40,32 @@ const sortFieldMap = {
} as const;
// Create a safe action for getting credit transactions
export const getCreditTransactionsAction = userActionClient
export const getCreditTransactionsAction = actionClient
.schema(getCreditTransactionsSchema)
.action(async ({ parsedInput, ctx }) => {
.action(async ({ parsedInput }) => {
try {
const { pageIndex, pageSize, search, sorting } = parsedInput;
const currentUser = (ctx as { user: User }).user;
// Search logic: text fields use ilike, and if search is a number, also search amount fields
const searchConditions = [];
if (search) {
// Always search text fields
searchConditions.push(
ilike(creditTransaction.type, `%${search}%`),
ilike(creditTransaction.paymentId, `%${search}%`),
ilike(creditTransaction.description, `%${search}%`)
);
// If search is a valid number, also search numeric fields
const numericSearch = Number.parseInt(search, 10);
if (!Number.isNaN(numericSearch)) {
searchConditions.push(
eq(creditTransaction.amount, numericSearch),
eq(creditTransaction.remainingAmount, numericSearch)
);
}
const session = await getSession();
if (!session) {
return {
success: false,
error: 'Unauthorized',
};
}
const { pageIndex, pageSize, search, sorting } = parsedInput;
// search by type, amount, paymentId, description, and restrict to current user
const where = search
? and(
eq(creditTransaction.userId, currentUser.id),
or(...searchConditions)
eq(creditTransaction.userId, session.user.id),
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, session.user.id);
const offset = pageIndex * pageSize;

View File

@ -2,16 +2,19 @@
import { getDb } from '@/db';
import { payment } from '@/db/schema';
import type { User } from '@/lib/auth-types';
import { findPlanByPriceId, getAllPricePlans } from '@/lib/price-plan';
import { userActionClient } from '@/lib/safe-action';
import { getSession } from '@/lib/server';
import { PaymentTypes } from '@/payment/types';
import { and, eq } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Input schema
const schema = z.object({
userId: z.string().min(1, { error: 'User ID is required' }),
userId: z.string().min(1, { message: 'User ID is required' }),
});
/**
@ -22,11 +25,33 @@ const schema = z.object({
* in order to do this, you have to update the logic to check the lifetime status,
* for example, just check the planId is `lifetime` or not.
*/
export const getLifetimeStatusAction = userActionClient
export const getLifetimeStatusAction = actionClient
.schema(schema)
.action(async ({ ctx }) => {
const currentUser = (ctx as { user: User }).user;
const userId = currentUser.id;
.action(async ({ parsedInput }) => {
const { userId } = parsedInput;
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to get lifetime status for user ${userId}`
);
return {
success: false,
error: 'Unauthorized',
};
}
// Only allow users to check their own status unless they're admins
if (session.user.id !== userId && session.user.role !== 'admin') {
console.warn(
`current user ${session.user.id} is not authorized to get lifetime status for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
try {
// Get lifetime plans

View File

@ -2,11 +2,13 @@
import { getDb } from '@/db';
import { user } from '@/db/schema';
import { isDemoWebsite } from '@/lib/demo';
import { adminActionClient } from '@/lib/safe-action';
import { asc, desc, ilike, or, sql } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Define the schema for getUsers parameters
const getUsersSchema = z.object({
pageIndex: z.number().min(0).default(0),
@ -36,7 +38,7 @@ const sortFieldMap = {
} as const;
// Create a safe action for getting users
export const getUsersAction = adminActionClient
export const getUsersAction = actionClient
.schema(getUsersSchema)
.action(async ({ parsedInput }) => {
try {
@ -73,8 +75,7 @@ export const getUsersAction = adminActionClient
]);
// hide user data in demo website
const isDemo = isDemoWebsite();
if (isDemo) {
if (process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true') {
items = items.map((item) => ({
...item,
name: 'Demo User',

View File

@ -1,11 +1,14 @@
'use server';
import { websiteConfig } from '@/config/website';
import { actionClient } from '@/lib/safe-action';
import { sendEmail } from '@/mail';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
/**
* DOC: When using Zod for validation, how can I localize error messages?
* https://next-intl.dev/docs/environments/actions-metadata-route-handlers#server-actions
@ -14,13 +17,13 @@ import { z } from 'zod';
const contactFormSchema = z.object({
name: z
.string()
.min(3, { error: 'Name must be at least 3 characters' })
.max(30, { error: 'Name must not exceed 30 characters' }),
email: z.email({ error: 'Please enter a valid email address' }),
.min(3, { message: 'Name must be at least 3 characters' })
.max(30, { message: 'Name must not exceed 30 characters' }),
email: z.string().email({ message: 'Please enter a valid email address' }),
message: z
.string()
.min(10, { error: 'Message must be at least 10 characters' })
.max(500, { error: 'Message must not exceed 500 characters' }),
.min(10, { message: 'Message must be at least 10 characters' })
.max(500, { message: 'Message must not exceed 500 characters' }),
});
// Create a safe action for contact form submission

View File

@ -1,14 +1,17 @@
'use server';
import { actionClient } from '@/lib/safe-action';
import { sendEmail } from '@/mail';
import { subscribe } from '@/newsletter';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Newsletter schema for validation
const newsletterSchema = z.object({
email: z.email({ error: 'Please enter a valid email address' }),
email: z.string().email({ message: 'Please enter a valid email address' }),
});
// Create a safe action for newsletter subscription

View File

@ -1,18 +1,30 @@
'use server';
import { userActionClient } from '@/lib/safe-action';
import { getSession } from '@/lib/server';
import { unsubscribe } from '@/newsletter';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Newsletter schema for validation
const newsletterSchema = z.object({
email: z.email({ error: 'Please enter a valid email address' }),
email: z.string().email({ message: 'Please enter a valid email address' }),
});
// Create a safe action for newsletter unsubscription
export const unsubscribeNewsletterAction = userActionClient
export const unsubscribeNewsletterAction = actionClient
.schema(newsletterSchema)
.action(async ({ parsedInput: { email } }) => {
const session = await getSession();
if (!session) {
return {
success: false,
error: 'Unauthorized',
};
}
try {
const unsubscribed = await unsubscribe(email);

View File

@ -1,12 +1,15 @@
'use server';
import { validateTurnstileToken } from '@/lib/captcha';
import { actionClient } from '@/lib/safe-action';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Captcha validation schema
const captchaSchema = z.object({
captchaToken: z.string().min(1, { error: 'Captcha token is required' }),
captchaToken: z.string().min(1, { message: 'Captcha token is required' }),
});
// Create a safe action for captcha validation

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

@ -61,6 +61,7 @@ export function ImagePlayground({
const providerToModel = {
replicate: selectedModels.replicate,
// vertex: selectedModels.vertex,
openai: selectedModels.openai,
fireworks: selectedModels.fireworks,
fal: selectedModels.fal,
@ -76,9 +77,9 @@ export function ImagePlayground({
return (
<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 */}
{/* <ImageGeneratorHeader /> */}
<ImageGeneratorHeader />
{/* input prompt */}
<PromptInput

View File

@ -15,6 +15,7 @@ import {
FireworksIcon,
OpenAIIcon,
ReplicateIcon,
// VertexIcon,
falAILogo,
} from '../lib/logos';
import type { ProviderKey } from '../lib/provider-config';
@ -39,6 +40,7 @@ interface ModelSelectProps {
const PROVIDER_ICONS = {
openai: OpenAIIcon,
replicate: ReplicateIcon,
// vertex: VertexIcon,
fireworks: FireworksIcon,
fal: falAILogo,
} as const;
@ -46,6 +48,7 @@ const PROVIDER_ICONS = {
const PROVIDER_LINKS = {
openai: 'openai',
replicate: 'replicate',
// vertex: 'google-vertex',
fireworks: 'fireworks',
fal: 'fal',
} as const;

View File

@ -62,6 +62,55 @@ export const ReplicateIcon = ({ size = 16 }) => {
);
};
export const VertexIcon = ({ size = 16 }) => {
return (
<svg
height={size}
width={size}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
style={{ color: 'currentcolor' }}
>
<g transform="scale(0.8) translate(65,65)">
<path
d="M128,249c-8.8,0-16-7.2-16-16v-105c0-8.8,7.2-16,16-16s16,7.2,16,16v105c0,8.8-7.2,16-16,16Z"
fill="white"
/>
<path
d="M256,464c-3,0-6-.8-8.6-2.5l-176-112c-7.5-4.7-9.7-14.6-4.9-22.1,4.8-7.5,14.6-9.6,22.1-4.9l167.4,106.5,167.4-106.5c7.5-4.7,17.3-2.5,22.1,4.9,4.7,7.5,2.5,17.3-4.9,22.1l-176,112c-2.6,1.7-5.6,2.5-8.6,2.5h0Z"
fill="white"
/>
<path
d="M256,394c-8.8,0-16-7.2-16-16v-73.1c0-8.8,7.2-16,16-16s16,7.2,16,16v73.1c0,8.8-7.2,16-16,16Z"
fill="white"
/>
<circle cx="128" cy="64" r="16" fill="white" />
<circle cx="128" cy="297" r="16" fill="white" />
<path
d="M384.2,314c-8.8,0-16-7.1-16-16l-.2-106c0-8.8,7.1-16,16-16h0c8.8,0,16,7.1,16,16l.2,106c0,8.8-7.1,16-16,16h0Z"
fill="white"
/>
<circle cx="384" cy="64" r="16" fill="white" />
<circle cx="384" cy="128" r="16" fill="white" />
<path
d="M320,225c-8.8,0-16-7.2-16-16v-103c0-8.8,7.2-16,16-16s16,7.2,16,16v103c0,8.8-7.2,16-16,16Z"
fill="white"
/>
<circle cx="256" cy="177" r="16" fill="white" />
<circle cx="256" cy="241" r="16" fill="white" />
<circle cx="320" cy="273" r="16" fill="white" />
<circle cx="320" cy="337" r="16" fill="white" />
<path
d="M192,225c-8.8,0-16-7.2-16-16v-103c0-8.8,7.2-16,16-16s16,7.2,16,16v103c0,8.8-7.2,16-16,16Z"
fill="white"
/>
<circle cx="192" cy="273" r="16" fill="white" />
<circle cx="192" cy="337" r="16" fill="white" />
</g>
</svg>
);
};
export const falAILogo = ({ size = 16 }: { size: number }) => {
return (
<svg

View File

@ -1,4 +1,9 @@
export type ProviderKey = 'replicate' | 'openai' | 'fireworks' | 'fal';
export type ProviderKey =
| 'replicate'
// | 'vertex'
| 'openai'
| 'fireworks'
| 'fal';
export type ModelMode = 'performance' | 'quality';
export const PROVIDERS: Record<
@ -32,6 +37,12 @@ export const PROVIDERS: Record<
'stability-ai/stable-diffusion-3.5-large-turbo',
],
},
// vertex: {
// displayName: 'Vertex AI',
// iconPath: '/provider-icons/vertex.svg',
// color: 'from-green-500 to-emerald-500',
// models: ['imagen-3.0-generate-001', 'imagen-3.0-fast-generate-001'],
// },
// https://ai-sdk.dev/providers/ai-sdk-providers/openai#image-models
openai: {
displayName: 'OpenAI',
@ -81,12 +92,14 @@ export const PROVIDERS: Record<
export const MODEL_CONFIGS: Record<ModelMode, Record<ProviderKey, string>> = {
performance: {
replicate: 'black-forest-labs/flux-1.1-pro',
// vertex: 'imagen-3.0-fast-generate-001',
openai: 'dall-e-3',
fireworks: 'accounts/fireworks/models/flux-1-schnell-fp8',
fal: 'fal-ai/flux/dev',
},
quality: {
replicate: 'stability-ai/stable-diffusion-3.5-large',
// vertex: 'imagen-3.0-generate-001',
openai: 'dall-e-3',
fireworks: 'accounts/fireworks/models/flux-1-dev-fp8',
fal: 'fal-ai/flux-pro/v1.1-ultra',
@ -95,6 +108,7 @@ export const MODEL_CONFIGS: Record<ModelMode, Record<ProviderKey, string>> = {
export const PROVIDER_ORDER: ProviderKey[] = [
'replicate',
// 'vertex',
'openai',
'fireworks',
'fal',

View File

@ -1,303 +0,0 @@
'use client';
import type { AnalysisResultsProps } from '@/ai/text/utils/web-content-analyzer';
import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-config';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
CalendarIcon,
CreditCardIcon,
ExternalLinkIcon,
ImageIcon,
InfoIcon,
ListIcon,
PlusIcon,
RefreshCwIcon,
SparklesIcon,
TagIcon,
} from 'lucide-react';
import Image from 'next/image';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import {
ImageOptimization,
useLazyLoading,
useStableCallback,
} from '../utils/performance';
// Memoized screenshot component for better performance
const LazyScreenshot = memo(
({
screenshot,
title,
onLoad,
onError,
}: {
screenshot: string;
title: string;
onLoad: () => void;
onError: () => void;
}) => {
const [imageRef, isVisible] = useLazyLoading(
webContentAnalyzerConfig.performance.lazyLoadingThreshold
);
const [imageLoading, setImageLoading] = useState(true);
const handleImageLoad = useCallback(() => {
setImageLoading(false);
onLoad();
}, [onLoad]);
const handleImageError = useCallback(() => {
setImageLoading(false);
onError();
}, [onError]);
return (
<div ref={imageRef} className="relative">
{imageLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-muted rounded-lg">
<RefreshCwIcon className="size-6 animate-spin text-muted-foreground" />
</div>
)}
<div className="relative aspect-[4/3] overflow-hidden rounded-lg border bg-muted">
{isVisible && (
<Image
src={screenshot}
alt={`Screenshot of ${title}`}
fill
className="object-cover object-top transition-opacity duration-300"
style={{
opacity: imageLoading ? 0 : 1,
}}
onLoad={handleImageLoad}
onError={handleImageError}
sizes="(max-width: 1024px) 100vw, 33vw"
loading="lazy"
/>
)}
</div>
</div>
);
}
);
LazyScreenshot.displayName = 'LazyScreenshot';
export const AnalysisResults = memo(function AnalysisResults({
results,
screenshot,
onNewAnalysis,
}: AnalysisResultsProps) {
const [imageError, setImageError] = useState(false);
// Memoized utility functions to prevent re-creation on every render
const formatDate = useCallback((dateString: string) => {
try {
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return 'Recently';
}
}, []);
const getDomainFromUrl = useCallback((url: string) => {
try {
return new URL(url).hostname;
} catch {
return url;
}
}, []);
const handleImageLoad = useCallback(() => {
// Image loaded successfully
}, []);
const handleImageError = useCallback(() => {
setImageError(true);
}, []);
// Memoized domain and formatted date to prevent recalculation
const domain = getDomainFromUrl(results.url);
const formattedDate = formatDate(results.analyzedAt);
return (
<div className="w-full max-w-4xl mx-auto space-y-6">
{/* Header Section */}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<CardTitle className="text-2xl font-bold leading-tight">
{results.title}
</CardTitle>
<CardDescription className="text-base">
{results.description}
</CardDescription>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<ExternalLinkIcon className="size-4" />
<a
href={results.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors underline"
>
{domain}
</a>
</div>
<div className="flex items-center gap-1">
<CalendarIcon className="size-4" />
<span>Analyzed {formattedDate}</span>
</div>
</div>
</div>
</div>
</CardHeader>
</Card>
{/* Info section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Introduction Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<InfoIcon className="size-5" />
Introduction
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed text-muted-foreground">
{results.introduction}
</p>
</CardContent>
</Card>
{/* Features Section */}
{results.features && results.features.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ListIcon className="size-5" />
Features
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{results.features.map((feature, index) => (
<div key={index} className="flex items-start gap-3">
<div className="flex-shrink-0 w-2 h-2 rounded-full bg-primary mt-2" />
<p className="text-sm leading-relaxed text-muted-foreground">
{feature}
</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Use Cases Section */}
{results.useCases && results.useCases.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TagIcon className="size-5" />
Use Cases
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{results.useCases.map((useCase, index) => (
<Badge key={index} variant="secondary" className="text-xs">
{useCase}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Pricing Section */}
{results.pricing && results.pricing !== 'Not specified' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CreditCardIcon className="size-5" />
Pricing
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed text-muted-foreground">
{results.pricing}
</p>
</CardContent>
</Card>
)}
</div>
{/* Screenshot Sidebar */}
<div className="lg:col-span-1">
<Card className="sticky top-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ImageIcon className="size-5" />
Screenshot
</CardTitle>
</CardHeader>
<CardContent>
{screenshot && !imageError ? (
<LazyScreenshot
screenshot={screenshot}
title={results.title}
onLoad={handleImageLoad}
onError={handleImageError}
/>
) : (
<div className="aspect-[4/3] flex items-center justify-center bg-muted rounded-lg border">
<div className="text-center space-y-2">
<ImageIcon className="size-8 text-muted-foreground mx-auto" />
<p className="text-sm text-muted-foreground">
{imageError
? 'Failed to load screenshot'
: 'No screenshot available'}
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Action Section */}
<div className="py-6">
<div className="flex justify-center">
<Button
onClick={onNewAnalysis}
size="lg"
className="w-full max-w-md cursor-pointer"
>
<SparklesIcon className="size-4" />
Analyze Another Website
</Button>
</div>
{/* <Separator className="my-6" /> */}
</div>
</div>
);
});

View File

@ -0,0 +1,51 @@
'use client';
import { CreditsBalanceButton } from '@/components/layout/credits-balance-button';
import { Button } from '@/components/ui/button';
import { useCredits } 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 { consumeCredits, hasEnoughCredits, isLoading } = useCredits();
const [loading, setLoading] = useState(false);
const handleConsume = async () => {
if (!hasEnoughCredits(CONSUME_CREDITS)) {
toast.error('Insufficient credits, please buy more credits.');
return;
}
setLoading(true);
const success = await consumeCredits(
CONSUME_CREDITS,
`AI Text Credit Consumption (${CONSUME_CREDITS} credits)`
);
setLoading(false);
if (success) {
toast.success(`${CONSUME_CREDITS} credits have been consumed.`);
} else {
toast.error('Failed to consume credits, please try again later.');
}
};
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={isLoading || loading}
className="w-full cursor-pointer"
>
<CoinsIcon className="size-4" />
<span>Consume {CONSUME_CREDITS} credits</span>
</Button>
</div>
);
}

View File

@ -1,313 +0,0 @@
'use client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import {
AlertCircleIcon,
AlertTriangleIcon,
ClockIcon,
CreditCardIcon,
HelpCircleIcon,
InfoIcon,
RefreshCwIcon,
ServerIcon,
ShieldIcon,
WifiOffIcon,
} from 'lucide-react';
import { useState } from 'react';
import {
ErrorSeverity,
ErrorType,
type WebContentAnalyzerError,
getRecoveryActions,
} from '../utils/error-handling';
interface ErrorDisplayProps {
error: WebContentAnalyzerError;
onRetry?: () => void;
onDismiss?: () => void;
className?: string;
}
// Error icon mapping
const errorIcons = {
[ErrorType.VALIDATION]: AlertCircleIcon,
[ErrorType.NETWORK]: WifiOffIcon,
[ErrorType.SCRAPING]: ServerIcon,
[ErrorType.ANALYSIS]: HelpCircleIcon,
[ErrorType.TIMEOUT]: ClockIcon,
[ErrorType.RATE_LIMIT]: ClockIcon,
[ErrorType.AUTHENTICATION]: ShieldIcon,
[ErrorType.SERVICE_UNAVAILABLE]: ServerIcon,
[ErrorType.UNKNOWN]: AlertTriangleIcon,
};
// Severity color mapping
const severityColors = {
[ErrorSeverity.LOW]: {
border: 'border-blue-200 dark:border-blue-800',
bg: 'bg-blue-50 dark:bg-blue-950/20',
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
iconColor: 'text-blue-600 dark:text-blue-400',
titleColor: 'text-blue-800 dark:text-blue-200',
textColor: 'text-blue-700 dark:text-blue-300',
},
[ErrorSeverity.MEDIUM]: {
border: 'border-yellow-200 dark:border-yellow-800',
bg: 'bg-yellow-50 dark:bg-yellow-950/20',
iconBg: 'bg-yellow-100 dark:bg-yellow-900/30',
iconColor: 'text-yellow-600 dark:text-yellow-400',
titleColor: 'text-yellow-800 dark:text-yellow-200',
textColor: 'text-yellow-700 dark:text-yellow-300',
},
[ErrorSeverity.HIGH]: {
border: 'border-red-200 dark:border-red-800',
bg: 'bg-red-50 dark:bg-red-950/20',
iconBg: 'bg-red-100 dark:bg-red-900/30',
iconColor: 'text-red-600 dark:text-red-400',
titleColor: 'text-red-800 dark:text-red-200',
textColor: 'text-red-700 dark:text-red-300',
},
[ErrorSeverity.CRITICAL]: {
border: 'border-red-200 dark:border-red-800',
bg: 'bg-red-50 dark:bg-red-950/20',
iconBg: 'bg-red-100 dark:bg-red-900/30',
iconColor: 'text-red-600 dark:text-red-400',
titleColor: 'text-red-800 dark:text-red-200',
textColor: 'text-red-700 dark:text-red-300',
},
};
// Error title mapping
const errorTitles = {
[ErrorType.VALIDATION]: 'Invalid Input',
[ErrorType.NETWORK]: 'Connection Error',
[ErrorType.SCRAPING]: 'Unable to Access Website',
[ErrorType.ANALYSIS]: 'Analysis Failed',
[ErrorType.TIMEOUT]: 'Request Timed Out',
[ErrorType.RATE_LIMIT]: 'Rate Limit Exceeded',
[ErrorType.AUTHENTICATION]: 'Authentication Required',
[ErrorType.SERVICE_UNAVAILABLE]: 'Service Unavailable',
[ErrorType.UNKNOWN]: 'Unexpected Error',
};
export function ErrorDisplay({
error,
onRetry,
onDismiss,
className,
}: ErrorDisplayProps) {
const [isRetrying, setIsRetrying] = useState(false);
const Icon = errorIcons[error.type];
const colors = severityColors[error.severity];
const title = errorTitles[error.type];
const recoveryActions = getRecoveryActions(error);
const handleRetry = async () => {
if (!onRetry) return;
setIsRetrying(true);
try {
await onRetry();
} finally {
setIsRetrying(false);
}
};
const handleAction = (action: string) => {
switch (action) {
case 'retry':
handleRetry();
break;
case 'refresh':
window.location.reload();
break;
case 'check_connection':
// Could open a network diagnostic or help page
window.open('https://www.google.com', '_blank');
break;
case 'purchase_credits':
// Navigate to credits purchase page
window.location.href = '/settings/billing';
break;
case 'check_balance':
// Navigate to dashboard
window.location.href = '/dashboard';
break;
case 'sign_in':
// Navigate to sign in
window.location.href = '/auth/login';
break;
case 'check_status':
// Could open status page
console.log('Check service status');
break;
case 'report_issue':
// Could open support form
console.log('Report issue');
break;
case 'wait_retry':
// Wait a bit then retry
setTimeout(handleRetry, 5000);
break;
case 'try_later':
onDismiss?.();
break;
default:
handleRetry();
}
};
return (
<Card className={cn('w-full max-w-2xl mx-auto', className)}>
<CardHeader>
<div className={cn('rounded-lg border p-6', colors.border, colors.bg)}>
<div className="flex items-start space-x-4">
<div className="flex-shrink-0">
<div className={cn('rounded-full p-2', colors.iconBg)}>
<Icon className={cn('size-5', colors.iconColor)} />
</div>
</div>
<div className="flex-1 min-w-0">
<CardTitle
className={cn('text-lg font-semibold', colors.titleColor)}
>
{title}
</CardTitle>
<p className={cn('mt-2 text-sm', colors.textColor)}>
{error.userMessage}
</p>
{/* Show technical details in development */}
{process.env.NODE_ENV === 'development' && (
<details className="mt-3">
<summary
className={cn('text-xs cursor-pointer', colors.textColor)}
>
Technical Details
</summary>
<pre
className={cn(
'mt-2 text-xs whitespace-pre-wrap',
colors.textColor
)}
>
Type: {error.type}
{'\n'}Severity: {error.severity}
{'\n'}Retryable: {error.retryable ? 'Yes' : 'No'}
{'\n'}Message: {error.message}
{error.originalError &&
`\nOriginal: ${error.originalError.message}`}
</pre>
</details>
)}
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{recoveryActions.map((action, index) => (
<Button
key={index}
variant={action.primary ? 'default' : 'outline'}
size="sm"
onClick={() => handleAction(action.action)}
disabled={isRetrying && action.action === 'retry'}
className="flex items-center gap-2 cursor-pointer"
>
{isRetrying && action.action === 'retry' ? (
<RefreshCwIcon className="size-4 animate-spin" />
) : action.action === 'retry' ? (
<RefreshCwIcon className="size-4" />
) : action.action === 'refresh' ? (
<RefreshCwIcon className="size-4" />
) : action.action === 'check_connection' ? (
<WifiOffIcon className="size-4" />
) : action.action === 'purchase_credits' ? (
<CreditCardIcon className="size-4" />
) : action.action === 'sign_in' ? (
<ShieldIcon className="size-4" />
) : (
<InfoIcon className="size-4" />
)}
{action.label}
</Button>
))}
{onDismiss && (
<Button
variant="ghost"
size="sm"
onClick={onDismiss}
className="ml-auto cursor-pointer"
>
Dismiss
</Button>
)}
</div>
</CardContent>
</Card>
);
}
// Simplified error display for inline use
export function InlineErrorDisplay({
error,
onRetry,
className,
}: {
error: WebContentAnalyzerError;
onRetry?: () => void;
className?: string;
}) {
const [isRetrying, setIsRetrying] = useState(false);
const colors = severityColors[error.severity];
const handleRetry = async () => {
if (!onRetry) return;
setIsRetrying(true);
try {
await onRetry();
} finally {
setIsRetrying(false);
}
};
return (
<div
className={cn(
'flex items-center gap-2 p-3 rounded-lg border',
colors.border,
colors.bg,
className
)}
>
<AlertCircleIcon
className={cn('size-4 flex-shrink-0', colors.iconColor)}
/>
<span className={cn('text-sm flex-1', colors.textColor)}>
{error.userMessage}
</span>
{error.retryable && onRetry && (
<Button
variant="ghost"
size="sm"
onClick={handleRetry}
disabled={isRetrying}
className={cn('cursor-pointer h-auto p-1', colors.textColor)}
>
{isRetrying ? (
<RefreshCwIcon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
</Button>
)}
</div>
);
}

View File

@ -1,4 +0,0 @@
export { AnalysisResults } from './analysis-results';
export { LoadingStates } from './loading-states';
export { UrlInputForm } from './url-input-form';
export { WebContentAnalyzer } from './web-content-analyzer';

View File

@ -1,155 +0,0 @@
'use client';
import type { LoadingStatesProps } from '@/ai/text/utils/web-content-analyzer';
import { Progress } from '@/components/ui/progress';
import { BotIcon, Globe2Icon, Loader2Icon, SearchIcon } from 'lucide-react';
import { memo, useEffect, useMemo, useState } from 'react';
export const LoadingStates = memo(function LoadingStates({
stage,
url,
}: LoadingStatesProps) {
const [progress, setProgress] = useState(0);
// Simulate progress animation
useEffect(() => {
const interval = setInterval(() => {
setProgress((prev) => {
if (stage === 'scraping') {
// Scraping progress: 0-60%
return prev < 60 ? prev + 2 : 60;
}
if (stage === 'analyzing') {
// Analyzing progress: 60-100%
return prev < 100 ? prev + 1.5 : 100;
}
return prev;
});
}, 100);
return () => clearInterval(interval);
}, [stage]);
// Reset progress when stage changes
useEffect(() => {
if (stage === 'scraping') {
setProgress(0);
} else if (stage === 'analyzing') {
setProgress(60);
}
}, [stage]);
// Memoize stage configuration to prevent unnecessary recalculations
const config = useMemo(() => {
const hostname = url
? (() => {
try {
return new URL(url).hostname;
} catch {
return 'the webpage';
}
})()
: 'the webpage';
switch (stage) {
case 'scraping':
return {
icon: Globe2Icon,
title: 'Scraping URL...',
description: `Extracting content from ${hostname}`,
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-50 dark:bg-blue-950/20',
borderColor: 'border-blue-200 dark:border-blue-800',
};
case 'analyzing':
return {
icon: BotIcon,
title: 'Analyzing content...',
description: 'AI is processing and structuring the webpage content',
color: 'text-purple-600 dark:text-purple-400',
bgColor: 'bg-purple-50 dark:bg-purple-950/20',
borderColor: 'border-purple-200 dark:border-purple-800',
};
default:
return {
icon: Loader2Icon,
title: 'Processing...',
description: 'Please wait while we process your request',
color: 'text-gray-600 dark:text-gray-400',
bgColor: 'bg-gray-50 dark:bg-gray-950/20',
borderColor: 'border-gray-200 dark:border-gray-800',
};
}
}, [stage, url]);
const IconComponent = config.icon;
return (
<div className="w-full max-w-2xl mx-auto">
<div
className={`rounded-lg border p-6 ${config.bgColor} ${config.borderColor} transition-all duration-300`}
>
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">
<div
className={`rounded-full p-3 ${config.bgColor} ${config.borderColor} border`}
>
<IconComponent
className={`size-6 ${config.color} ${
stage === 'scraping' || stage === 'analyzing'
? 'animate-pulse'
: 'animate-spin'
}`}
aria-hidden="true"
/>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<h3 className={`text-lg font-semibold ${config.color}`}>
{config.title}
</h3>
<span className="text-sm text-muted-foreground font-medium">
{Math.round(progress)}%
</span>
</div>
<p className="text-sm text-muted-foreground mb-4">
{config.description}
</p>
<div className="space-y-2">
<Progress
value={progress}
className="h-2"
aria-label={`${config.title} ${Math.round(progress)}% complete`}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span
className={
stage === 'scraping' || progress >= 60
? config.color
: 'text-muted-foreground'
}
>
Scraping content
</span>
<span
className={
stage === 'analyzing' || progress >= 60
? config.color
: 'text-muted-foreground'
}
>
AI analysis
</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
});

View File

@ -1,158 +0,0 @@
'use client';
import type { UrlInputFormProps } from '@/ai/text/utils/web-content-analyzer';
import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-config';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { LinkIcon, Loader2Icon, SparklesIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useDebounce } from '../utils/performance';
// Form schema for URL input
const urlFormSchema = z.object({
url: z.url().optional(), // Allow empty string for initial state
});
type UrlFormData = z.infer<typeof urlFormSchema>;
export function UrlInputForm({
onSubmit,
isLoading,
disabled = false,
modelProvider,
setModelProvider,
}: UrlInputFormProps) {
const [mounted, setMounted] = useState(false);
// Prevent hydration mismatch by only rendering content after mount
useEffect(() => {
setMounted(true);
}, []);
const form = useForm<UrlFormData>({
resolver: zodResolver(urlFormSchema),
defaultValues: {
url: '',
},
mode: 'onSubmit', // Only validate on submit to avoid premature errors
});
// Watch the URL field for debouncing
const urlValue = form.watch('url');
const debouncedUrl = useDebounce(
urlValue,
webContentAnalyzerConfig.performance.urlInputDebounceMs
);
// Debounced URL validation effect
useEffect(() => {
if (debouncedUrl && debouncedUrl !== urlValue) {
// Trigger validation when debounced value changes
form.trigger('url');
}
}, [debouncedUrl, urlValue, form]);
const handleSubmit = (data: UrlFormData) => {
onSubmit(data.url ?? '', modelProvider);
};
const handleFormSubmit = form.handleSubmit(handleSubmit);
const isFormDisabled = isLoading || disabled;
return (
<>
<div className="w-full max-w-2xl mx-auto">
{/* Model Provider Selection (for mobile/smaller screens, optional) */}
<div className="flex justify-end items-center mb-4">
<Select
value={modelProvider}
onValueChange={setModelProvider}
disabled={isLoading || disabled}
>
<SelectTrigger id="model-provider-select-form" className="w-40">
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="openrouter">OpenRouter</SelectItem>
<SelectItem value="openai">OpenAI GPT-4o</SelectItem>
<SelectItem value="gemini">Google Gemini</SelectItem>
<SelectItem value="deepseek">DeepSeek R1</SelectItem>
</SelectContent>
</Select>
</div>
<Form {...form}>
<form onSubmit={handleFormSubmit} className="space-y-4">
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="relative">
<LinkIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground size-4" />
<Input
{...field}
type="url"
placeholder="https://example.com"
disabled={isFormDisabled}
className="pl-10"
autoComplete="url"
autoFocus
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!mounted ? (
// Show loading state during hydration to prevent mismatch
<Button type="button" disabled className="w-full" size="lg">
<Loader2Icon className="size-4 animate-spin" />
<span>Loading...</span>
</Button>
) : (
<Button
type="submit"
disabled={isFormDisabled || !urlValue?.trim()}
className="w-full"
size="lg"
>
{isLoading ? (
<>
<Loader2Icon className="size-4 animate-spin" />
<span>Analyzing...</span>
</>
) : (
<>
<SparklesIcon className="size-4" />
<span>Analyze Website</span>
</>
)}
</Button>
)}
</form>
</Form>
</div>
</>
);
}

View File

@ -1,461 +0,0 @@
'use client';
import type {
AnalysisState,
AnalyzeContentResponse,
ModelProvider,
WebContentAnalyzerProps,
} from '@/ai/text/utils/web-content-analyzer';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Component, useCallback, useReducer, useState } from 'react';
import { toast } from 'sonner';
import {
ErrorSeverity,
ErrorType,
WebContentAnalyzerError,
classifyError,
logError,
withRetry,
} from '../utils/error-handling';
import { AnalysisResults as AnalysisResultsComponent } from './analysis-results';
import { LoadingStates } from './loading-states';
import { UrlInputForm } from './url-input-form';
// Action types for state reducer
type AnalysisAction =
| { type: 'START_ANALYSIS'; payload: { url: string } }
| { type: 'SET_LOADING_STAGE'; payload: { stage: 'scraping' | 'analyzing' } }
| {
type: 'SET_RESULTS';
payload: { results: AnalysisState['results']; screenshot?: string };
}
| { type: 'SET_ERROR'; payload: { error: string } }
| { type: 'RESET' };
// State reducer for better state management and performance
function analysisReducer(
state: AnalysisState,
action: AnalysisAction
): AnalysisState {
switch (action.type) {
case 'START_ANALYSIS':
return {
...state,
url: action.payload.url,
isLoading: true,
loadingStage: 'scraping',
results: null,
error: null,
screenshot: undefined,
};
case 'SET_LOADING_STAGE':
return {
...state,
loadingStage: action.payload.stage,
};
case 'SET_RESULTS':
return {
...state,
isLoading: false,
loadingStage: null,
results: action.payload.results,
screenshot: action.payload.screenshot,
error: null,
};
case 'SET_ERROR':
return {
...state,
isLoading: false,
loadingStage: null,
error: action.payload.error,
};
case 'RESET':
return {
url: '',
isLoading: false,
loadingStage: null,
results: null,
error: null,
screenshot: undefined,
};
default:
return state;
}
}
// Initial state
const initialState: AnalysisState = {
url: '',
isLoading: false,
loadingStage: null,
results: null,
error: null,
screenshot: undefined,
};
// Error boundary component for handling component errors
class ErrorBoundary extends Component<
{
children: React.ReactNode;
onError: (error: Error) => void;
},
{ hasError: boolean }
> {
constructor(props: {
children: React.ReactNode;
onError: (error: Error) => void;
}) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(_: Error) {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error(
'WebContentAnalyzer Error Boundary caught an error:',
error,
errorInfo
);
this.props.onError(error);
}
render() {
if (this.state.hasError) {
return (
<div className="w-full max-w-2xl mx-auto">
<div className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950/20 p-6">
<div className="flex items-start space-x-4">
<div className="flex-shrink-0">
<div className="rounded-full p-2 bg-red-100 dark:bg-red-900/30">
<svg
className="size-5 text-red-600 dark:text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-red-800 dark:text-red-200">
Component Error
</h3>
<p className="mt-2 text-sm text-red-700 dark:text-red-300">
An unexpected error occurred. Please refresh the page and try
again.
</p>
<div className="mt-4">
<Button
onClick={() => window.location.reload()}
variant="outline"
className="text-red-700 dark:text-red-200 bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 border-red-200 dark:border-red-800"
>
<svg
className="size-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Refresh Page
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export function WebContentAnalyzer({ className }: WebContentAnalyzerProps) {
// Use reducer for better state management and performance
const [state, dispatch] = useReducer(analysisReducer, initialState);
// Model provider state
const [modelProvider, setModelProvider] =
useState<ModelProvider>('openrouter');
// Enhanced error state
const [analyzedError, setAnalyzedError] =
useState<WebContentAnalyzerError | null>(null);
// Handle analysis submission with enhanced error handling
const handleAnalyzeUrl = useCallback(
async (url: string, provider: ModelProvider) => {
// Reset state and start analysis
dispatch({ type: 'START_ANALYSIS', payload: { url } });
setAnalyzedError(null);
try {
// Use retry mechanism for the API call
const result = await withRetry(async () => {
const response = await fetch('/api/analyze-content', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url, modelProvider: provider }),
});
const data: AnalyzeContentResponse = await response.json();
// Handle HTTP errors
if (!response.ok) {
// Create specific error based on status code
let errorType = ErrorType.UNKNOWN;
let severity = ErrorSeverity.MEDIUM;
let retryable = true;
switch (response.status) {
case 400:
errorType = ErrorType.VALIDATION;
retryable = false;
break;
case 408:
errorType = ErrorType.TIMEOUT;
break;
case 422:
errorType = ErrorType.SCRAPING;
break;
case 429:
errorType = ErrorType.RATE_LIMIT;
break;
case 503:
errorType = ErrorType.SERVICE_UNAVAILABLE;
severity = ErrorSeverity.HIGH;
break;
default:
errorType = ErrorType.NETWORK;
}
throw new WebContentAnalyzerError(
errorType,
data.error || `HTTP ${response.status}: ${response.statusText}`,
data.error || 'Failed to analyze website. Please try again.',
severity,
retryable
);
}
if (!data.success || !data.data) {
throw new WebContentAnalyzerError(
ErrorType.ANALYSIS,
data.error || 'Analysis failed',
data.error ||
'Failed to analyze website content. Please try again.',
ErrorSeverity.MEDIUM,
true
);
}
return data;
});
// Update state to analyzing stage
dispatch({
type: 'SET_LOADING_STAGE',
payload: { stage: 'analyzing' },
});
// Simulate a brief delay for analyzing stage to show progress
await new Promise((resolve) => setTimeout(resolve, 1000));
// Set results and complete analysis
dispatch({
type: 'SET_RESULTS',
payload: {
results: result.data!.analysis,
screenshot: result.data!.screenshot,
},
});
// Show success toast - defer to avoid flushSync during render
setTimeout(() => {
toast.success('Website analysis completed successfully!', {
description: `Analyzed ${new URL(url).hostname}`,
});
}, 0);
} catch (error) {
// Classify the error
const analyzedError =
error instanceof WebContentAnalyzerError
? error
: classifyError(error);
// Log the error
logError(analyzedError, { url, component: 'WebContentAnalyzer' });
// Update state with error
dispatch({
type: 'SET_ERROR',
payload: { error: analyzedError.userMessage },
});
// Set the analyzed error for the ErrorDisplay component
setAnalyzedError(analyzedError);
// Show error toast with appropriate severity - defer to avoid flushSync during render
const toastOptions = {
description: analyzedError.userMessage,
};
setTimeout(() => {
switch (analyzedError.severity) {
case ErrorSeverity.CRITICAL:
case ErrorSeverity.HIGH:
toast.error('Analysis Failed', toastOptions);
break;
case ErrorSeverity.MEDIUM:
toast.warning('Analysis Failed', toastOptions);
break;
case ErrorSeverity.LOW:
toast.info('Analysis Issue', toastOptions);
break;
}
}, 0);
}
},
[]
);
// Handle starting a new analysis
const handleNewAnalysis = useCallback(() => {
dispatch({ type: 'RESET' });
setAnalyzedError(null);
}, []);
// Handle component errors
const handleError = useCallback((error: Error) => {
console.error('WebContentAnalyzer component error:', error);
dispatch({
type: 'SET_ERROR',
payload: {
error:
'An unexpected error occurred. Please refresh the page and try again.',
},
});
// Defer toast to avoid flushSync during render
setTimeout(() => {
toast.error('Component error', {
description: 'An unexpected error occurred. Please refresh the page.',
});
}, 0);
}, []);
return (
<ErrorBoundary onError={handleError}>
<div className={cn('w-full space-y-8', className)}>
{/* Main Content Area */}
<div className="space-y-8">
{/* URL Input Form - Always visible */}
{!state.results && (
<UrlInputForm
onSubmit={handleAnalyzeUrl}
isLoading={state.isLoading}
disabled={state.isLoading}
modelProvider={modelProvider}
setModelProvider={setModelProvider}
/>
)}
{/* Loading States */}
{state.isLoading && state.loadingStage && (
<LoadingStates stage={state.loadingStage} url={state.url} />
)}
{/* Error State */}
{state.error && !state.isLoading && (
<div className="w-full max-w-2xl mx-auto">
<div className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950/20 p-6">
<div className="flex items-start space-x-4">
<div className="flex-shrink-0">
<div className="rounded-full p-2 bg-red-100 dark:bg-red-900/30">
<svg
className="size-5 text-red-600 dark:text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-red-800 dark:text-red-200">
Analysis Failed
</h3>
<p className="mt-2 text-sm text-red-700 dark:text-red-300">
{state.error}
</p>
<div className="mt-4">
<Button
onClick={handleNewAnalysis}
variant="outline"
className="text-red-700 dark:text-red-200 bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 border-red-200 dark:border-red-800"
>
<svg
className="size-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Try Again
</Button>
</div>
</div>
</div>
</div>
</div>
)}
{/* Analysis Results */}
{state.results && !state.isLoading && (
<AnalysisResultsComponent
results={state.results}
screenshot={state.screenshot}
onNewAnalysis={handleNewAnalysis}
/>
)}
</div>
</div>
</ErrorBoundary>
);
}

View File

@ -1,331 +0,0 @@
/**
* Error handling utilities for web content analyzer
*/
// Import configuration for performance settings
import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-config';
// Error types for different failure scenarios
export enum ErrorType {
VALIDATION = 'validation',
NETWORK = 'network',
SCRAPING = 'scraping',
ANALYSIS = 'analysis',
TIMEOUT = 'timeout',
RATE_LIMIT = 'rate_limit',
AUTHENTICATION = 'authentication',
SERVICE_UNAVAILABLE = 'service_unavailable',
UNKNOWN = 'unknown',
}
// Error severity levels
export enum ErrorSeverity {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
CRITICAL = 'critical',
}
// Custom error class for web content analyzer
export class WebContentAnalyzerError extends Error {
public readonly type: ErrorType;
public readonly severity: ErrorSeverity;
public readonly retryable: boolean;
public readonly userMessage: string;
public readonly originalError?: Error;
constructor(
type: ErrorType,
message: string,
userMessage: string,
severity: ErrorSeverity = ErrorSeverity.MEDIUM,
retryable = false,
originalError?: Error
) {
super(message);
this.name = 'WebContentAnalyzerError';
this.type = type;
this.severity = severity;
this.retryable = retryable;
this.userMessage = userMessage;
this.originalError = originalError;
}
}
// Error classification function
export function classifyError(error: unknown): WebContentAnalyzerError {
if (error instanceof WebContentAnalyzerError) {
return error;
}
if (error instanceof Error) {
const message = error.message.toLowerCase();
// Network errors
if (
message.includes('network') ||
message.includes('fetch') ||
message.includes('connection') ||
message.includes('econnreset') ||
message.includes('enotfound')
) {
return new WebContentAnalyzerError(
ErrorType.NETWORK,
error.message,
'Network connection failed. Please check your internet connection and try again.',
ErrorSeverity.MEDIUM,
true,
error
);
}
// Timeout errors
if (
message.includes('timeout') ||
message.includes('timed out') ||
message.includes('aborted')
) {
return new WebContentAnalyzerError(
ErrorType.TIMEOUT,
error.message,
'Request timed out. Please try again with a simpler webpage.',
ErrorSeverity.MEDIUM,
true,
error
);
}
// Scraping errors
if (
message.includes('scrape') ||
message.includes('firecrawl') ||
message.includes('webpage') ||
message.includes('content not found')
) {
return new WebContentAnalyzerError(
ErrorType.SCRAPING,
error.message,
'Unable to access the webpage. Please check the URL and try again.',
ErrorSeverity.MEDIUM,
true,
error
);
}
// Analysis errors
if (
message.includes('analyze') ||
message.includes('openai') ||
message.includes('ai') ||
message.includes('model')
) {
return new WebContentAnalyzerError(
ErrorType.ANALYSIS,
error.message,
'Failed to analyze webpage content. Please try again.',
ErrorSeverity.MEDIUM,
true,
error
);
}
// Rate limit errors
if (
message.includes('rate limit') ||
message.includes('too many requests') ||
message.includes('quota')
) {
return new WebContentAnalyzerError(
ErrorType.RATE_LIMIT,
error.message,
'Too many requests. Please wait a moment and try again.',
ErrorSeverity.MEDIUM,
true,
error
);
}
// Authentication errors
if (
message.includes('unauthorized') ||
message.includes('authentication') ||
message.includes('token')
) {
return new WebContentAnalyzerError(
ErrorType.AUTHENTICATION,
error.message,
'Authentication failed. Please refresh the page and try again.',
ErrorSeverity.HIGH,
false,
error
);
}
// Service unavailable errors
if (
message.includes('service unavailable') ||
message.includes('503') ||
message.includes('502') ||
message.includes('500')
) {
return new WebContentAnalyzerError(
ErrorType.SERVICE_UNAVAILABLE,
error.message,
'Service is temporarily unavailable. Please try again later.',
ErrorSeverity.HIGH,
true,
error
);
}
}
// Unknown error
return new WebContentAnalyzerError(
ErrorType.UNKNOWN,
error instanceof Error ? error.message : 'Unknown error occurred',
'An unexpected error occurred. Please try again.',
ErrorSeverity.MEDIUM,
true,
error instanceof Error ? error : undefined
);
}
// Retry configuration
export interface RetryConfig {
maxAttempts: number;
baseDelay: number;
maxDelay: number;
backoffMultiplier: number;
}
export const defaultRetryConfig: RetryConfig = {
maxAttempts: webContentAnalyzerConfig.performance.maxRetryAttempts,
baseDelay: webContentAnalyzerConfig.performance.retryDelayMs,
maxDelay: 10000, // 10 seconds
backoffMultiplier: 2,
};
// Retry utility with exponential backoff
export async function withRetry<T>(
operation: () => Promise<T>,
config: RetryConfig = defaultRetryConfig
): Promise<T> {
let lastError: WebContentAnalyzerError;
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
try {
return await operation();
} catch (error) {
lastError = classifyError(error);
// Don't retry if error is not retryable or this is the last attempt
if (!lastError.retryable || attempt === config.maxAttempts) {
throw lastError;
}
// Calculate delay with exponential backoff
const delay = Math.min(
config.baseDelay * config.backoffMultiplier ** (attempt - 1),
config.maxDelay
);
console.warn(
`Attempt ${attempt} failed, retrying in ${delay}ms:`,
lastError.message
);
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError!;
}
// Error recovery suggestions
export function getRecoveryActions(error: WebContentAnalyzerError): Array<{
label: string;
action: string;
primary?: boolean;
}> {
switch (error.type) {
case ErrorType.NETWORK:
return [
{ label: 'Try Again', action: 'retry', primary: true },
{ label: 'Check Connection', action: 'check_connection' },
];
case ErrorType.TIMEOUT:
return [
{ label: 'Try Again', action: 'retry', primary: true },
{ label: 'Try Simpler URL', action: 'simplify_url' },
];
case ErrorType.SCRAPING:
return [
{ label: 'Try Again', action: 'retry', primary: true },
{ label: 'Check URL', action: 'check_url' },
];
case ErrorType.ANALYSIS:
return [
{ label: 'Try Again', action: 'retry', primary: true },
{ label: 'Report Issue', action: 'report_issue' },
];
case ErrorType.RATE_LIMIT:
return [{ label: 'Wait and Retry', action: 'wait_retry', primary: true }];
case ErrorType.AUTHENTICATION:
return [
{ label: 'Refresh Page', action: 'refresh', primary: true },
{ label: 'Sign In Again', action: 'sign_in' },
];
case ErrorType.SERVICE_UNAVAILABLE:
return [
{ label: 'Try Later', action: 'try_later', primary: true },
{ label: 'Check Status', action: 'check_status' },
];
default:
return [
{ label: 'Try Again', action: 'retry', primary: true },
{ label: 'Refresh Page', action: 'refresh' },
];
}
}
// Error logging utility
export function logError(
error: WebContentAnalyzerError,
context?: Record<string, any>
) {
const logData = {
type: error.type,
severity: error.severity,
message: error.message,
userMessage: error.userMessage,
retryable: error.retryable,
context,
stack: error.stack,
originalError: error.originalError?.message,
timestamp: new Date().toISOString(),
};
// Log based on severity
switch (error.severity) {
case ErrorSeverity.CRITICAL:
console.error('CRITICAL WebContentAnalyzer Error:', logData);
break;
case ErrorSeverity.HIGH:
console.error('HIGH WebContentAnalyzer Error:', logData);
break;
case ErrorSeverity.MEDIUM:
console.warn('MEDIUM WebContentAnalyzer Error:', logData);
break;
case ErrorSeverity.LOW:
console.info('LOW WebContentAnalyzer Error:', logData);
break;
}
}

View File

@ -1,251 +0,0 @@
/**
* Performance optimization utilities for the web content analyzer
*/
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Custom hook for debouncing values
* @param value - The value to debounce
* @param delay - Delay in milliseconds
* @returns The debounced value
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
/**
* Custom hook for throttling function calls
* @param callback - The function to throttle
* @param delay - Delay in milliseconds
* @returns The throttled function
*/
export function useThrottle<T extends (...args: any[]) => any>(
callback: T,
delay: number
): T {
const lastRun = useRef(Date.now());
return useCallback(
((...args) => {
if (Date.now() - lastRun.current >= delay) {
callback(...args);
lastRun.current = Date.now();
}
}) as T,
[callback, delay]
);
}
/**
* Custom hook for lazy loading with Intersection Observer
* @param threshold - Intersection threshold (0-1)
* @param rootMargin - Root margin for the observer
* @returns [ref, isIntersecting] tuple
*/
export function useLazyLoading<T extends HTMLElement = HTMLDivElement>(
threshold = 0.1,
rootMargin = '0px'
): [React.RefObject<T | null>, boolean] {
const [isIntersecting, setIsIntersecting] = useState(false);
const ref = useRef<T | null>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsIntersecting(true);
observer.disconnect();
}
},
{ threshold, rootMargin }
);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, [threshold, rootMargin]);
return [ref, isIntersecting];
}
/**
* Custom hook for memoizing expensive calculations
* @param factory - Function that returns the value to memoize
* @param deps - Dependencies array
* @returns The memoized value
*/
export function useMemoizedValue<T>(
factory: () => T,
deps: React.DependencyList
): T {
const [value, setValue] = useState<T>(factory);
const depsRef = useRef(deps);
useEffect(() => {
// Check if dependencies have changed
const hasChanged = deps.some(
(dep, index) => dep !== depsRef.current[index]
);
if (hasChanged) {
setValue(factory());
depsRef.current = deps;
}
}, deps);
return value;
}
/**
* Utility function to truncate text at word boundaries
* @param text - Text to truncate
* @param maxLength - Maximum length
* @param suffix - Suffix to add when truncated
* @returns Truncated text
*/
export function truncateAtWordBoundary(
text: string,
maxLength: number,
suffix = '...'
): string {
if (text.length <= maxLength) {
return text;
}
const truncated = text.substring(0, maxLength - suffix.length);
const lastSpace = truncated.lastIndexOf(' ');
if (lastSpace > maxLength * 0.8) {
return truncated.substring(0, lastSpace) + suffix;
}
return truncated + suffix;
}
/**
* Utility function to create a stable callback reference
* @param callback - The callback function
* @param deps - Dependencies array
* @returns Stable callback reference
*/
export function useStableCallback<T extends (...args: any[]) => any>(
callback: T,
deps: React.DependencyList
): T {
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, deps);
return useCallback(((...args) => callbackRef.current(...args)) as T, []);
}
/**
* Performance monitoring utility
*/
const timers = new Map<string, number>();
export const PerformanceMonitor = {
start(label: string): void {
timers.set(label, performance.now());
},
end(label: string): number {
const startTime = timers.get(label);
if (!startTime) {
console.warn(`Performance timer '${label}' was not started`);
return 0;
}
const duration = performance.now() - startTime;
timers.delete(label);
if (process.env.NODE_ENV === 'development') {
console.log(`⏱️ ${label}: ${duration.toFixed(2)}ms`);
}
return duration;
},
measure<T>(label: string, fn: () => T): T {
PerformanceMonitor.start(label);
try {
return fn();
} finally {
PerformanceMonitor.end(label);
}
},
async measureAsync<T>(label: string, fn: () => Promise<T>): Promise<T> {
PerformanceMonitor.start(label);
try {
return await fn();
} finally {
PerformanceMonitor.end(label);
}
},
};
/**
* Image optimization utilities
*/
export const ImageOptimization = {
/**
* Create optimized image loading attributes
*/
getOptimizedImageProps: (src: string, alt: string, priority = false) => ({
src,
alt,
loading: priority ? 'eager' : ('lazy' as const),
decoding: 'async' as const,
style: { contentVisibility: 'auto' } as React.CSSProperties,
}),
/**
* Generate responsive image sizes
*/
getResponsiveSizes: (breakpoints: Record<string, string>) => {
return Object.entries(breakpoints)
.map(([breakpoint, size]) => `(max-width: ${breakpoint}) ${size}`)
.join(', ');
},
};
/**
* Content optimization utilities
*/
export const ContentOptimization = {
/**
* Optimize content for display by removing excessive whitespace
*/
optimizeContent: (content: string): string => {
return content
.replace(/\s+/g, ' ') // Replace multiple spaces with single space
.replace(/\n\s*\n/g, '\n\n') // Normalize paragraph breaks
.trim();
},
/**
* Extract preview text from content
*/
extractPreview: (content: string, maxLength = 150): string => {
const cleaned = content.replace(/[#*_`]/g, '').trim();
return truncateAtWordBoundary(cleaned, maxLength);
},
};

View File

@ -1,148 +0,0 @@
/**
* Web Content Analyzer Configuration
*
* This file contains configuration settings for the web content analyzer feature,
* including credit costs and other operational parameters.
*/
export const webContentAnalyzerConfig = {
/**
* Maximum content length for AI analysis (in characters)
* Optimized to prevent token limit issues while maintaining quality
*/
maxContentLength: 8000,
/**
* Content truncation settings for performance optimization
*/
contentTruncation: {
/**
* Preferred truncation point as percentage of max length
* Try to truncate at sentence boundaries when possible
*/
preferredTruncationPoint: 0.8,
/**
* Minimum content length to consider for truncation
*/
minContentLength: 1000,
/**
* Maximum number of sentences to preserve when truncating
*/
maxSentences: 50,
},
/**
* Request timeout in milliseconds
*/
timeoutMillis: 55 * 1000, // 55 seconds
/**
* Performance optimization settings
*/
performance: {
/**
* Debounce delay for URL input (in milliseconds)
*/
urlInputDebounceMs: 500,
/**
* Image lazy loading threshold (intersection observer)
*/
lazyLoadingThreshold: 0.1,
/**
* Maximum number of retry attempts for failed requests
*/
maxRetryAttempts: 3,
/**
* Delay between retry attempts (in milliseconds)
*/
retryDelayMs: 1000,
},
/**
* Firecrawl API configuration and scraping options
*/
firecrawl: {
// API Configuration
apiKey: process.env.FIRECRAWL_API_KEY,
baseUrl: 'https://api.firecrawl.dev',
// Default scraping options
formats: ['markdown', 'screenshot'],
includeTags: ['title', 'meta', 'h1', 'h2', 'h3', 'p', 'article'],
excludeTags: ['script', 'style', 'nav', 'footer', 'aside'],
onlyMainContent: true,
waitFor: 2000,
// Screenshot optimization settings
screenshot: {
quality: 80, // Reduce quality for faster loading
fullPage: false, // Only capture viewport for performance
},
// Rate limiting and timeout settings
rateLimit: {
maxConcurrentRequests: 3,
requestDelay: 1000, // 1 second between requests
},
// Maximum content size (in characters)
maxContentSize: 100000, // 100KB of text content
},
/**
* AI model providers
*/
openai: {
model: 'gpt-4o-mini',
temperature: 0.1, // Low temperature for consistent results
maxTokens: 2000, // Limit response tokens for performance
},
gemini: {
model: 'gemini-2.0-flash',
temperature: 0.1,
maxTokens: 2000,
},
deepseek: {
model: 'deepseek-chat',
temperature: 0.1,
maxTokens: 2000,
},
openrouter: {
// model: 'openrouter/horizon-beta',
// model: 'x-ai/grok-3-beta',
// model: 'openai/gpt-4o-mini',
model: 'deepseek/deepseek-r1:free',
temperature: 0.1,
maxTokens: 2000,
},
} as const;
/**
* Validates if the Firecrawl API key is configured
*/
export function validateFirecrawlConfig(): boolean {
if (!webContentAnalyzerConfig.firecrawl.apiKey) {
console.warn(
'FIRECRAWL_API_KEY is not configured. Web content analysis features will not work.'
);
return false;
}
return true;
}
/**
* Validate if the web content analyzer is properly configured
*/
export function validateWebContentAnalyzerConfig(): boolean {
return (
typeof webContentAnalyzerConfig.maxContentLength === 'number' &&
webContentAnalyzerConfig.maxContentLength > 0 &&
typeof webContentAnalyzerConfig.timeoutMillis === 'number' &&
webContentAnalyzerConfig.timeoutMillis > 0
);
}

View File

@ -1,205 +0,0 @@
import { z } from 'zod';
// Core Analysis Results Interface
export interface AnalysisResults {
title: string;
description: string;
introduction: string;
features: string[];
pricing: string;
useCases: string[];
url: string;
analyzedAt: string;
}
// API Request/Response Interfaces
export interface AnalyzeContentRequest {
url: string;
modelProvider: ModelProvider;
}
export interface AnalyzeContentResponse {
success: boolean;
data?: {
analysis: AnalysisResults;
screenshot?: string;
};
error?: string;
creditsConsumed?: number;
}
// Firecrawl Response Type Definitions
export interface FirecrawlResponse {
success: boolean;
data?: {
markdown: string;
screenshot?: string;
metadata?: {
title?: string;
description?: string;
url?: string;
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
};
};
error?: string;
}
export interface FirecrawlScrapeOptions {
formats?: ('markdown' | 'html' | 'rawHtml' | 'screenshot')[];
includeTags?: string[];
excludeTags?: string[];
onlyMainContent?: boolean;
screenshot?: boolean;
fullPageScreenshot?: boolean;
waitFor?: number;
}
// Analysis State Interface for Component State Management
export interface AnalysisState {
url: string;
isLoading: boolean;
loadingStage: 'scraping' | 'analyzing' | null;
results: AnalysisResults | null;
error: string | null;
screenshot?: string;
}
// Component Props Interfaces
export type ModelProvider = 'openai' | 'gemini' | 'deepseek' | 'openrouter';
export interface WebContentAnalyzerProps {
className?: string;
modelProvider?: ModelProvider;
}
export interface UrlInputFormProps {
onSubmit: (url: string, modelProvider: ModelProvider) => void;
isLoading: boolean;
disabled?: boolean;
modelProvider: ModelProvider;
setModelProvider: (provider: ModelProvider) => void;
}
export interface AnalysisResultsProps {
results: AnalysisResults;
screenshot?: string;
onNewAnalysis: () => void;
}
export interface LoadingStatesProps {
stage: 'scraping' | 'analyzing';
url?: string;
}
// Zod Validation Schemas
// URL Validation Schema
export const urlSchema = z
.url()
.min(1, 'URL is required')
.refine(
(url) => url.startsWith('http://') || url.startsWith('https://'),
'URL must start with http:// or https://'
);
// Analysis Results Schema
export const analysisResultsSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().min(1, 'Description is required'),
introduction: z.string().min(1, 'Introduction is required'),
features: z.array(z.string()).default([]),
pricing: z.string().default('Not specified'),
useCases: z.array(z.string()).default([]),
url: urlSchema,
analyzedAt: z.iso.datetime(),
});
// API Request Schema
export const analyzeContentRequestSchema = z.object({
url: urlSchema,
modelProvider: z.enum(['openai', 'gemini', 'deepseek', 'openrouter']),
});
// API Response Schema
export const analyzeContentResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
analysis: analysisResultsSchema,
screenshot: z.string().optional(),
})
.optional(),
error: z.string().optional(),
creditsConsumed: z.number().optional(),
});
// Firecrawl Response Schema
export const firecrawlResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
markdown: z.string(),
screenshot: z.string().optional(),
metadata: z
.object({
title: z.string().optional(),
description: z.string().optional(),
url: z.string().optional(),
ogTitle: z.string().optional(),
ogDescription: z.string().optional(),
ogImage: z.string().optional(),
})
.optional(),
})
.optional(),
error: z.string().optional(),
});
// Firecrawl Scrape Options Schema
export const firecrawlScrapeOptionsSchema = z.object({
formats: z
.array(z.enum(['markdown', 'html', 'rawHtml', 'screenshot']))
.optional(),
includeTags: z.array(z.string()).optional(),
excludeTags: z.array(z.string()).optional(),
onlyMainContent: z.boolean().optional(),
screenshot: z.boolean().optional(),
fullPageScreenshot: z.boolean().optional(),
waitFor: z.number().optional(),
});
// Type exports for Zod inferred types
export type UrlInput = z.infer<typeof urlSchema>;
export type AnalyzeContentRequestInput = z.infer<
typeof analyzeContentRequestSchema
>;
export type AnalyzeContentResponseInput = z.infer<
typeof analyzeContentResponseSchema
>;
export type FirecrawlResponseInput = z.infer<typeof firecrawlResponseSchema>;
export type FirecrawlScrapeOptionsInput = z.infer<
typeof firecrawlScrapeOptionsSchema
>;
// Validation helper functions
export const validateUrl = (url: string) => {
return urlSchema.safeParse(url);
};
export const validateAnalyzeContentRequest = (data: unknown) => {
return analyzeContentRequestSchema.safeParse(data);
};
export const validateAnalyzeContentResponse = (data: unknown) => {
return analyzeContentResponseSchema.safeParse(data);
};
export const validateFirecrawlResponse = (data: unknown) => {
return firecrawlResponseSchema.safeParse(data);
};
export const validateAnalysisResults = (data: unknown) => {
return analysisResultsSchema.safeParse(data);
};

View File

@ -10,8 +10,8 @@ import LogoCloud from '@/components/blocks/logo-cloud/logo-cloud';
import PricingSection from '@/components/blocks/pricing/pricing';
import StatsSection from '@/components/blocks/stats/stats';
import TestimonialsSection from '@/components/blocks/testimonials/testimonials';
import CrispChat from '@/components/layout/crisp-chat';
import { NewsletterCard } from '@/components/newsletter/newsletter-card';
import DiscordWidget from '@/components/shared/discord-widget';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import type { Metadata } from 'next';
@ -73,8 +73,6 @@ export default async function HomePage(props: HomePageProps) {
<TestimonialsSection />
<NewsletterCard />
<CrispChat />
</div>
</>
);

View File

@ -1,4 +1,5 @@
import Container from '@/components/layout/container';
import { BlurFadeDemo } from '@/components/magicui/example/blur-fade-example';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button, buttonVariants } from '@/components/ui/button';
import { websiteConfig } from '@/config/website';
@ -97,6 +98,9 @@ export default async function AboutPage() {
</div>
</div>
</div>
{/* image section */}
<BlurFadeDemo />
</div>
</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" />
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-4xl text-foreground">{t('content')}</h1>
</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 { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { ImageIcon } from 'lucide-react';
import type { Metadata } from 'next';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
@ -27,21 +26,8 @@ export default async function AIImagePage() {
const t = await getTranslations('AIImagePage');
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">
<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 className="mx-auto space-y-8">
<ImagePlayground suggestions={getRandomSuggestions(5)} />
</div>
);
}

View File

@ -1,7 +1,7 @@
import { WebContentAnalyzer } from '@/ai/text/components/web-content-analyzer';
import { ConsumeCreditCard } from '@/ai/text/components/consume-credit-card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { BotIcon, FileTextIcon, GlobeIcon, ZapIcon } from 'lucide-react';
import type { Metadata } from 'next';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
@ -26,66 +26,32 @@ export default async function AITextPage() {
const t = await getTranslations('AITextPage');
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 className="max-w-4xl mx-auto space-y-8">
{/* about section */}
<div className="relative max-w-(--breakpoint-md) mx-auto mb-24 mt-8 md:mt-16">
<div className="mx-auto flex flex-col justify-between gap-8">
<div className="flex flex-row items-center gap-8">
{/* avatar and name */}
<div className="flex items-center gap-8">
<Avatar className="size-32 p-0.5">
<AvatarImage
className="rounded-full border-4 border-gray-200"
src="/logo.png"
alt="Avatar"
/>
<AvatarFallback>
<div className="size-32 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<h1 className="text-4xl md:text-6xl font-bold tracking-tight bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent">
{t('analyzer.title')}
</h1>
<p className="text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed">
{t('subtitle')}
</p>
</div>
{/* Web Content Analyzer Component */}
<div className="max-w-6xl mx-auto">
<WebContentAnalyzer className="w-full" />
</div>
{/* Features Section */}
<div className="mt-24 grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
<div className="text-center space-y-4">
<div className="inline-flex items-center justify-center size-12 rounded-lg bg-blue-100 dark:bg-blue-900/20">
<GlobeIcon className="size-6 text-blue-600 dark:text-blue-400" />
<div>
<h1 className="text-4xl text-foreground">{t('content')}</h1>
</div>
</div>
<h3 className="text-lg font-semibold">
{t('features.scraping.title')}
</h3>
<p className="text-sm text-muted-foreground">
{t('features.scraping.description')}
</p>
</div>
<div className="text-center space-y-4">
<div className="inline-flex items-center justify-center size-12 rounded-lg bg-green-100 dark:bg-green-900/20">
<BotIcon className="size-6 text-green-600 dark:text-green-400" />
</div>
<h3 className="text-lg font-semibold">
{t('features.analysis.title')}
</h3>
<p className="text-sm text-muted-foreground">
{t('features.analysis.description')}
</p>
</div>
<div className="text-center space-y-4">
<div className="inline-flex items-center justify-center size-12 rounded-lg bg-purple-100 dark:bg-purple-900/20">
<FileTextIcon className="size-6 text-purple-600 dark:text-purple-400" />
</div>
<h3 className="text-lg font-semibold">
{t('features.results.title')}
</h3>
<p className="text-sm text-muted-foreground">
{t('features.results.description')}
</p>
</div>
{/* simulate consume credits */}
<ConsumeCreditCard />
</div>
</div>
</div>

View File

@ -42,6 +42,10 @@ export default async function AIVideoPage() {
<div className="size-32 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-4xl text-foreground">{t('content')}</h1>
</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 { getMDXComponents } from '@/components/docs/mdx-components';
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 { LocaleLink } from '@/i18n/navigation';
import { formatDate } from '@/lib/formatter';
import { constructMetadata } from '@/lib/metadata';
import { checkPremiumAccess } from '@/lib/premium-access';
import { getSession } from '@/lib/server';
import {
type BlogType,
authorSource,
@ -17,7 +13,6 @@ import {
categorySource,
} from '@/lib/source';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
import { CalendarIcon, FileTextIcon } from 'lucide-react';
import type { Metadata } from 'next';
import type { Locale } from 'next-intl';
@ -26,6 +21,7 @@ import Image from 'next/image';
import { notFound } from 'next/navigation';
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,
@ -87,8 +83,7 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
notFound();
}
const { date, title, description, image, author, categories, premium } =
post.data;
const { date, title, description, image, author, categories } = post.data;
const publishDate = formatDate(new Date(date));
const blogAuthor = authorSource.getPage([author], locale);
@ -96,13 +91,6 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
.getPages(locale)
.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;
// 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>
{/* blog post date and premium badge */}
{/* blog post date */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground" />
@ -141,8 +129,6 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
{publishDate}
</span>
</div>
{premium && <PremiumBadge size="sm" />}
</div>
{/* blog post title */}
@ -155,14 +141,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
{/* blog post content */}
{/* in order to make the mdx.css work, we need to add the className prose to the div */}
{/* https://github.com/tailwindlabs/tailwindcss-typography */}
<div className="mt-8">
<PremiumGuard
isPremium={!!premium}
canAccess={hasPremiumAccess}
className="max-w-none"
>
<MDX components={getMDXComponents()} />
</PremiumGuard>
<div className="mt-8 max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg">
<MDX components={getMDXComponents()} />
</div>
<div className="flex items-center justify-start my-16">
@ -232,8 +212,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
{relatedPosts && relatedPosts.length > 0 && (
<div className="flex flex-col gap-8 mt-8">
<div className="flex items-center gap-2">
<FileTextIcon className="size-4 text-primary" />
<h2 className="text-lg tracking-wider font-semibold text-primary">
<FileTextIcon className="size-4 text-muted-foreground" />
<h2 className="text-lg tracking-wider font-semibold text-gradient_indigo-purple">
{t('morePosts')}
</h2>
</div>

View File

@ -1,5 +1,4 @@
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
import { isDemoWebsite } from '@/lib/demo';
import { getSession } from '@/lib/server';
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
@ -10,7 +9,7 @@ interface UsersLayoutProps {
export default async function UsersLayout({ children }: UsersLayoutProps) {
// if is demo website, allow user to access admin and user pages, but data is fake
const isDemo = isDemoWebsite();
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
// Check if user is admin
const session = await getSession();
if (!session || (session.user.role !== 'admin' && !isDemo)) {

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,14 +1,23 @@
import BillingCard from '@/components/settings/billing/billing-card';
import CreditsBalanceCard from '@/components/settings/billing/credits-balance-card';
import { CreditPackages } from '@/components/settings/credits/credit-packages';
import { websiteConfig } from '@/config/website';
/**
* Billing page, show billing information
*/
export default function BillingPage() {
return (
<div className="flex flex-col gap-8">
<div className="space-y-8">
{/* Billing and Credits Balance Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<BillingCard />
{websiteConfig.credits.enableCredits && <CreditsBalanceCard />}
</div>
{/* Credit Packages */}
{websiteConfig.credits.enableCredits && (
<div className="w-full">
<CreditPackages />
</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

@ -1,10 +1,10 @@
import CreditsPageClient from '@/components/settings/credits/credits-page-client';
import { CreditTransactionsPageClient } from '@/components/settings/credits/credit-transactions-page';
import { websiteConfig } from '@/config/website';
import { Routes } from '@/routes';
import { redirect } from 'next/navigation';
/**
* Credits page, show credit balance and transactions
* Credits page, show credit transactions
*/
export default function CreditsPage() {
// If credits are disabled, redirect to billing page
@ -12,5 +12,5 @@ export default function CreditsPage() {
redirect(Routes.SettingsBilling);
}
return <CreditsPageClient />;
return <CreditTransactionsPageClient />;
}

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

@ -5,10 +5,10 @@ export default function ProfilePage() {
return (
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<UpdateNameCard />
<UpdateAvatarCard />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<UpdateAvatarCard />
<UpdateNameCard />
</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

@ -1,17 +1,12 @@
import { DeleteAccountCard } from '@/components/settings/security/delete-account-card';
import { PasswordCardWrapper } from '@/components/settings/security/password-card-wrapper';
import { websiteConfig } from '@/config/website';
export default function SecurityPage() {
const credentialLoginEnabled = websiteConfig.auth.enableCredentialLogin;
return (
<div className="flex flex-col gap-8">
{credentialLoginEnabled && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<PasswordCardWrapper />
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<PasswordCardWrapper />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<DeleteAccountCard />
</div>

View File

@ -1,7 +1,5 @@
import * as Preview from '@/components/docs';
import { getMDXComponents } from '@/components/docs/mdx-components';
import { PremiumBadge } from '@/components/premium/premium-badge';
import { PremiumGuard } from '@/components/premium/premium-guard';
import {
HoverCard,
HoverCardContent,
@ -9,8 +7,6 @@ import {
} from '@/components/ui/hover-card';
import { LOCALES } from '@/i18n/routing';
import { constructMetadata } from '@/lib/metadata';
import { checkPremiumAccess } from '@/lib/premium-access';
import { getSession } from '@/lib/server';
import { source } from '@/lib/source';
import { getUrlWithLocale } from '@/lib/urls/urls';
import Link from 'fumadocs-core/link';
@ -90,14 +86,6 @@ export default async function DocPage({ params }: DocPageProps) {
}
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;
@ -110,54 +98,44 @@ export default async function DocPage({ params }: DocPageProps) {
}}
>
<DocsTitle>{page.data.title}</DocsTitle>
{premium && <PremiumBadge size="sm" className="mt-2" />}
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
{/* Preview Rendered Component */}
{preview ? <PreviewRenderer preview={preview} /> : null}
{/* MDX Content */}
<PremiumGuard
isPremium={!!premium}
canAccess={hasPremiumAccess}
className="max-w-none"
>
<MDX
components={getMDXComponents({
a: ({
href,
...props
}: { href?: string; [key: string]: any }) => {
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 (
<HoverCard>
<HoverCardTrigger asChild>
<Link
href={
found.hash
? `${found.page.url}#${found.hash}`
: found.page.url
}
{...props}
/>
</HoverCardTrigger>
<HoverCardContent className="text-sm">
<p className="font-medium">{found.page.data.title}</p>
<p className="text-fd-muted-foreground">
{found.page.data.description}
</p>
</HoverCardContent>
</HoverCard>
);
},
})}
/>
</PremiumGuard>
return (
<HoverCard>
<HoverCardTrigger asChild>
<Link
href={
found.hash
? `${found.page.url}#${found.hash}`
: found.page.url
}
{...props}
/>
</HoverCardTrigger>
<HoverCardContent className="text-sm">
<p className="font-medium">{found.page.data.title}</p>
<p className="text-fd-muted-foreground">
{found.page.data.description}
</p>
</HoverCardContent>
</HoverCard>
);
},
})}
/>
</DocsBody>
</DocsPage>
);

View File

@ -12,7 +12,6 @@ import { routing } from '@/i18n/routing';
import { cn } from '@/lib/utils';
import { type Locale, NextIntlClientProvider, hasLocale } from 'next-intl';
import { notFound } from 'next/navigation';
import { NuqsAdapter } from 'nuqs/adapters/next/app';
import type { ReactNode } from 'react';
import { Toaster } from 'sonner';
import { Providers } from './providers';
@ -58,17 +57,15 @@ export default async function LocaleLayout({
fontBricolageGrotesque.variable
)}
>
<NuqsAdapter>
<NextIntlClientProvider>
<Providers locale={locale}>
{children}
<NextIntlClientProvider>
<Providers locale={locale}>
{children}
<Toaster richColors position="top-right" offset={64} />
<TailwindIndicator />
<Analytics />
</Providers>
</NextIntlClientProvider>
</NuqsAdapter>
<Toaster richColors position="top-right" offset={64} />
<TailwindIndicator />
<Analytics />
</Providers>
</NextIntlClientProvider>
</body>
</html>
);

View File

@ -1,9 +1,10 @@
'use client';
import { ActiveThemeProvider } from '@/components/layout/active-theme-provider';
import { QueryProvider } from '@/components/providers/query-provider';
import { PaymentProvider } from '@/components/layout/payment-provider';
import { TooltipProvider } from '@/components/ui/tooltip';
import { websiteConfig } from '@/config/website';
import { CreditsProvider } from '@/providers/credits-provider';
import type { Translations } from 'fumadocs-ui/i18n';
import { RootProvider } from 'fumadocs-ui/provider';
import { useTranslations } from 'next-intl';
@ -29,7 +30,7 @@ interface ProvidersProps {
*/
export function Providers({ children, locale }: ProvidersProps) {
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
// make sure `locale` is consistent with your i18n config
@ -53,19 +54,21 @@ export function Providers({ children, locale }: ProvidersProps) {
};
return (
<QueryProvider>
<ThemeProvider
attribute="class"
defaultTheme={defaultMode}
enableSystem={true}
disableTransitionOnChange
>
<ActiveThemeProvider>
<RootProvider theme={theme} i18n={{ locale, locales, translations }}>
<TooltipProvider>{children}</TooltipProvider>
</RootProvider>
</ActiveThemeProvider>
</ThemeProvider>
</QueryProvider>
<ThemeProvider
attribute="class"
defaultTheme={defaultMode}
enableSystem={true}
disableTransitionOnChange
>
<ActiveThemeProvider>
<RootProvider theme={theme} i18n={{ locale, locales, translations }}>
<TooltipProvider>
<PaymentProvider>
<CreditsProvider>{children}</CreditsProvider>
</PaymentProvider>
</TooltipProvider>
</RootProvider>
</ActiveThemeProvider>
</ThemeProvider>
);
}

View File

@ -1,460 +0,0 @@
import {
ErrorSeverity,
ErrorType,
WebContentAnalyzerError,
classifyError,
logError,
withRetry,
} from '@/ai/text/utils/error-handling';
import {
type AnalysisResults,
type AnalyzeContentResponse,
analyzeContentRequestSchema,
validateUrl,
} from '@/ai/text/utils/web-content-analyzer';
import {
validateFirecrawlConfig,
webContentAnalyzerConfig,
} from '@/ai/text/utils/web-content-analyzer-config';
import { createDeepSeek } from '@ai-sdk/deepseek';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { createOpenAI } from '@ai-sdk/openai';
import FirecrawlApp from '@mendable/firecrawl-js';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateObject } from 'ai';
import { type NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
// Constants from configuration
const TIMEOUT_MILLIS = webContentAnalyzerConfig.timeoutMillis;
const MAX_CONTENT_LENGTH = webContentAnalyzerConfig.maxContentLength;
// Initialize Firecrawl client
const getFirecrawlClient = () => {
if (!validateFirecrawlConfig()) {
throw new Error('Firecrawl API key is not configured');
}
return new FirecrawlApp({
apiKey: webContentAnalyzerConfig.firecrawl.apiKey,
});
};
// AI analysis schema for structured output
const analysisSchema = z.object({
title: z.string().describe('Main title or product name from the webpage'),
description: z.string().describe('Brief description in 1-2 sentences'),
introduction: z
.string()
.describe('Detailed introduction paragraph about the content'),
features: z.array(z.string()).describe('List of key features or highlights'),
pricing: z
.string()
.describe('Pricing information or "Not specified" if unavailable'),
useCases: z.array(z.string()).describe('List of use cases or applications'),
});
// Timeout wrapper
const withTimeout = <T>(
promise: Promise<T>,
timeoutMillis: number
): Promise<T> => {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), timeoutMillis)
),
]);
};
// Enhanced content truncation with intelligent boundary detection
const truncateContent = (content: string, maxLength: number): string => {
if (content.length <= maxLength) {
return content;
}
const { contentTruncation } = webContentAnalyzerConfig;
const preferredLength = Math.floor(
maxLength * contentTruncation.preferredTruncationPoint
);
// If content is shorter than minimum threshold, use simple truncation
if (content.length < contentTruncation.minContentLength) {
return content.substring(0, maxLength) + '...';
}
// Try to find the best truncation point
const truncated = content.substring(0, preferredLength);
// First, try to truncate at sentence boundaries
const sentences = content.split(/[.!?]+/);
if (sentences.length > 1) {
let sentenceLength = 0;
let sentenceCount = 0;
for (const sentence of sentences) {
const nextLength = sentenceLength + sentence.length + 1; // +1 for punctuation
if (
nextLength > maxLength ||
sentenceCount >= contentTruncation.maxSentences
) {
break;
}
sentenceLength = nextLength;
sentenceCount++;
}
if (sentenceLength > preferredLength) {
return sentences.slice(0, sentenceCount).join('.') + '.';
}
}
// If sentence boundary doesn't work well, try paragraph boundaries
const paragraphs = content.split(/\n\s*\n/);
if (paragraphs.length > 1) {
let paragraphLength = 0;
for (let i = 0; i < paragraphs.length; i++) {
const nextLength = paragraphLength + paragraphs[i].length + 2; // +2 for \n\n
if (nextLength > maxLength) {
break;
}
paragraphLength = nextLength;
if (paragraphLength > preferredLength) {
return paragraphs.slice(0, i + 1).join('\n\n');
}
}
}
// Fallback to word boundary truncation
const words = truncated.split(' ');
const lastCompleteWord = words.slice(0, -1).join(' ');
if (lastCompleteWord.length > preferredLength) {
return lastCompleteWord + '...';
}
// Final fallback to character truncation
return content.substring(0, maxLength) + '...';
};
// Scrape webpage using Firecrawl with retry logic
async function scrapeWebpage(
url: string
): Promise<{ content: string; screenshot?: string }> {
return withRetry(async () => {
const firecrawl = getFirecrawlClient();
try {
const scrapeResponse = await firecrawl.scrapeUrl(url, {
formats: ['markdown', 'screenshot'],
onlyMainContent: webContentAnalyzerConfig.firecrawl.onlyMainContent,
waitFor: webContentAnalyzerConfig.firecrawl.waitFor,
});
if (!scrapeResponse.success) {
throw new WebContentAnalyzerError(
ErrorType.SCRAPING,
scrapeResponse.error || 'Failed to scrape webpage',
'Unable to access the webpage. Please check the URL and try again.',
ErrorSeverity.MEDIUM,
true
);
}
const content = scrapeResponse.markdown || '';
const screenshot = scrapeResponse.screenshot;
if (!content.trim()) {
throw new WebContentAnalyzerError(
ErrorType.SCRAPING,
'No content found on the webpage',
'The webpage appears to be empty or inaccessible. Please try a different URL.',
ErrorSeverity.MEDIUM,
false
);
}
return {
content: truncateContent(content, MAX_CONTENT_LENGTH),
screenshot,
};
} catch (error) {
if (error instanceof WebContentAnalyzerError) {
throw error;
}
// Classify and throw the error
throw classifyError(error);
}
});
}
// Analyze content using selected provider with retry logic
async function analyzeContent(
content: string,
url: string,
provider: string
): Promise<AnalysisResults> {
return withRetry(async () => {
try {
let model: any;
let temperature: number | undefined;
let maxTokens: number | undefined;
switch (provider) {
case 'openai':
model = createOpenAI({
apiKey: process.env.OPENAI_API_KEY,
}).chat(webContentAnalyzerConfig.openai.model);
temperature = webContentAnalyzerConfig.openai.temperature;
maxTokens = webContentAnalyzerConfig.openai.maxTokens;
break;
case 'gemini':
model = createGoogleGenerativeAI({
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY,
}).chat(webContentAnalyzerConfig.gemini.model);
temperature = webContentAnalyzerConfig.gemini.temperature;
maxTokens = webContentAnalyzerConfig.gemini.maxTokens;
break;
case 'deepseek':
model = createDeepSeek({
apiKey: process.env.DEEPSEEK_API_KEY,
}).chat(webContentAnalyzerConfig.deepseek.model);
temperature = webContentAnalyzerConfig.deepseek.temperature;
maxTokens = webContentAnalyzerConfig.deepseek.maxTokens;
break;
case 'openrouter':
model = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
}).chat(webContentAnalyzerConfig.openrouter.model);
temperature = webContentAnalyzerConfig.openrouter.temperature;
maxTokens = webContentAnalyzerConfig.openrouter.maxTokens;
break;
default:
throw new WebContentAnalyzerError(
ErrorType.VALIDATION,
'Invalid model provider',
'Please select a valid model provider.',
ErrorSeverity.MEDIUM,
false
);
}
const { object } = await generateObject({
model,
schema: analysisSchema,
prompt: `
Analyze the following webpage content and extract structured information.
URL: ${url}
Content: ${content}
Please provide accurate and relevant information based on the content. If certain information is not available, use appropriate defaults:
- For pricing: use "Not specified" if no pricing information is found
- For features and use cases: provide empty arrays if none are found
- Ensure the title and description are meaningful and based on the actual content
`,
temperature,
maxOutputTokens: maxTokens,
});
return {
...object,
url,
analyzedAt: new Date().toISOString(),
};
} catch (error) {
if (error instanceof WebContentAnalyzerError) {
throw error;
}
// Check for specific OpenAI/AI errors
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('rate limit') || message.includes('quota')) {
throw new WebContentAnalyzerError(
ErrorType.RATE_LIMIT,
error.message,
'AI service is temporarily overloaded. Please wait a moment and try again.',
ErrorSeverity.MEDIUM,
true,
error
);
}
if (message.includes('timeout') || message.includes('aborted')) {
throw new WebContentAnalyzerError(
ErrorType.TIMEOUT,
error.message,
'AI analysis timed out. Please try again with a shorter webpage.',
ErrorSeverity.MEDIUM,
true,
error
);
}
}
// Classify and throw the error
throw classifyError(error);
}
});
}
export async function POST(req: NextRequest) {
const requestId = Math.random().toString(36).substring(7);
const startTime = performance.now();
try {
// Parse and validate request
const body = await req.json();
const validationResult = analyzeContentRequestSchema.safeParse(body);
if (!validationResult.success) {
const validationError = new WebContentAnalyzerError(
ErrorType.VALIDATION,
'Invalid request parameters',
'Please provide a valid URL.',
ErrorSeverity.MEDIUM,
false
);
logError(validationError, {
requestId,
validationErrors: validationResult.error,
});
return NextResponse.json(
{
success: false,
error: validationError.userMessage,
} satisfies AnalyzeContentResponse,
{ status: 400 }
);
}
const { url, modelProvider } = validationResult.data;
console.log('modelProvider', modelProvider, 'url', url);
// Additional URL validation
const urlValidation = validateUrl(url);
if (!urlValidation.success) {
const urlError = new WebContentAnalyzerError(
ErrorType.VALIDATION,
urlValidation.error.issues[0]?.message || 'Invalid URL',
'Please enter a valid URL starting with http:// or https://',
ErrorSeverity.MEDIUM,
false
);
logError(urlError, { requestId, url });
return NextResponse.json(
{
success: false,
error: urlError.userMessage,
} satisfies AnalyzeContentResponse,
{ status: 400 }
);
}
// Check if Firecrawl is configured
if (!validateFirecrawlConfig()) {
const configError = new WebContentAnalyzerError(
ErrorType.SERVICE_UNAVAILABLE,
'Firecrawl API key is not configured',
'Web content analysis service is temporarily unavailable.',
ErrorSeverity.CRITICAL,
false
);
logError(configError, { requestId });
return NextResponse.json(
{
success: false,
error: configError.userMessage,
} satisfies AnalyzeContentResponse,
{ status: 503 }
);
}
console.log(`Starting analysis [requestId=${requestId}, url=${url}]`);
// Perform analysis with timeout and enhanced error handling
const analysisPromise = (async () => {
try {
// Step 1: Scrape webpage
const { content, screenshot } = await scrapeWebpage(url);
// Step 2: Analyze content with AI (pass provider)
const analysis = await analyzeContent(content, url, modelProvider);
return { analysis, screenshot };
} catch (error) {
// If it's already a WebContentAnalyzerError, just re-throw
if (error instanceof WebContentAnalyzerError) {
throw error;
}
// Otherwise classify the error
throw classifyError(error);
}
})();
// Apply timeout wrapper
const result = await withTimeout(analysisPromise, TIMEOUT_MILLIS);
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
console.log(
`Analysis completed [requestId=${requestId}, elapsed=${elapsed}s]`
);
return NextResponse.json({
success: true,
data: result,
} satisfies AnalyzeContentResponse);
} catch (error) {
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
// Classify the error if it's not already a WebContentAnalyzerError
const analyzedError =
error instanceof WebContentAnalyzerError ? error : classifyError(error);
// Log the error with context
logError(analyzedError, {
requestId,
elapsed: `${elapsed}s`,
url: req.url,
});
// Determine status code based on error type
let statusCode = 500;
switch (analyzedError.type) {
case ErrorType.VALIDATION:
statusCode = 400;
break;
case ErrorType.TIMEOUT:
statusCode = 408;
break;
case ErrorType.SCRAPING:
statusCode = 422;
break;
case ErrorType.RATE_LIMIT:
statusCode = 429;
break;
case ErrorType.SERVICE_UNAVAILABLE:
statusCode = 503;
break;
default:
statusCode = 500;
}
return NextResponse.json(
{
success: false,
error: analyzedError.userMessage,
} satisfies AnalyzeContentResponse,
{ status: statusCode }
);
}
}

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

@ -1,60 +0,0 @@
import { distributeCreditsToAllUsers } from '@/credits/distribute';
import { NextResponse } from 'next/server';
// Basic authentication middleware
function validateBasicAuth(request: Request): boolean {
const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Basic ')) {
return false;
}
// Extract credentials from Authorization header
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString(
'utf-8'
);
const [username, password] = credentials.split(':');
// Validate against environment variables
const expectedUsername = process.env.CRON_JOBS_USERNAME;
const expectedPassword = process.env.CRON_JOBS_PASSWORD;
if (!expectedUsername || !expectedPassword) {
console.error(
'Basic auth credentials not configured in environment variables'
);
return false;
}
return username === expectedUsername && password === expectedPassword;
}
/**
* distribute credits to all users daily
*/
export async function GET(request: Request) {
// Validate basic authentication
if (!validateBasicAuth(request)) {
console.error('distribute credits unauthorized');
return new NextResponse('Unauthorized', {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="Secure Area"',
},
});
}
console.log('route: distribute credits start');
const { usersCount, processedCount, errorCount } =
await distributeCreditsToAllUsers();
console.log(
`route: distribute credits end, users: ${usersCount}, processed: ${processedCount}, errors: ${errorCount}`
);
return NextResponse.json({
message: `distribute credits success, users: ${usersCount}, processed: ${processedCount}, errors: ${errorCount}`,
usersCount,
processedCount,
errorCount,
});
}

View File

@ -0,0 +1,20 @@
import { inngest } from '@/inngest/client';
import { NextResponse } from 'next/server';
// Opt out of caching; every request should send a new event
export const dynamic = 'force-dynamic';
// Create a simple async Next.js API route handler
export async function GET() {
console.log('Send event to Inngest start');
// Send your event payload to Inngest
await inngest.send({
name: 'test/hello.world',
data: {
email: 'testUser@example.com',
},
});
console.log('Send event to Inngest end');
return NextResponse.json({ message: 'Event sent!' });
}

View File

@ -0,0 +1,19 @@
import { serve } from 'inngest/next';
import { inngest } from '../../../inngest/client';
import { distributeCreditsDaily, helloWorld } from '../../../inngest/functions';
/**
* Inngest route
*
* https://www.inngest.com/docs/getting-started/nextjs-quick-start
*
* Next.js Edge Functions hosted on Vercel can also stream responses back to Inngest,
* giving you a much higher request timeout of 15 minutes (up from 10 seconds on the Vercel Hobby plan!).
* To enable this, set your runtime to "edge" (see Quickstart for Using Edge Functions | Vercel Docs)
* and add the streaming: "allow" option to your serve handler:
* https://www.inngest.com/docs/learn/serving-inngest-functions#framework-next-js
*/
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [helloWorld, distributeCreditsDaily],
});

View File

@ -14,6 +14,8 @@ type Href = Parameters<typeof getLocalePathname>[0]['href'];
const staticRoutes = [
'/',
'/pricing',
'/blog',
'/docs',
'/about',
'/contact',
'/waitlist',
@ -23,8 +25,6 @@ const staticRoutes = [
'/cookie',
'/auth/login',
'/auth/register',
...(websiteConfig.blog.enable ? ['/blog'] : []),
...(websiteConfig.docs.enable ? ['/docs'] : []),
];
/**
@ -48,106 +48,101 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
})
);
// add blog related routes if enabled
if (websiteConfig.blog.enable) {
// add categories
sitemapList.push(
...categorySource.getPages().flatMap((category) =>
routing.locales.map((locale) => ({
url: getUrl(`/blog/category/${category.slugs[0]}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
}))
)
);
// add categories
sitemapList.push(
...categorySource.getPages().flatMap((category) =>
routing.locales.map((locale) => ({
url: getUrl(`/blog/category/${category.slugs[0]}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
}))
)
);
// add paginated blog list pages
routing.locales.forEach((locale) => {
const posts = blogSource
// add paginated blog list pages
routing.locales.forEach((locale) => {
const posts = blogSource
.getPages(locale)
.filter((post) => post.data.published);
const totalPages = Math.max(
1,
Math.ceil(posts.length / websiteConfig.blog.paginationSize)
);
// /blog/page/[page] (from 2)
for (let page = 2; page <= totalPages; page++) {
sitemapList.push({
url: getUrl(`/blog/page/${page}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
});
}
});
// add paginated category pages
routing.locales.forEach((locale) => {
const localeCategories = categorySource.getPages(locale);
localeCategories.forEach((category) => {
// posts in this category and locale
const postsInCategory = blogSource
.getPages(locale)
.filter((post) => post.data.published);
.filter((post) => post.data.published)
.filter((post) =>
post.data.categories.some((cat) => cat === category.slugs[0])
);
const totalPages = Math.max(
1,
Math.ceil(posts.length / websiteConfig.blog.paginationSize)
Math.ceil(postsInCategory.length / websiteConfig.blog.paginationSize)
);
// /blog/page/[page] (from 2)
// /blog/category/[slug] (first page)
sitemapList.push({
url: getUrl(`/blog/category/${category.slugs[0]}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
});
// /blog/category/[slug]/page/[page] (from 2)
for (let page = 2; page <= totalPages; page++) {
sitemapList.push({
url: getUrl(`/blog/page/${page}`, locale),
url: getUrl(
`/blog/category/${category.slugs[0]}/page/${page}`,
locale
),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
});
}
});
});
// add paginated category pages
routing.locales.forEach((locale) => {
const localeCategories = categorySource.getPages(locale);
localeCategories.forEach((category) => {
// posts in this category and locale
const postsInCategory = blogSource
.getPages(locale)
.filter((post) => post.data.published)
.filter((post) =>
post.data.categories.some((cat) => cat === category.slugs[0])
);
const totalPages = Math.max(
1,
Math.ceil(postsInCategory.length / websiteConfig.blog.paginationSize)
);
// /blog/category/[slug] (first page)
sitemapList.push({
url: getUrl(`/blog/category/${category.slugs[0]}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
});
// /blog/category/[slug]/page/[page] (from 2)
for (let page = 2; page <= totalPages; page++) {
sitemapList.push({
url: getUrl(
`/blog/category/${category.slugs[0]}/page/${page}`,
locale
),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
});
}
});
});
// add posts (single post pages)
sitemapList.push(
...blogSource.getPages().flatMap((post) =>
routing.locales
.filter((locale) => post.locale === locale)
.map((locale) => ({
url: getUrl(`/blog/${post.slugs.join('/')}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
}))
)
);
}
// add docs related routes if enabled
if (websiteConfig.docs.enable) {
const docsParams = source.generateParams();
sitemapList.push(
...docsParams.flatMap((param) =>
routing.locales.map((locale) => ({
url: getUrl(`/docs/${param.slug.join('/')}`, locale),
// add posts (single post pages)
sitemapList.push(
...blogSource.getPages().flatMap((post) =>
routing.locales
.filter((locale) => post.locale === locale)
.map((locale) => ({
url: getUrl(`/blog/${post.slugs.join('/')}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
}))
)
);
}
)
);
// add docs
const docsParams = source.generateParams();
sitemapList.push(
...docsParams.flatMap((param) =>
routing.locales.map((locale) => ({
url: getUrl(`/docs/${param.slug.join('/')}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
}))
)
);
return sitemapList;
}

View File

@ -6,6 +6,7 @@ import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
@ -20,12 +21,12 @@ import {
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { useIsMobile } from '@/hooks/use-mobile';
import { useBanUser, useUnbanUser } from '@/hooks/use-users';
import { authClient } from '@/lib/auth-client';
import type { User } from '@/lib/auth-types';
import { isDemoWebsite } from '@/lib/demo';
import { formatDate } from '@/lib/formatter';
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
import { cn } from '@/lib/utils';
import { useUsersStore } from '@/stores/users-store';
import {
CalendarIcon,
Loader2Icon,
@ -45,16 +46,14 @@ interface UserDetailViewerProps {
export function UserDetailViewer({ user }: UserDetailViewerProps) {
const t = useTranslations('Dashboard.admin.users');
const isMobile = useIsMobile();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | undefined>();
const [banReason, setBanReason] = useState(t('ban.defaultReason'));
const [banExpiresAt, setBanExpiresAt] = useState<Date | undefined>();
// TanStack Query mutations
const banUserMutation = useBanUser();
const unbanUserMutation = useUnbanUser();
const triggerRefresh = useUsersStore((state) => state.triggerRefresh);
// show fake data in demo website
const isDemo = isDemoWebsite();
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
const handleBan = async () => {
if (!banReason) {
@ -67,10 +66,11 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
return;
}
setIsLoading(true);
setError('');
try {
await banUserMutation.mutateAsync({
await authClient.admin.banUser({
userId: user.id,
banReason,
banExpiresIn: banExpiresAt
@ -82,11 +82,15 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
// Reset form
setBanReason('');
setBanExpiresAt(undefined);
// Trigger refresh
triggerRefresh();
} catch (err) {
const error = err as Error;
console.error('Failed to ban user:', error);
setError(error.message || t('ban.error'));
toast.error(error.message || t('ban.error'));
} finally {
setIsLoading(false);
}
};
@ -96,19 +100,24 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
return;
}
setIsLoading(true);
setError('');
try {
await unbanUserMutation.mutateAsync({
await authClient.admin.unbanUser({
userId: user.id,
});
toast.success(t('unban.success'));
// Trigger refresh
triggerRefresh();
} catch (err) {
const error = err as Error;
console.error('Failed to unban user:', error);
setError(error.message || t('unban.error'));
toast.error(error.message || t('unban.error'));
} finally {
setIsLoading(false);
}
};
@ -156,7 +165,7 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
{user.role === 'admin' ? t('admin') : t('user')}
</Badge>
{/* email verified */}
{/* <Badge variant="outline" className="px-1.5 hover:bg-accent">
<Badge variant="outline" className="px-1.5 hover:bg-accent">
{user.emailVerified ? (
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
) : (
@ -165,7 +174,7 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
{user.emailVerified
? t('email.verified')
: t('email.unverified')}
</Badge> */}
</Badge>
{/* user banned */}
<div className="flex items-center gap-2">
@ -186,23 +195,15 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
<span className="text-muted-foreground text-xs">
{t('columns.email')}:
</span>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="text-sm px-1.5 cursor-pointer hover:bg-accent"
onClick={() => {
navigator.clipboard.writeText(user.email);
toast.success(t('emailCopied'));
}}
>
{user.emailVerified ? (
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
) : (
<MailQuestionIcon className="stroke-red-500 dark:stroke-red-400" />
)}
{user.email}
</Badge>
</div>
<span
className="break-words cursor-pointer hover:bg-accent px-2 py-1 rounded border"
onClick={() => {
navigator.clipboard.writeText(user.email!);
toast.success(t('emailCopied'));
}}
>
{user.email}
</span>
</div>
)}
@ -254,10 +255,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
<Button
variant="destructive"
onClick={handleUnban}
disabled={unbanUserMutation.isPending || isDemo}
disabled={isLoading || isDemo}
className="mt-4 cursor-pointer"
>
{unbanUserMutation.isPending && (
{isLoading && (
<Loader2Icon className="mr-2 size-4 animate-spin" />
)}
{t('unban.button')}
@ -313,10 +314,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
<Button
type="submit"
variant="destructive"
disabled={banUserMutation.isPending || !banReason || isDemo}
disabled={isLoading || !banReason || isDemo}
className="mt-4 cursor-pointer"
>
{banUserMutation.isPending && (
{isLoading && (
<Loader2Icon className="mr-2 size-4 animate-spin" />
)}
{t('ban.button')}

View File

@ -1,59 +1,74 @@
'use client';
import { getUsersAction } from '@/actions/get-users';
import { UsersTable } from '@/components/admin/users-table';
import { useUsers } from '@/hooks/use-users';
import type { User } from '@/lib/auth-types';
import { useUsersStore } from '@/stores/users-store';
import type { SortingState } from '@tanstack/react-table';
import { useTranslations } from 'next-intl';
import {
parseAsIndex,
parseAsInteger,
parseAsString,
useQueryStates,
} from 'nuqs';
import { useMemo } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
export function UsersPageClient() {
const t = useTranslations('Dashboard.admin.users');
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState('');
const [data, setData] = useState<User[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([
{ id: 'createdAt', desc: true },
]);
const refreshTrigger = useUsersStore((state) => state.refreshTrigger);
const [{ page, pageSize, search, sortId, sortDesc }, setQueryStates] =
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 fetchUsers = useCallback(async () => {
try {
setLoading(true);
const result = await getUsersAction({
pageIndex,
pageSize,
search,
sorting,
});
const sorting: SortingState = useMemo(
() => [{ id: sortId, desc: Boolean(sortDesc) }],
[sortId, sortDesc]
);
if (result?.data?.success) {
setData(result.data.data?.items || []);
setTotal(result.data.data?.total || 0);
} else {
const errorMessage = result?.data?.error || t('error');
toast.error(errorMessage);
setData([]);
setTotal(0);
}
} catch (error) {
console.error('Failed to fetch users:', error);
toast.error(t('error'));
setData([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [pageIndex, pageSize, search, sorting, refreshTrigger]);
// page is already 0-based internally thanks to parseAsIndex
const { data, isLoading } = useUsers(page, pageSize, search, sorting);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
return (
<UsersTable
data={data?.items || []}
total={data?.total || 0}
pageIndex={page}
pageSize={pageSize}
search={search}
sorting={sorting}
loading={isLoading}
onSearch={(newSearch) => setQueryStates({ search: newSearch, page: 0 })}
onPageChange={(newPageIndex) => setQueryStates({ page: newPageIndex })}
onPageSizeChange={(newPageSize) =>
setQueryStates({ pageSize: newPageSize, page: 0 })
}
onSortingChange={(newSorting) => {
if (newSorting.length > 0) {
setQueryStates({
sortId: newSorting[0].id,
sortDesc: newSorting[0].desc ? 1 : 0,
});
}
}}
/>
<>
<UsersTable
data={data}
total={total}
pageIndex={pageIndex}
pageSize={pageSize}
search={search}
loading={loading}
onSearch={setSearch}
onPageChange={setPageIndex}
onPageSizeChange={setPageSize}
onSortingChange={setSorting}
/>
</>
);
}

View File

@ -27,7 +27,6 @@ import {
TableRow,
} from '@/components/ui/table';
import type { User } from '@/lib/auth-types';
import { isDemoWebsite } from '@/lib/demo';
import { formatDate } from '@/lib/formatter';
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
import { IconCaretDownFilled, IconCaretUpFilled } from '@tabler/icons-react';
@ -59,7 +58,6 @@ import { useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '../ui/badge';
import { Label } from '../ui/label';
import { Skeleton } from '../ui/skeleton';
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
@ -117,27 +115,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 {
data: User[];
total: number;
pageIndex: number;
pageSize: number;
search: string;
sorting?: SortingState;
loading?: boolean;
onSearch: (search: string) => void;
onPageChange: (page: number) => void;
@ -154,7 +137,6 @@ export function UsersTable({
pageIndex,
pageSize,
search,
sorting = [{ id: 'createdAt', desc: true }],
loading,
onSearch,
onPageChange,
@ -163,11 +145,14 @@ export function UsersTable({
}: UsersTableProps) {
const t = useTranslations('Dashboard.admin.users');
const tTable = useTranslations('Common.table');
const [sorting, setSorting] = useState<SortingState>([
{ id: 'createdAt', desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
// show fake data in demo website
const isDemo = isDemoWebsite();
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
// Map column IDs to translation keys
const columnIdToTranslationKey = {
@ -365,6 +350,7 @@ export function UsersTable({
},
onSortingChange: (updater) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
setSorting(next);
onSortingChange?.(next);
},
onColumnFiltersChange: setColumnFilters,
@ -457,12 +443,7 @@ export function UsersTable({
))}
</TableHeader>
<TableBody>
{loading ? (
// Show skeleton rows while loading
Array.from({ length: pageSize }).map((_, index) => (
<TableRowSkeleton key={index} columns={columns.length} />
))
) : table.getRowModel().rows?.length ? (
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
@ -484,7 +465,7 @@ export function UsersTable({
colSpan={columns.length}
className="h-24 text-center"
>
{tTable('noResults')}
{loading ? tTable('loading') : tTable('noResults')}
</TableCell>
</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';

Some files were not shown because too many files have changed in this diff Show More