Compare commits
No commits in common. "cloudflare" and "dev/credits-v2" have entirely different histories.
cloudflare
...
dev/credit
@ -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
41
.gitattributes
vendored
@ -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
6
.gitignore
vendored
@ -41,12 +41,6 @@ certificates
|
||||
# claude code
|
||||
.claude
|
||||
|
||||
# conductor
|
||||
.conductor
|
||||
|
||||
# kiro
|
||||
.kiro
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@ -4,7 +4,6 @@
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"Lokalise.i18n-ally",
|
||||
"unifiedjs.vscode-mdx",
|
||||
"eamodio.gitlens",
|
||||
"editorconfig.editorconfig"
|
||||
"eamodio.gitlens"
|
||||
]
|
||||
}
|
||||
|
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
||||
|
21
biome.json
21
biome.json
@ -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
7483
cloudflare-env.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@ -1,56 +0,0 @@
|
||||
---
|
||||
title: "Premium Blog Post"
|
||||
description: "This blog post is a test for premium content."
|
||||
date: "2025-08-30"
|
||||
published: true
|
||||
premium: true
|
||||
categories: ["product"]
|
||||
author: "fox"
|
||||
image: "/images/blog/post-7.png"
|
||||
---
|
||||
|
||||
This blog post is a test for premium content.
|
||||
|
||||
You can read this part of the blog post if you are not a premium user.
|
||||
|
||||
But for the rest of the blog post, you need to be logged in as a premium user.
|
||||
|
||||
You can click the "Sign In" button to sign in as a user with free plan.
|
||||
|
||||
Then you can click the "Upgrade Now" button to upgrade to a premium plan.
|
||||
|
||||
<Callout type="warn">
|
||||
Don't worry, you don't actually pay any cents, because we are in the sandbox environment of Stripe.
|
||||
</Callout>
|
||||
|
||||
You can use the test card number to pay for monthly or yearly PRO plan or LIFETIME plan.
|
||||
|
||||
```
|
||||
Card number: 4242 4242 4242 4242
|
||||
Exp: 12/34
|
||||
CVV: 567
|
||||
```
|
||||
|
||||
After that, you can return to the blog post and you can read the rest of the blog post.
|
||||
|
||||
For more details, please check out the documentation: [Blog](https://mksaas.com/docs/blog).
|
||||
|
||||
Now the rest of the blog post is premium content.
|
||||
|
||||
<PremiumContent>
|
||||
|
||||
<Callout type="info">
|
||||
This is the beginning of the premium content part.
|
||||
</Callout>
|
||||
|
||||
This is the premium content part.
|
||||
|
||||
You can read this paragraph only if you are a premium user.
|
||||
|
||||
Please don't share this blog post with others.
|
||||
|
||||
<Callout type="info">
|
||||
This is the end of the premium content part.
|
||||
</Callout>
|
||||
|
||||
</PremiumContent>
|
@ -1,56 +0,0 @@
|
||||
---
|
||||
title: "测试专用付费文章"
|
||||
description: "这是一篇测试专用付费文章。"
|
||||
date: "2025-08-30"
|
||||
published: true
|
||||
premium: true
|
||||
categories: ["product"]
|
||||
author: "fox"
|
||||
image: "/images/blog/post-7.png"
|
||||
---
|
||||
|
||||
这是一篇测试专用的付费文章。
|
||||
|
||||
如果你不是付费用户,你可以阅读这篇文章的这部分内容。
|
||||
|
||||
但如果你想阅读剩下的内容,你需要成为一个付费用户。
|
||||
|
||||
你可以点击 "登录" 按钮来以免费用户的身份登录。
|
||||
|
||||
然后你可以点击 "立即升级" 按钮来升级到付费计划。
|
||||
|
||||
<Callout type="warn">
|
||||
不用担心,你实际上不需要支付任何费用,因为我们处于 Stripe 的沙盒环境中。
|
||||
</Callout>
|
||||
|
||||
你可以使用测试卡号来支付月度或年度 PRO 计划或终身计划。
|
||||
|
||||
```
|
||||
Card number: 4242 4242 4242 4242
|
||||
Exp: 12/34
|
||||
CVV: 567
|
||||
```
|
||||
|
||||
之后,你可以返回这篇博客文章,然后你可以阅读剩下的内容。
|
||||
|
||||
更多详情,请参考文档:[博客](https://mksaas.com/docs/blog)。
|
||||
|
||||
现在剩下的内容是付费内容。
|
||||
|
||||
<PremiumContent>
|
||||
|
||||
<Callout type="info">
|
||||
这是付费内容部分的开始。
|
||||
</Callout>
|
||||
|
||||
这是付费内容部分。
|
||||
|
||||
你可以阅读这篇内容,只要你是一个付费用户。
|
||||
|
||||
请不要分享这篇文章给其他人。
|
||||
|
||||
<Callout type="info">
|
||||
这是付费内容部分的结束。
|
||||
</Callout>
|
||||
|
||||
</PremiumContent>
|
@ -2,7 +2,6 @@
|
||||
title: What is Fumadocs
|
||||
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>
|
||||
|
@ -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 变得更加有用!
|
@ -1 +0,0 @@
|
||||
NEXTJS_ENV=development
|
43
env.example
43
env.example
@ -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=""
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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": "正在开发中"
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -1,6 +0,0 @@
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||
|
||||
|
||||
export default defineCloudflareConfig({
|
||||
|
||||
});
|
47
package.json
47
package.json
@ -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
7959
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,2 +0,0 @@
|
||||
/_next/static/*
|
||||
Cache-Control: public,max-age=31536000,immutable
|
129
public/sw.js
Normal file
129
public/sw.js
Normal file
@ -0,0 +1,129 @@
|
||||
// Service Worker for caching iframe content
|
||||
const CACHE_NAME = 'cnblocks-iframe-cache-v1'
|
||||
|
||||
// Add iframe URLs to this list to prioritize caching
|
||||
const URLS_TO_CACHE = [
|
||||
// Default assets that should be cached
|
||||
'/favicon.ico',
|
||||
// Images used in iframes
|
||||
'/payments.png',
|
||||
'/payments-light.png',
|
||||
'/origin-cal.png',
|
||||
'/origin-cal-dark.png',
|
||||
'/exercice.png',
|
||||
'/exercice-dark.png',
|
||||
'/charts-light.png',
|
||||
'/charts.png',
|
||||
'/music-light.png',
|
||||
'/music.png',
|
||||
'/mail-back-light.png',
|
||||
'/mail-upper.png',
|
||||
'/mail-back.png',
|
||||
'/card.png',
|
||||
'/dark-card.webp',
|
||||
]
|
||||
|
||||
// Install event - cache resources
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
console.log('Opened cache')
|
||||
return cache.addAll(URLS_TO_CACHE)
|
||||
})
|
||||
.then(() => self.skipWaiting()) // Activate SW immediately
|
||||
)
|
||||
})
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
const currentCaches = [CACHE_NAME]
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((cacheNames) => {
|
||||
return cacheNames.filter((cacheName) => !currentCaches.includes(cacheName))
|
||||
})
|
||||
.then((cachesToDelete) => {
|
||||
return Promise.all(
|
||||
cachesToDelete.map((cacheToDelete) => {
|
||||
return caches.delete(cacheToDelete)
|
||||
})
|
||||
)
|
||||
})
|
||||
.then(() => self.clients.claim()) // Take control of clients immediately
|
||||
)
|
||||
})
|
||||
|
||||
// Fetch event - serve from cache or fetch from network and cache
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Check if this is an iframe request - typically they'll be HTML or have 'preview' in the URL
|
||||
const isIframeRequest = event.request.url.includes('/preview/') || event.request.url.includes('/examples/')
|
||||
|
||||
if (isIframeRequest) {
|
||||
event.respondWith(
|
||||
caches.match(event.request, { ignoreSearch: true }).then((response) => {
|
||||
// Return cached response if found
|
||||
if (response) {
|
||||
return response
|
||||
}
|
||||
|
||||
// Clone the request (requests are one-time use)
|
||||
const fetchRequest = event.request.clone()
|
||||
|
||||
return fetch(fetchRequest).then((response) => {
|
||||
// Check if we received a valid response
|
||||
if (!response || response.status !== 200 || response.type !== 'basic') {
|
||||
return response
|
||||
}
|
||||
|
||||
// Clone the response (responses are one-time use)
|
||||
const responseToCache = response.clone()
|
||||
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache)
|
||||
})
|
||||
|
||||
return response
|
||||
})
|
||||
})
|
||||
)
|
||||
} else {
|
||||
// For non-iframe requests, use a standard cache-first strategy
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((response) => {
|
||||
if (response) {
|
||||
return response
|
||||
}
|
||||
return fetch(event.request)
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for messages from clients (to force cache update, etc)
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting()
|
||||
}
|
||||
|
||||
// Handle cache clearing
|
||||
if (event.data && event.data.type === 'CLEAR_IFRAME_CACHE') {
|
||||
const url = event.data.url
|
||||
|
||||
if (url) {
|
||||
// Clear specific URL from cache
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.delete(url).then(() => {
|
||||
console.log(`Cleared cache for: ${url}`)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// Clear the entire cache
|
||||
caches.delete(CACHE_NAME).then(() => {
|
||||
console.log('Cleared entire iframe cache')
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
@ -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();
|
@ -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(),
|
||||
}),
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
|
@ -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 {
|
||||
|
@ -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 };
|
||||
});
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
51
src/ai/text/components/consume-credit-card.tsx
Normal file
51
src/ai/text/components/consume-credit-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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';
|
@ -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>
|
||||
);
|
||||
});
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
},
|
||||
};
|
@ -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
|
||||
);
|
||||
}
|
@ -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);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -1,13 +0,0 @@
|
||||
import Container from '@/components/layout/container';
|
||||
import { ConsumeCreditsCard } from '@/components/test/consume-credits-card';
|
||||
|
||||
export default async function TestPage() {
|
||||
return (
|
||||
<Container className="py-16 px-4">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* credits test */}
|
||||
<ConsumeCreditsCard />
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
@ -42,6 +42,10 @@ export default async function AIAudioPage() {
|
||||
<div className="size-32 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div>
|
||||
<h1 className="text-4xl text-foreground">{t('content')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,46 +0,0 @@
|
||||
import ChatBot from '@/ai/chat/components/ChatBot';
|
||||
import { constructMetadata } from '@/lib/metadata';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { ZapIcon } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import type { Locale } from 'next-intl';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}): Promise<Metadata | undefined> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Metadata' });
|
||||
const pt = await getTranslations({ locale, namespace: 'AIChatPage' });
|
||||
|
||||
return constructMetadata({
|
||||
title: pt('title') + ' | ' + t('title'),
|
||||
description: pt('description'),
|
||||
canonicalUrl: getUrlWithLocale('/ai/chat', locale),
|
||||
});
|
||||
}
|
||||
|
||||
export default async function AIChatPage() {
|
||||
const t = await getTranslations('AIChatPage');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/50 rounded-lg">
|
||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||
{/* Header Section */}
|
||||
<div className="text-center space-y-6 mb-12">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium">
|
||||
<ZapIcon className="size-4" />
|
||||
{t('title')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Bot */}
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<ChatBot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -2,7 +2,6 @@ import { ImagePlayground } from '@/ai/image/components/ImagePlayground';
|
||||
import { getRandomSuggestions } from '@/ai/image/lib/suggestions';
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
16
src/app/[locale]/(marketing)/blocks/[category]/layout.tsx
Normal file
16
src/app/[locale]/(marketing)/blocks/[category]/layout.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { categories } from '@/components/tailark/blocks';
|
||||
import BlocksNav from '@/components/tailark/blocks-nav';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
/**
|
||||
* The locale inconsistency issue has been fixed in the BlocksNav component
|
||||
*/
|
||||
export default function BlockCategoryLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<>
|
||||
<BlocksNav categories={categories} />
|
||||
|
||||
<main>{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
54
src/app/[locale]/(marketing)/blocks/[category]/page.tsx
Normal file
54
src/app/[locale]/(marketing)/blocks/[category]/page.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import BlockPreview from '@/components/tailark/block-preview';
|
||||
import { blocks, categories } from '@/components/tailark/blocks';
|
||||
import { constructMetadata } from '@/lib/metadata';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import type { Metadata } from 'next';
|
||||
import type { Locale } from 'next-intl';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
export const revalidate = 3600;
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return categories.map((category) => ({
|
||||
category: category,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: Locale; category: string }>;
|
||||
}): Promise<Metadata | undefined> {
|
||||
const { locale, category } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Metadata' });
|
||||
return constructMetadata({
|
||||
title: category + ' | ' + t('title'),
|
||||
description: t('description'),
|
||||
canonicalUrl: getUrlWithLocale(`/blocks/${category}`, locale),
|
||||
});
|
||||
}
|
||||
|
||||
interface BlockCategoryPageProps {
|
||||
params: Promise<{ category: string }>;
|
||||
}
|
||||
|
||||
export default async function BlockCategoryPage({
|
||||
params,
|
||||
}: BlockCategoryPageProps) {
|
||||
const { category } = await params;
|
||||
const categoryBlocks = blocks.filter((b) => b.category === category);
|
||||
|
||||
if (categoryBlocks.length === 0) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{categoryBlocks.map((block, index) => (
|
||||
<BlockPreview {...block} key={index} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
@ -2,14 +2,10 @@ import AllPostsButton from '@/components/blog/all-posts-button';
|
||||
import BlogGrid from '@/components/blog/blog-grid';
|
||||
import { 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>
|
||||
|
@ -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)) {
|
||||
|
5
src/app/[locale]/(protected)/admin/users/loading.tsx
Normal file
5
src/app/[locale]/(protected)/admin/users/loading.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
export default function Loading() {
|
||||
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||
}
|
5
src/app/[locale]/(protected)/loading.tsx
Normal file
5
src/app/[locale]/(protected)/loading.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
export default function Loading() {
|
||||
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
export default function Loading() {
|
||||
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,5 @@
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
export default function Loading() {
|
||||
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||
}
|
@ -1,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 />;
|
||||
}
|
||||
|
@ -0,0 +1,5 @@
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
export default function Loading() {
|
||||
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
export default function Loading() {
|
||||
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -0,0 +1,5 @@
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
export default function Loading() {
|
||||
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||
}
|
@ -1,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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
});
|
||||
}
|
@ -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,
|
||||
});
|
||||
}
|
20
src/app/api/hello/route.ts
Normal file
20
src/app/api/hello/route.ts
Normal 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!' });
|
||||
}
|
19
src/app/api/inngest/route.ts
Normal file
19
src/app/api/inngest/route.ts
Normal 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],
|
||||
});
|
@ -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;
|
||||
}
|
||||
|
@ -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')}
|
||||
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -1,65 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export type ActionsProps = ComponentProps<'div'>;
|
||||
|
||||
export const Actions = ({ className, children, ...props }: ActionsProps) => (
|
||||
<div className={cn('flex items-center gap-1', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const Action = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
className,
|
||||
variant = 'ghost',
|
||||
size = 'sm',
|
||||
...props
|
||||
}: ActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
'size-9 p-1.5 text-muted-foreground hover:text-foreground relative',
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
@ -1,212 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UIMessage } from 'ai';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type BranchContextType = {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
};
|
||||
|
||||
const BranchContext = createContext<BranchContextType | null>(null);
|
||||
|
||||
const useBranch = () => {
|
||||
const context = useContext(BranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Branch components must be used within Branch');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type BranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const Branch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: BranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const contextValue: BranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
};
|
||||
|
||||
return (
|
||||
<BranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn('grid w-full gap-2 [&>div]:pb-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
</BranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
|
||||
const { currentBranch, setBranches, branches } = useBranch();
|
||||
const childrenArray = Array.isArray(children) ? children : [children];
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-2 overflow-hidden [&>div]:pb-0',
|
||||
index === currentBranch ? 'block' : 'hidden'
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage['role'];
|
||||
};
|
||||
|
||||
export const BranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: BranchSelectorProps) => {
|
||||
const { totalBranches } = useBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 self-end px-10',
|
||||
from === 'assistant' ? 'justify-start' : 'justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const BranchPrevious = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
className={cn(
|
||||
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const BranchNext = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
className={cn(
|
||||
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useBranch();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium text-muted-foreground text-xs tabular-nums',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</span>
|
||||
);
|
||||
};
|
@ -1,148 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react';
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import {
|
||||
oneDark,
|
||||
oneLight,
|
||||
} from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: '',
|
||||
});
|
||||
|
||||
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: string;
|
||||
showLineNumbers?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden rounded-md border bg-background text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative">
|
||||
<SyntaxHighlighter
|
||||
className="overflow-hidden dark:hidden"
|
||||
codeTagProps={{
|
||||
className: 'font-mono text-sm',
|
||||
}}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '0.875rem',
|
||||
background: 'hsl(var(--background))',
|
||||
color: 'hsl(var(--foreground))',
|
||||
}}
|
||||
language={language}
|
||||
lineNumberStyle={{
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
paddingRight: '1rem',
|
||||
minWidth: '2.5rem',
|
||||
}}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={oneLight}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
<SyntaxHighlighter
|
||||
className="hidden overflow-hidden dark:block"
|
||||
codeTagProps={{
|
||||
className: 'font-mono text-sm',
|
||||
}}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '0.875rem',
|
||||
background: 'hsl(var(--background))',
|
||||
color: 'hsl(var(--foreground))',
|
||||
}}
|
||||
language={language}
|
||||
lineNumberStyle={{
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
paddingRight: '1rem',
|
||||
minWidth: '2.5rem',
|
||||
}}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={oneDark}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
{children && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === 'undefined' || !navigator.clipboard.writeText) {
|
||||
onError?.(new Error('Clipboard API not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
@ -1,62 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowDownIcon } from 'lucide-react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-auto', className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content className={cn('p-4', className)} {...props} />
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
'absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full',
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
@ -1,24 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Experimental_GeneratedImage } from 'ai';
|
||||
|
||||
export type ImageProps = Experimental_GeneratedImage & {
|
||||
className?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export const Image = ({
|
||||
base64,
|
||||
uint8Array,
|
||||
mediaType,
|
||||
...props
|
||||
}: ImageProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt}
|
||||
className={cn(
|
||||
'h-auto max-w-full overflow-hidden rounded-md',
|
||||
props.className
|
||||
)}
|
||||
src={`data:${mediaType};base64,${base64}`}
|
||||
/>
|
||||
);
|
@ -1,287 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
type CarouselApi,
|
||||
} from '@/components/ui/carousel';
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export type InlineCitationProps = ComponentProps<'span'>;
|
||||
|
||||
export const InlineCitation = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationProps) => (
|
||||
<span
|
||||
className={cn('group inline items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationTextProps = ComponentProps<'span'>;
|
||||
|
||||
export const InlineCitationText = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationTextProps) => (
|
||||
<span
|
||||
className={cn('transition-colors group-hover:bg-accent', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
|
||||
|
||||
export const InlineCitationCard = (props: InlineCitationCardProps) => (
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
);
|
||||
|
||||
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
|
||||
sources: string[];
|
||||
};
|
||||
|
||||
export const InlineCitationCardTrigger = ({
|
||||
sources,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardTriggerProps) => (
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge
|
||||
className={cn('ml-1 rounded-full', className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{sources.length ? (
|
||||
<>
|
||||
{new URL(sources[0]).hostname}{' '}
|
||||
{sources.length > 1 && `+${sources.length - 1}`}
|
||||
</>
|
||||
) : (
|
||||
'unknown'
|
||||
)}
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
);
|
||||
|
||||
export type InlineCitationCardBodyProps = ComponentProps<'div'>;
|
||||
|
||||
export const InlineCitationCardBody = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardBodyProps) => (
|
||||
<HoverCardContent className={cn('relative w-80 p-0', className)} {...props} />
|
||||
);
|
||||
|
||||
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
|
||||
|
||||
const useCarouselApi = () => {
|
||||
const context = useContext(CarouselApiContext);
|
||||
return context;
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
|
||||
|
||||
export const InlineCitationCarousel = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationCarouselProps) => {
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
|
||||
return (
|
||||
<CarouselApiContext.Provider value={api}>
|
||||
<Carousel className={cn('w-full', className)} setApi={setApi} {...props}>
|
||||
{children}
|
||||
</Carousel>
|
||||
</CarouselApiContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselContentProps = ComponentProps<'div'>;
|
||||
|
||||
export const InlineCitationCarouselContent = (
|
||||
props: InlineCitationCarouselContentProps
|
||||
) => <CarouselContent {...props} />;
|
||||
|
||||
export type InlineCitationCarouselItemProps = ComponentProps<'div'>;
|
||||
|
||||
export const InlineCitationCarouselItem = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselItemProps) => (
|
||||
<CarouselItem
|
||||
className={cn('w-full space-y-2 p-4 pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>;
|
||||
|
||||
export const InlineCitationCarouselHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCarouselIndexProps = ComponentProps<'div'>;
|
||||
|
||||
export const InlineCitationCarouselIndex = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselIndexProps) => {
|
||||
const api = useCarouselApi();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCount(api.scrollSnapList().length);
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
|
||||
api.on('select', () => {
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? `${current}/${count}`}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselPrevProps = ComponentProps<'button'>;
|
||||
|
||||
export const InlineCitationCarouselPrev = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselPrevProps) => {
|
||||
const api = useCarouselApi();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollPrev();
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Previous"
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselNextProps = ComponentProps<'button'>;
|
||||
|
||||
export const InlineCitationCarouselNext = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselNextProps) => {
|
||||
const api = useCarouselApi();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollNext();
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Next"
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowRightIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationSourceProps = ComponentProps<'div'> & {
|
||||
title?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const InlineCitationSource = ({
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationSourceProps) => (
|
||||
<div className={cn('space-y-1', className)} {...props}>
|
||||
{title && (
|
||||
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
|
||||
)}
|
||||
{url && (
|
||||
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type InlineCitationQuoteProps = ComponentProps<'blockquote'>;
|
||||
|
||||
export const InlineCitationQuote = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationQuoteProps) => (
|
||||
<blockquote
|
||||
className={cn(
|
||||
'border-muted border-l-2 pl-3 text-muted-foreground text-sm italic',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
@ -1,96 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
type LoaderIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
style={{ color: 'currentcolor' }}
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
>
|
||||
<title>Loader</title>
|
||||
<g clipPath="url(#clip0_2393_1490)">
|
||||
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path
|
||||
d="M8 16V12"
|
||||
opacity="0.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 1.52783L5.64887 4.7639"
|
||||
opacity="0.9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 1.52783L10.3511 4.7639"
|
||||
opacity="0.1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 14.472L10.3511 11.236"
|
||||
opacity="0.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 14.472L5.64887 11.236"
|
||||
opacity="0.6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 5.52783L11.8043 6.7639"
|
||||
opacity="0.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 10.472L4.19583 9.23598"
|
||||
opacity="0.7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 10.4722L11.8043 9.2361"
|
||||
opacity="0.3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 5.52783L4.19583 6.7639"
|
||||
opacity="0.8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2393_1490">
|
||||
<rect fill="white" height="16" width="16" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex animate-spin items-center justify-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<LoaderIcon size={size} />
|
||||
</div>
|
||||
);
|
@ -1,62 +0,0 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UIMessage } from 'ai';
|
||||
import type { ComponentProps, HTMLAttributes } from 'react';
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage['role'];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex w-full items-end justify-end gap-2 py-4',
|
||||
from === 'user' ? 'is-user' : 'is-assistant flex-row-reverse justify-end',
|
||||
'[&>div]:max-w-[80%]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm',
|
||||
'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground',
|
||||
'group-[.is-assistant]:bg-card group-[.is-assistant]:text-card-foreground',
|
||||
'is-user:dark',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageAvatarProps = ComponentProps<typeof Avatar> & {
|
||||
src: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export const MessageAvatar = ({
|
||||
src,
|
||||
name,
|
||||
className,
|
||||
...props
|
||||
}: MessageAvatarProps) => (
|
||||
<Avatar className={cn('size-8 ring-1 ring-border', className)} {...props}>
|
||||
<AvatarImage alt="" className="mt-0 mb-0" src={src} />
|
||||
<AvatarFallback>{name?.slice(0, 2) || 'ME'}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
@ -1,230 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ChatStatus } from 'ai';
|
||||
import { Loader2Icon, SendIcon, SquareIcon, XIcon } from 'lucide-react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
HTMLAttributes,
|
||||
KeyboardEventHandler,
|
||||
} from 'react';
|
||||
import { Children } from 'react';
|
||||
|
||||
export type PromptInputProps = HTMLAttributes<HTMLFormElement>;
|
||||
|
||||
export const PromptInput = ({ className, ...props }: PromptInputProps) => (
|
||||
<form
|
||||
className={cn(
|
||||
'w-full divide-y overflow-hidden rounded-xl border bg-background shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PromptInputTextareaProps = ComponentProps<typeof Textarea> & {
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
};
|
||||
|
||||
export const PromptInputTextarea = ({
|
||||
onChange,
|
||||
className,
|
||||
placeholder = 'What would you like to know?',
|
||||
minHeight = 48,
|
||||
maxHeight = 164,
|
||||
...props
|
||||
}: PromptInputTextareaProps) => {
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// Don't submit if IME composition is in progress
|
||||
if (e.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Allow newline
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit on Enter (without Shift)
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget.form;
|
||||
if (form) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
className={cn(
|
||||
'w-full resize-none rounded-none border-none p-3 shadow-none outline-none ring-0',
|
||||
'field-sizing-content max-h-[6lh] bg-transparent dark:bg-transparent',
|
||||
'focus-visible:ring-0',
|
||||
className
|
||||
)}
|
||||
name="message"
|
||||
onChange={(e) => {
|
||||
onChange?.(e);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type PromptInputToolbarProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PromptInputToolbar = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputToolbarProps) => (
|
||||
<div
|
||||
className={cn('flex items-center justify-between p-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PromptInputTools = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputToolsProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1',
|
||||
'[&_button:first-child]:rounded-bl-xl',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PromptInputButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const PromptInputButton = ({
|
||||
variant = 'ghost',
|
||||
className,
|
||||
size,
|
||||
...props
|
||||
}: PromptInputButtonProps) => {
|
||||
const newSize =
|
||||
(size ?? Children.count(props.children) > 1) ? 'default' : 'icon';
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'shrink-0 gap-1.5 rounded-lg',
|
||||
variant === 'ghost' && 'text-muted-foreground',
|
||||
newSize === 'default' && 'px-3',
|
||||
className
|
||||
)}
|
||||
size={newSize}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type PromptInputSubmitProps = ComponentProps<typeof Button> & {
|
||||
status?: ChatStatus;
|
||||
};
|
||||
|
||||
export const PromptInputSubmit = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'icon',
|
||||
status,
|
||||
children,
|
||||
...props
|
||||
}: PromptInputSubmitProps) => {
|
||||
let Icon = <SendIcon className="size-4" />;
|
||||
|
||||
if (status === 'submitted') {
|
||||
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
||||
} else if (status === 'streaming') {
|
||||
Icon = <SquareIcon className="size-4" />;
|
||||
} else if (status === 'error') {
|
||||
Icon = <XIcon className="size-4" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('gap-1.5 rounded-lg', className)}
|
||||
size={size}
|
||||
type="submit"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children ?? Icon}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type PromptInputModelSelectProps = ComponentProps<typeof Select>;
|
||||
|
||||
export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
|
||||
<Select {...props} />
|
||||
);
|
||||
|
||||
export type PromptInputModelSelectTriggerProps = ComponentProps<
|
||||
typeof SelectTrigger
|
||||
>;
|
||||
|
||||
export const PromptInputModelSelectTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputModelSelectTriggerProps) => (
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
|
||||
'hover:bg-accent hover:text-foreground [&[aria-expanded="true"]]:bg-accent [&[aria-expanded="true"]]:text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PromptInputModelSelectContentProps = ComponentProps<
|
||||
typeof SelectContent
|
||||
>;
|
||||
|
||||
export const PromptInputModelSelectContent = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputModelSelectContentProps) => (
|
||||
<SelectContent className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>;
|
||||
|
||||
export const PromptInputModelSelectItem = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputModelSelectItemProps) => (
|
||||
<SelectItem className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
export type PromptInputModelSelectValueProps = ComponentProps<
|
||||
typeof SelectValue
|
||||
>;
|
||||
|
||||
export const PromptInputModelSelectValue = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputModelSelectValueProps) => (
|
||||
<SelectValue className={cn(className)} {...props} />
|
||||
);
|
@ -1,171 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useControllableState } from '@radix-ui/react-use-controllable-state';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BrainIcon, ChevronDownIcon } from 'lucide-react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { createContext, memo, useContext, useEffect, useState } from 'react';
|
||||
import { Response } from './response';
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error('Reasoning components must be used within Reasoning');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: 0,
|
||||
});
|
||||
|
||||
const [hasAutoClosedRef, setHasAutoClosedRef] = useState(false);
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now());
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.round((Date.now() - startTime) / MS_IN_S));
|
||||
setStartTime(null);
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosedRef) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosedRef(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosedRef]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn('not-prose mb-4', className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({ className, children, ...props }: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-muted-foreground text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{isStreaming || duration === 0 ? (
|
||||
<p>Thinking...</p>
|
||||
) : (
|
||||
<p>Thought for {duration} seconds</p>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'size-4 text-muted-foreground transition-transform',
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-4 text-sm',
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Response className="grid gap-2">{children}</Response>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
);
|
||||
|
||||
Reasoning.displayName = 'Reasoning';
|
||||
ReasoningTrigger.displayName = 'ReasoningTrigger';
|
||||
ReasoningContent.displayName = 'ReasoningContent';
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user