Compare commits
3 Commits
cloudflare
...
dev/innges
Author | SHA1 | Date | |
---|---|---|---|
|
09545c4b6e | ||
|
249fd63405 | ||
|
9d6841e7fc |
@ -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
|
13
.gitignore
vendored
13
.gitignore
vendored
@ -30,23 +30,12 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
certificates
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# claude code
|
||||
.claude
|
||||
|
||||
# conductor
|
||||
.conductor
|
||||
|
||||
# kiro
|
||||
.kiro
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@ -64,4 +53,4 @@ next-env.d.ts
|
||||
.wrangler
|
||||
.dev.vars
|
||||
.dev.vars*
|
||||
!.dev.vars.example
|
||||
!.dev.vars.example
|
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
|
||||
}
|
||||
}
|
||||
}
|
109
CLAUDE.md
109
CLAUDE.md
@ -1,109 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Core Development
|
||||
- `pnpm dev` - Start development server with content collections
|
||||
- `pnpm build` - Build the application and content collections
|
||||
- `pnpm start` - Start production server
|
||||
- `pnpm lint` - Run Biome linter (use for code quality checks)
|
||||
- `pnpm format` - Format code with Biome
|
||||
|
||||
### Database Operations (Drizzle ORM)
|
||||
- `pnpm db:generate` - Generate new migration files based on schema changes
|
||||
- `pnpm db:migrate` - Apply pending migrations to the database
|
||||
- `pnpm db:push` - Sync schema changes directly to the database (development only)
|
||||
- `pnpm db:studio` - Open Drizzle Studio for database inspection and management
|
||||
|
||||
### Content and Email
|
||||
- `pnpm content` - Process MDX content collections
|
||||
- `pnpm email` - Start email template development server on port 3333
|
||||
|
||||
## Project Architecture
|
||||
|
||||
This is a Next.js 15 full-stack SaaS application with the following key architectural components:
|
||||
|
||||
### Core Stack
|
||||
- **Framework**: Next.js 15 with App Router
|
||||
- **Database**: PostgreSQL with Drizzle ORM
|
||||
- **Authentication**: Better Auth with social providers (Google, GitHub)
|
||||
- **Payments**: Stripe integration with subscription and one-time payments
|
||||
- **UI**: Radix UI components with TailwindCSS
|
||||
- **State Management**: Zustand for client-side state
|
||||
- **Internationalization**: next-intl with English and Chinese locales
|
||||
- **Content**: Fumadocs for documentation and MDX for content
|
||||
- **Code Quality**: Biome for formatting and linting
|
||||
|
||||
### Key Directory Structure
|
||||
- `src/app/` - Next.js app router with internationalized routing
|
||||
- `src/components/` - Reusable React components organized by feature
|
||||
- `src/lib/` - Utility functions and shared code
|
||||
- `src/db/` - Database schema and migrations
|
||||
- `src/actions/` - Server actions for API operations
|
||||
- `src/stores/` - Zustand state management
|
||||
- `src/hooks/` - Custom React hooks
|
||||
- `src/config/` - Application configuration files
|
||||
- `src/i18n/` - Internationalization setup
|
||||
- `src/mail/` - Email templates and mail functionality
|
||||
- `src/payment/` - Stripe payment integration
|
||||
- `src/credits/` - Credit system implementation
|
||||
- `content/` - MDX content files for docs and blog
|
||||
- `messages/` - Translation files (en.json, zh.json) for internationalization
|
||||
|
||||
### Authentication & User Management
|
||||
- Uses Better Auth with PostgreSQL adapter
|
||||
- Supports email/password and social login (Google, GitHub)
|
||||
- Includes user management, email verification, and password reset
|
||||
- Admin plugin for user management and banning
|
||||
- Automatic newsletter subscription on user creation
|
||||
|
||||
### Payment System
|
||||
- Stripe integration for subscriptions and one-time payments
|
||||
- Three pricing tiers: Free, Pro (monthly/yearly), and Lifetime
|
||||
- Credit system with packages for pay-per-use features
|
||||
- Customer portal for subscription management
|
||||
|
||||
### Feature Modules
|
||||
- **Blog**: MDX-based blog with pagination and categories
|
||||
- **Docs**: Fumadocs-powered documentation
|
||||
- **AI Features**: Image generation with multiple providers (OpenAI, Replicate, etc.)
|
||||
- **Newsletter**: Email subscription system
|
||||
- **Analytics**: Multiple analytics providers support
|
||||
- **Storage**: S3 integration for file uploads
|
||||
|
||||
### Development Workflow
|
||||
1. Use TypeScript for all new code
|
||||
2. Follow Biome formatting rules (single quotes, trailing commas)
|
||||
3. Write server actions in `src/actions/`
|
||||
4. Use Zustand for client-side state management
|
||||
5. Implement database changes through Drizzle migrations
|
||||
6. Use Radix UI components for consistent UI
|
||||
7. Follow the established directory structure
|
||||
8. Use proper error handling with error.tsx and not-found.tsx
|
||||
9. Leverage Next.js 15 features like Server Actions
|
||||
10. Use `next-safe-action` for secure form submissions
|
||||
|
||||
### Configuration
|
||||
- Main config in `src/config/website.tsx`
|
||||
- Environment variables template in `env.example`
|
||||
- Database config in `drizzle.config.ts`
|
||||
- Biome config in `biome.json` with specific ignore patterns
|
||||
- TypeScript config with path aliases (@/* for src/*)
|
||||
|
||||
### Testing and Quality
|
||||
- Use Biome for linting and formatting
|
||||
- TypeScript for type safety
|
||||
- Environment variables for configuration
|
||||
- Proper error boundaries and not-found pages
|
||||
- Zod for runtime validation
|
||||
|
||||
## Important Notes
|
||||
|
||||
- The project uses pnpm as the package manager
|
||||
- Database schema is in `src/db/schema.ts` with auth, payment, and credit tables
|
||||
- Email templates are in `src/mail/templates/`
|
||||
- The app supports both light and dark themes
|
||||
- Content is managed through MDX files in the `content/` directory
|
||||
- The project includes comprehensive internationalization support
|
@ -21,25 +21,24 @@ 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
|
||||
|
||||
By default, you should have access to all 5 repositories. If you find that you’re unable to access any of them, please don’t hesitate to reach out to me, and I’ll assist you in resolving the issue.
|
||||
By default, you should have access to all four repositories. If you find that you’re unable to access any of them, please don’t hesitate to reach out to me, and I’ll assist you in resolving the issue.
|
||||
|
||||
- [mksaas-template (ready)](https://github.com/MkSaaSHQ/mksaas-template): https://demo.mksaas.com
|
||||
- [mksaas-blog (ready)](https://github.com/MkSaaSHQ/mksaas-blog): https://mksaas.me
|
||||
- [mksaas-haitang (ready)](https://github.com/MkSaaSHQ/mksaas-haitang): https://haitang.app
|
||||
- [mksaas-outfit (ready)](https://github.com/MkSaaSHQ/mksaas-outfit)
|
||||
- [mksaas-app (WIP)](https://github.com/MkSaaSHQ/mksaas-app): https://mksaas.app
|
||||
|
||||
## Notice
|
||||
|
||||
> If you have any questions, please [submit an issue](https://github.com/MkSaaSHQ/mksaas-template/issues/new), or contact me at [support@mksaas.com](mailto:support@mksaas.com), or join our [discord community](https://mksaas.link/discord) and ask for help there.
|
||||
> If you have any questions, please [submit an issue](https://github.com/MkSaaSHQ/mksaas-template/issues/new), or contact me at [support@mksaas.com](mailto:support@mksaas.com).
|
||||
|
||||
> If you want to receive notifications whenever code changes, please click `Watch` button in the top right.
|
||||
|
||||
> When submitting any content to the issues of the repository, please use **English** as the main Language, so that everyone can read it and help you, thank you for your supports.
|
||||
> When submitting any content to the issues or discussions of the repository, please use **English** as the main Language, so that everyone can read it and help you, thank you for your supports.
|
||||
|
||||
## License
|
||||
|
||||
|
23
biome.json
23
biome.json
@ -9,12 +9,7 @@
|
||||
"ignoreUnknown": true,
|
||||
"ignore": [
|
||||
".next/**",
|
||||
".open-next/**",
|
||||
".wrangler/**",
|
||||
".cursor/**",
|
||||
".claude/**",
|
||||
".kiro/**",
|
||||
".conductor/**",
|
||||
".vscode/**",
|
||||
".source/**",
|
||||
"node_modules/**",
|
||||
@ -26,11 +21,10 @@
|
||||
"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 +32,7 @@
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80,
|
||||
"formatWithErrors": true,
|
||||
"useEditorconfig": true
|
||||
"formatWithErrors": true
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
@ -74,12 +67,7 @@
|
||||
},
|
||||
"ignore": [
|
||||
".next/**",
|
||||
".open-next/**",
|
||||
".wrangler/**",
|
||||
".cursor/**",
|
||||
".claude/**",
|
||||
".conductor/**",
|
||||
".kiro/**",
|
||||
".vscode/**",
|
||||
".source/**",
|
||||
"node_modules/**",
|
||||
@ -91,11 +79,10 @@
|
||||
"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,4 +2,4 @@
|
||||
title: 组件
|
||||
description: 改进文档的额外组件
|
||||
index: true
|
||||
---
|
||||
---
|
@ -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
|
75
env.example
75
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,7 +60,7 @@ 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
|
||||
# -----------------------------------------------------------------------------
|
||||
STRIPE_SECRET_KEY=""
|
||||
@ -71,29 +71,18 @@ NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=""
|
||||
NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=""
|
||||
# Lifetime plan - one-time payment
|
||||
NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=""
|
||||
# Credit package - basic
|
||||
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=""
|
||||
# Credit package - standard
|
||||
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STANDARD=""
|
||||
# Credit package - premium
|
||||
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PREMIUM=""
|
||||
# Credit package - enterprise
|
||||
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_ENTERPRISE=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configurations
|
||||
# -----------------------------------------------------------------------------
|
||||
# Disable image optimization, check out next.config.ts for more details
|
||||
# -----------------------------------------------------------------------------
|
||||
DISABLE_IMAGE_OPTIMIZATION=false
|
||||
# -----------------------------------------------------------------------------
|
||||
# Run this website as demo website, in most cases, you should set this to false
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_DEMO_WEBSITE=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Analytics
|
||||
# https://mksaas.com/docs/analytics
|
||||
# https://mksaas.com/docs/analytics#setup
|
||||
# -----------------------------------------------------------------------------
|
||||
# Google Analytics (https://analytics.google.com)
|
||||
# https://mksaas.com/docs/analytics#google
|
||||
@ -130,16 +119,19 @@ NEXT_PUBLIC_SELINE_TOKEN=""
|
||||
# DataFast Analytics (https://datafa.st)
|
||||
# https://mksaas.com/docs/analytics#datafast
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_DATAFAST_WEBSITE_ID=""
|
||||
NEXT_PUBLIC_DATAFAST_DOMAIN=""
|
||||
NEXT_PUBLIC_DATAFAST_ANALYTICS_ID=""
|
||||
NEXT_PUBLIC_DATAFAST_ANALYTICS_DOMAIN=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Notification (Discord)
|
||||
# Discord
|
||||
# -----------------------------------------------------------------------------
|
||||
DISCORD_WEBHOOK_URL=""
|
||||
NEXT_PUBLIC_DISCORD_WIDGET_SERVER_ID=""
|
||||
NEXT_PUBLIC_DISCORD_WIDGET_CHANNEL_ID=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Notification (Feishu)
|
||||
# Feishu
|
||||
# -----------------------------------------------------------------------------
|
||||
FEISHU_WEBHOOK_URL=""
|
||||
|
||||
@ -156,42 +148,3 @@ NEXT_PUBLIC_AFFILIATE_AFFONSO_ID=""
|
||||
# https://www.promotekit.com/
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_AFFILIATE_PROMOTEKIT_ID=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Captcha (Cloudflare Turnstile)
|
||||
# https://mksaas.com/docs/captcha
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
|
||||
TURNSTILE_SECRET_KEY=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Crisp
|
||||
# https://mksaas.com/docs/chat
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_CRISP_WEBSITE_ID=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Cron Jobs
|
||||
# https://mksaas.com/docs/cronjobs
|
||||
# -----------------------------------------------------------------------------
|
||||
CRON_JOBS_USERNAME=""
|
||||
CRON_JOBS_PASSWORD=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# AI
|
||||
# https://mksaas.com/docs/ai
|
||||
# -----------------------------------------------------------------------------
|
||||
AI_GATEWAY_API_KEY=""
|
||||
FAL_API_KEY=""
|
||||
FIREWORKS_API_KEY=""
|
||||
OPENAI_API_KEY=""
|
||||
REPLICATE_API_TOKEN=""
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=""
|
||||
DEEPSEEK_API_KEY=""
|
||||
OPENROUTER_API_KEY=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Web Content Analyzer (Firecrawl)
|
||||
# https://firecrawl.dev/
|
||||
# -----------------------------------------------------------------------------
|
||||
FIRECRAWL_API_KEY=""
|
||||
|
214
messages/en.json
214
messages/en.json
@ -5,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",
|
||||
@ -29,21 +28,7 @@
|
||||
"save": "Save",
|
||||
"loading": "Loading...",
|
||||
"cancel": "Cancel",
|
||||
"logoutFailed": "Failed to log out",
|
||||
"table": {
|
||||
"totalRecords": "Total {count} records",
|
||||
"noResults": "No results",
|
||||
"loading": "Loading...",
|
||||
"columns": "Columns",
|
||||
"rowsPerPage": "Rows per page",
|
||||
"page": "Page",
|
||||
"firstPage": "First Page",
|
||||
"lastPage": "Last Page",
|
||||
"nextPage": "Next Page",
|
||||
"previousPage": "Previous Page",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc"
|
||||
}
|
||||
"logoutFailed": "Failed to log out"
|
||||
},
|
||||
"PricingPage": {
|
||||
"title": "Pricing",
|
||||
@ -114,24 +99,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreditPackages": {
|
||||
"basic": {
|
||||
"name": "Basic",
|
||||
"description": "Basic credits package description"
|
||||
},
|
||||
"standard": {
|
||||
"name": "Standard",
|
||||
"description": "Standard credits package description"
|
||||
},
|
||||
"premium": {
|
||||
"name": "Premium",
|
||||
"description": "Premium credits package description"
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "Enterprise",
|
||||
"description": "Enterprise credits package description"
|
||||
}
|
||||
},
|
||||
"NotFoundPage": {
|
||||
"title": "404",
|
||||
"message": "Sorry, the page you are looking for does not exist.",
|
||||
@ -220,9 +187,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",
|
||||
@ -237,9 +202,7 @@
|
||||
"hidePassword": "Hide password",
|
||||
"nameRequired": "Please enter your name",
|
||||
"emailRequired": "Please enter your email",
|
||||
"passwordRequired": "Please enter your password",
|
||||
"captchaInvalid": "Captcha verification failed",
|
||||
"captchaError": "Captcha verification error"
|
||||
"passwordRequired": "Please enter your password"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Forgot Password",
|
||||
@ -293,20 +256,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 +284,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"
|
||||
@ -483,7 +430,6 @@
|
||||
"avatar": {
|
||||
"dashboard": "Dashboard",
|
||||
"billing": "Billing",
|
||||
"credits": "Credits",
|
||||
"settings": "Settings"
|
||||
}
|
||||
},
|
||||
@ -509,6 +455,14 @@
|
||||
"banReason": "Ban Reason",
|
||||
"banExpires": "Ban Expires"
|
||||
},
|
||||
"noResults": "No results",
|
||||
"firstPage": "First Page",
|
||||
"lastPage": "Last Page",
|
||||
"nextPage": "Next Page",
|
||||
"previousPage": "Previous Page",
|
||||
"rowsPerPage": "Rows per page",
|
||||
"page": "Page",
|
||||
"loading": "Loading...",
|
||||
"admin": "Admin",
|
||||
"user": "User",
|
||||
"email": {
|
||||
@ -518,8 +472,8 @@
|
||||
"emailCopied": "Email copied to clipboard",
|
||||
"banned": "Banned",
|
||||
"active": "Active",
|
||||
"joined": "Joined at",
|
||||
"updated": "Updated at",
|
||||
"joined": "Joined",
|
||||
"updated": "Updated",
|
||||
"ban": {
|
||||
"reason": "Ban Reason",
|
||||
"reasonPlaceholder": "Enter the reason for banning this user",
|
||||
@ -590,8 +544,7 @@
|
||||
"createCustomerPortalFailed": "Failed to open Stripe customer portal"
|
||||
},
|
||||
"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",
|
||||
@ -599,77 +552,7 @@
|
||||
"manageBilling": "Manage Billing",
|
||||
"upgradePlan": "Upgrade Plan",
|
||||
"retry": "Retry",
|
||||
"errorMessage": "Failed to get data",
|
||||
"paymentSuccess": "Payment successful"
|
||||
},
|
||||
"credits": {
|
||||
"title": "Credits",
|
||||
"description": "Manage your credit transactions",
|
||||
"tabs": {
|
||||
"balance": "Balance",
|
||||
"transactions": "Transactions"
|
||||
},
|
||||
"balance": {
|
||||
"title": "Credit Balance",
|
||||
"description": "Your credit balance",
|
||||
"credits": "Credits",
|
||||
"creditsDescription": "You have {credits} credits",
|
||||
"creditsExpired": "Credits expired",
|
||||
"creditsAdded": "Credits have been added to your account",
|
||||
"viewTransactions": "View Credit Transactions",
|
||||
"retry": "Retry",
|
||||
"expiringCredits": "{credits} credits expiring in the next {days} days"
|
||||
},
|
||||
"packages": {
|
||||
"title": "Credit Packages",
|
||||
"description": "Purchase additional credits to use our services",
|
||||
"purchase": "Purchase",
|
||||
"processing": "Processing...",
|
||||
"popular": "Popular",
|
||||
"completePurchase": "Complete Your Purchase",
|
||||
"failedToFetchCredits": "Failed to fetch credits",
|
||||
"failedToCreatePaymentIntent": "Failed to create payment intent",
|
||||
"failedToInitiatePayment": "Failed to initiate payment",
|
||||
"cancel": "Cancel",
|
||||
"purchaseFailed": "Purchase credits failed",
|
||||
"checkoutFailed": "Failed to create checkout session",
|
||||
"loading": "Loading...",
|
||||
"pay": "Pay"
|
||||
},
|
||||
"transactions": {
|
||||
"title": "Credit Transactions",
|
||||
"error": "Failed to get credit transactions",
|
||||
"search": "Search credit transactions...",
|
||||
"paymentIdCopied": "Payment ID copied to clipboard",
|
||||
"columns": {
|
||||
"columns": "Columns",
|
||||
"id": "ID",
|
||||
"type": "Type",
|
||||
"description": "Description",
|
||||
"amount": "Amount",
|
||||
"remainingAmount": "Remaining Amount",
|
||||
"paymentId": "Payment ID",
|
||||
"expirationDate": "Expiration Date",
|
||||
"expirationDateProcessedAt": "Expiration Date Processed At",
|
||||
"createdAt": "Created At",
|
||||
"updatedAt": "Updated At"
|
||||
},
|
||||
"types": {
|
||||
"MONTHLY_REFRESH": "Monthly Refresh",
|
||||
"REGISTER_GIFT": "Register Gift",
|
||||
"PURCHASE": "Purchased Credits",
|
||||
"USAGE": "Consumed Credits",
|
||||
"EXPIRE": "Expired Credits",
|
||||
"SUBSCRIPTION_RENEWAL": "Subscription Renewal",
|
||||
"LIFETIME_MONTHLY": "Lifetime Monthly"
|
||||
},
|
||||
"detailViewer": {
|
||||
"title": "Credit Transaction Detail",
|
||||
"close": "Close"
|
||||
},
|
||||
"expired": "Expired",
|
||||
"never": "Never"
|
||||
}
|
||||
"errorMessage": "Failed to get data"
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notification",
|
||||
@ -1007,68 +890,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"
|
||||
}
|
||||
}
|
||||
|
219
messages/zh.json
219
messages/zh.json
@ -5,7 +5,6 @@
|
||||
"description": "MkSaaS 是构建 AI SaaS 的最佳模板,使用 MkSaaS 可以在几天内轻松构建您的 AI SaaS,简单且毫不费力。"
|
||||
},
|
||||
"Common": {
|
||||
"premium": "付费文章",
|
||||
"login": "登录",
|
||||
"logout": "退出",
|
||||
"signUp": "注册",
|
||||
@ -29,21 +28,7 @@
|
||||
"saving": "保存中...",
|
||||
"loading": "加载中...",
|
||||
"cancel": "取消",
|
||||
"logoutFailed": "退出失败",
|
||||
"table": {
|
||||
"totalRecords": "总共 {count} 条记录",
|
||||
"noResults": "无结果",
|
||||
"loading": "加载中...",
|
||||
"columns": "列",
|
||||
"rowsPerPage": "每页行数",
|
||||
"page": "页",
|
||||
"firstPage": "第一页",
|
||||
"lastPage": "最后一页",
|
||||
"nextPage": "下一页",
|
||||
"previousPage": "上一页",
|
||||
"ascending": "升序",
|
||||
"descending": "降序"
|
||||
}
|
||||
"logoutFailed": "退出失败"
|
||||
},
|
||||
"PricingPage": {
|
||||
"title": "价格",
|
||||
@ -109,29 +94,12 @@
|
||||
"feature-3": "专属支持",
|
||||
"feature-4": "企业级安全",
|
||||
"feature-5": "高级集成",
|
||||
"feature-6": "自定义品牌",
|
||||
"feature-7": "终身更新"
|
||||
"feature-6": "自定义域名",
|
||||
"feature-7": "自定义品牌",
|
||||
"feature-8": "终身更新"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreditPackages": {
|
||||
"basic": {
|
||||
"name": "基础版",
|
||||
"description": "基础版功能介绍放这里"
|
||||
},
|
||||
"standard": {
|
||||
"name": "标准版",
|
||||
"description": "标准版功能介绍放这里"
|
||||
},
|
||||
"premium": {
|
||||
"name": "高级版",
|
||||
"description": "高级版功能介绍放这里"
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "企业版",
|
||||
"description": "企业版功能介绍放这里"
|
||||
}
|
||||
},
|
||||
"NotFoundPage": {
|
||||
"title": "404",
|
||||
"message": "抱歉,您正在寻找的页面不存在",
|
||||
@ -220,9 +188,7 @@
|
||||
"hidePassword": "隐藏密码",
|
||||
"or": "或以社媒账号登录",
|
||||
"emailRequired": "请输入邮箱",
|
||||
"passwordRequired": "请输入密码",
|
||||
"captchaInvalid": "验证码验证失败",
|
||||
"captchaError": "验证码验证出错"
|
||||
"passwordRequired": "请输入密码"
|
||||
},
|
||||
"register": {
|
||||
"title": "注册",
|
||||
@ -237,9 +203,7 @@
|
||||
"hidePassword": "隐藏密码",
|
||||
"nameRequired": "请输入姓名",
|
||||
"emailRequired": "请输入邮箱",
|
||||
"passwordRequired": "请输入密码",
|
||||
"captchaInvalid": "验证码验证失败",
|
||||
"captchaError": "验证码验证出错"
|
||||
"passwordRequired": "请输入密码"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "忘记密码",
|
||||
@ -293,20 +257,8 @@
|
||||
"nextPage": "下一页",
|
||||
"chooseLanguage": "选择语言",
|
||||
"title": "MkSaaS文档",
|
||||
"homepage": "首页"
|
||||
},
|
||||
"PremiumContent": {
|
||||
"title": "解锁付费内容",
|
||||
"description": "订阅我们的付费计划,访问所有付费内容和独家内容。",
|
||||
"upgradeCta": "立即升级",
|
||||
"benefit1": "所有内容",
|
||||
"benefit2": "独家内容",
|
||||
"benefit3": "随时取消",
|
||||
"signIn": "登录",
|
||||
"loginRequired": "登录以继续阅读",
|
||||
"loginDescription": "这是一篇付费内容,请登录您的账户以访问完整内容。",
|
||||
"checkingAccess": "检查阅读权限...",
|
||||
"loadingContent": "加载完整内容..."
|
||||
"homepage": "首页",
|
||||
"blog": "博客"
|
||||
},
|
||||
"Marketing": {
|
||||
"navbar": {
|
||||
@ -333,10 +285,6 @@
|
||||
"title": "AI 图像",
|
||||
"description": "展示如何使用 AI 生成精美图像"
|
||||
},
|
||||
"chat": {
|
||||
"title": "AI 聊天",
|
||||
"description": "展示如何使用 AI 与客户聊天"
|
||||
},
|
||||
"video": {
|
||||
"title": "AI 视频",
|
||||
"description": "展示如何使用 AI 生成惊人视频"
|
||||
@ -426,13 +374,13 @@
|
||||
"comparator": {
|
||||
"title": "Comparator 组件"
|
||||
},
|
||||
"faq": {
|
||||
"title": "FAQ 组件"
|
||||
"faqs": {
|
||||
"title": "FAQs 组件"
|
||||
},
|
||||
"login": {
|
||||
"title": "Login 组件"
|
||||
},
|
||||
"signup": {
|
||||
"sign-up": {
|
||||
"title": "Signup 组件"
|
||||
},
|
||||
"forgot-password": {
|
||||
@ -483,7 +431,6 @@
|
||||
"avatar": {
|
||||
"dashboard": "工作台",
|
||||
"billing": "账单",
|
||||
"credits": "积分",
|
||||
"settings": "设置"
|
||||
}
|
||||
},
|
||||
@ -509,6 +456,14 @@
|
||||
"banReason": "封禁原因",
|
||||
"banExpires": "封禁到期时间"
|
||||
},
|
||||
"noResults": "没有结果",
|
||||
"firstPage": "第一页",
|
||||
"lastPage": "最后一页",
|
||||
"nextPage": "下一页",
|
||||
"previousPage": "上一页",
|
||||
"rowsPerPage": "每页行数",
|
||||
"page": "页",
|
||||
"loading": "加载中...",
|
||||
"admin": "管理员",
|
||||
"user": "用户",
|
||||
"email": {
|
||||
@ -590,8 +545,7 @@
|
||||
"createCustomerPortalFailed": "打开Stripe客户界面失败"
|
||||
},
|
||||
"price": "价格:",
|
||||
"periodStartDate": "周期开始日期:",
|
||||
"periodEndDate": "周期结束日期:",
|
||||
"nextBillingDate": "下次账单日期:",
|
||||
"trialEnds": "试用结束日期:",
|
||||
"freePlanMessage": "您当前使用的是功能有限的免费方案",
|
||||
"lifetimeMessage": "您拥有所有高级功能的终身使用权限",
|
||||
@ -599,77 +553,7 @@
|
||||
"manageBilling": "管理账单",
|
||||
"upgradePlan": "升级方案",
|
||||
"retry": "重试",
|
||||
"errorMessage": "获取数据失败",
|
||||
"paymentSuccess": "支付成功"
|
||||
},
|
||||
"credits": {
|
||||
"title": "积分",
|
||||
"description": "管理您的积分交易",
|
||||
"tabs": {
|
||||
"balance": "积分余额",
|
||||
"transactions": "交易记录"
|
||||
},
|
||||
"balance": {
|
||||
"title": "积分余额",
|
||||
"description": "您的积分余额",
|
||||
"credits": "积分",
|
||||
"creditsDescription": "您有 {credits} 积分",
|
||||
"creditsExpired": "积分已过期",
|
||||
"creditsAdded": "积分已添加到您的账户",
|
||||
"viewTransactions": "查看积分记录",
|
||||
"retry": "重试",
|
||||
"expiringCredits": "{credits} 积分将在 {days} 天内过期"
|
||||
},
|
||||
"packages": {
|
||||
"title": "积分套餐",
|
||||
"description": "购买积分以使用我们的更多服务",
|
||||
"purchase": "购买",
|
||||
"processing": "处理中...",
|
||||
"popular": "热门",
|
||||
"completePurchase": "请支付订单",
|
||||
"failedToFetchCredits": "获取积分失败",
|
||||
"failedToCreatePaymentIntent": "创建付款意向失败",
|
||||
"failedToInitiatePayment": "发起付款失败",
|
||||
"cancel": "取消",
|
||||
"purchaseFailed": "购买积分失败",
|
||||
"checkoutFailed": "创建支付会话失败",
|
||||
"loading": "加载中...",
|
||||
"pay": "支付"
|
||||
},
|
||||
"transactions": {
|
||||
"title": "积分记录",
|
||||
"error": "获取积分交易记录失败",
|
||||
"search": "搜索积分交易记录...",
|
||||
"paymentIdCopied": "支付ID已复制到剪贴板",
|
||||
"columns": {
|
||||
"columns": "列",
|
||||
"id": "ID",
|
||||
"type": "类型",
|
||||
"description": "描述",
|
||||
"amount": "金额",
|
||||
"remainingAmount": "剩余金额",
|
||||
"paymentId": "支付编号",
|
||||
"expirationDate": "过期日期",
|
||||
"expirationDateProcessedAt": "过期处理时间",
|
||||
"createdAt": "创建时间",
|
||||
"updatedAt": "更新时间"
|
||||
},
|
||||
"types": {
|
||||
"MONTHLY_REFRESH": "每月赠送",
|
||||
"REGISTER_GIFT": "注册赠送",
|
||||
"PURCHASE": "购买积分",
|
||||
"USAGE": "使用积分",
|
||||
"EXPIRE": "过期积分",
|
||||
"SUBSCRIPTION_RENEWAL": "订阅月度积分",
|
||||
"LIFETIME_MONTHLY": "终身月度积分"
|
||||
},
|
||||
"detailViewer": {
|
||||
"title": "积分交易详情",
|
||||
"close": "关闭"
|
||||
},
|
||||
"expired": "已过期",
|
||||
"never": "永不过期"
|
||||
}
|
||||
"errorMessage": "获取数据失败"
|
||||
},
|
||||
"notification": {
|
||||
"title": "通知",
|
||||
@ -1008,67 +892,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({
|
||||
|
||||
});
|
44
package.json
44
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,16 @@
|
||||
"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/openai": "^1.1.13",
|
||||
"@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",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@mendable/firecrawl-js": "^1.29.1",
|
||||
"@hookform/resolvers": "^4.1.0",
|
||||
"@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 +65,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 +88,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.39.2",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.483.0",
|
||||
"motion": "^12.4.3",
|
||||
@ -112,17 +99,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 +114,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 +121,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"
|
||||
}
|
||||
}
|
||||
|
7884
pnpm-lock.yaml
generated
7884
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,37 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { consumeCredits } from '@/credits/credits';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
import { z } from 'zod';
|
||||
|
||||
// consume credits schema
|
||||
const consumeSchema = z.object({
|
||||
amount: z.number().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Consume credits
|
||||
*/
|
||||
export const consumeCreditsAction = userActionClient
|
||||
.schema(consumeSchema)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const { amount, description } = parsedInput;
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
|
||||
try {
|
||||
await consumeCredits({
|
||||
userId: currentUser.id,
|
||||
amount,
|
||||
description: description || `Consume credits: ${amount}`,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('consume credits error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Something went wrong',
|
||||
};
|
||||
}
|
||||
});
|
@ -1,34 +1,57 @@
|
||||
'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 { cookies } from 'next/headers';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
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
|
||||
@ -39,37 +62,27 @@ export const createCheckoutAction = userActionClient
|
||||
if (!plan) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Price plan not found',
|
||||
error: 'Plan not found',
|
||||
};
|
||||
}
|
||||
|
||||
// Add user id to metadata, so we can get it in the webhook event
|
||||
const customMetadata: Record<string, string> = {
|
||||
const customMetadata = {
|
||||
...metadata,
|
||||
userId: currentUser.id,
|
||||
userName: currentUser.name,
|
||||
userId: session.user.id,
|
||||
userName: session.user.name,
|
||||
};
|
||||
|
||||
// https://datafa.st/docs/stripe-checkout-api
|
||||
// if datafast analytics is enabled, add the revenue attribution to the metadata
|
||||
if (websiteConfig.features.enableDatafastRevenueTrack) {
|
||||
const cookieStore = await cookies();
|
||||
customMetadata.datafast_visitor_id =
|
||||
cookieStore.get('datafast_visitor_id')?.value ?? '';
|
||||
customMetadata.datafast_session_id =
|
||||
cookieStore.get('datafast_session_id')?.value ?? '';
|
||||
}
|
||||
|
||||
// Create the checkout session with localized URLs
|
||||
const successUrl = getUrlWithLocale(
|
||||
`${Routes.SettingsBilling}?session_id={CHECKOUT_SESSION_ID}`,
|
||||
'/settings/billing?session_id={CHECKOUT_SESSION_ID}',
|
||||
locale
|
||||
);
|
||||
const cancelUrl = getUrlWithLocale(Routes.Pricing, locale);
|
||||
const params: CreateCheckoutParams = {
|
||||
planId,
|
||||
priceId,
|
||||
customerEmail: currentUser.email,
|
||||
customerEmail: session.user.email,
|
||||
metadata: customMetadata,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
|
@ -1,99 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { getCreditPackageById } from '@/credits/server';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { createCreditCheckout } from '@/payment';
|
||||
import type { CreateCreditCheckoutParams } from '@/payment/types';
|
||||
import { Routes } from '@/routes';
|
||||
import { getLocale } from 'next-intl/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Credit checkout schema for validation
|
||||
// metadata is optional, and may contain referral information if you need
|
||||
const creditCheckoutSchema = z.object({
|
||||
userId: z.string().min(1, { error: 'User ID is required' }),
|
||||
packageId: z.string().min(1, { error: 'Package ID is required' }),
|
||||
priceId: z.string().min(1, { error: 'Price ID is required' }),
|
||||
metadata: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a checkout session for a credit package
|
||||
*/
|
||||
export const createCreditCheckoutSession = userActionClient
|
||||
.schema(creditCheckoutSchema)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const { packageId, priceId, metadata } = parsedInput;
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
|
||||
try {
|
||||
// Get the current locale from the request
|
||||
const locale = await getLocale();
|
||||
|
||||
// Find the credit package
|
||||
const creditPackage = getCreditPackageById(packageId);
|
||||
if (!creditPackage) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Credit package not found',
|
||||
};
|
||||
}
|
||||
|
||||
// Add metadata to identify this as a credit purchase
|
||||
const customMetadata: Record<string, string> = {
|
||||
...metadata,
|
||||
type: 'credit_purchase',
|
||||
packageId,
|
||||
credits: creditPackage.amount.toString(),
|
||||
userId: currentUser.id,
|
||||
userName: currentUser.name,
|
||||
};
|
||||
|
||||
// https://datafa.st/docs/stripe-checkout-api
|
||||
// if datafast analytics is enabled, add the revenue attribution to the metadata
|
||||
if (websiteConfig.features.enableDatafastRevenueTrack) {
|
||||
const cookieStore = await cookies();
|
||||
customMetadata.datafast_visitor_id =
|
||||
cookieStore.get('datafast_visitor_id')?.value ?? '';
|
||||
customMetadata.datafast_session_id =
|
||||
cookieStore.get('datafast_session_id')?.value ?? '';
|
||||
}
|
||||
|
||||
// Create checkout session with credit-specific URLs
|
||||
const successUrl = getUrlWithLocale(
|
||||
`${Routes.SettingsCredits}?credits_session_id={CHECKOUT_SESSION_ID}`,
|
||||
locale
|
||||
);
|
||||
const cancelUrl = getUrlWithLocale(Routes.SettingsCredits, locale);
|
||||
|
||||
const params: CreateCreditCheckoutParams = {
|
||||
packageId,
|
||||
priceId,
|
||||
customerEmail: currentUser.email,
|
||||
metadata: customMetadata,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
locale,
|
||||
};
|
||||
|
||||
const result = await createCreditCheckout(params);
|
||||
// console.log('create credit checkout session result:', result);
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Create credit checkout session error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to create checkout session',
|
||||
};
|
||||
}
|
||||
});
|
@ -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 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getUserCredits } from '@/credits/credits';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
|
||||
/**
|
||||
* Get current user's credits
|
||||
*/
|
||||
export const getCreditBalanceAction = userActionClient.action(
|
||||
async ({ ctx }) => {
|
||||
try {
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
const credits = await getUserCredits(currentUser.id);
|
||||
return { success: true, credits };
|
||||
} catch (error) {
|
||||
console.error('get credit balance error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch credit balance',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
@ -1,62 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getDb } from '@/db';
|
||||
import { creditTransaction } from '@/db/schema';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { CREDITS_EXPIRATION_DAYS } from '@/lib/constants';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
import { addDays } from 'date-fns';
|
||||
import { and, eq, gt, gte, isNotNull, lte, sum } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Get credit statistics for a user
|
||||
*/
|
||||
export const getCreditStatsAction = userActionClient.action(async ({ ctx }) => {
|
||||
try {
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
const userId = currentUser.id;
|
||||
|
||||
const db = await getDb();
|
||||
const now = new Date();
|
||||
// Get credits expiring in the next 30 days
|
||||
const expirationDaysFromNow = addDays(now, CREDITS_EXPIRATION_DAYS);
|
||||
|
||||
// Get total credits expiring in the next 30 days
|
||||
const expiringCreditsResult = await db
|
||||
.select({
|
||||
totalAmount: sum(creditTransaction.remainingAmount),
|
||||
})
|
||||
.from(creditTransaction)
|
||||
.where(
|
||||
and(
|
||||
eq(creditTransaction.userId, userId),
|
||||
isNotNull(creditTransaction.expirationDate),
|
||||
isNotNull(creditTransaction.remainingAmount),
|
||||
gt(creditTransaction.remainingAmount, 0),
|
||||
lte(creditTransaction.expirationDate, expirationDaysFromNow),
|
||||
gte(creditTransaction.expirationDate, now)
|
||||
)
|
||||
);
|
||||
|
||||
const totalExpiringCredits =
|
||||
Number(expiringCreditsResult[0]?.totalAmount) || 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
expiringCredits: {
|
||||
amount: totalExpiringCredits,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('get credit stats error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch credit statistics',
|
||||
};
|
||||
}
|
||||
});
|
@ -1,128 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getDb } from '@/db';
|
||||
import { creditTransaction } from '@/db/schema';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
import { and, asc, desc, eq, ilike, or, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define the schema for getCreditTransactions parameters
|
||||
const getCreditTransactionsSchema = z.object({
|
||||
pageIndex: z.number().min(0).default(0),
|
||||
pageSize: z.number().min(1).max(100).default(10),
|
||||
search: z.string().optional().default(''),
|
||||
sorting: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
desc: z.boolean(),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
// Define sort field mapping
|
||||
const sortFieldMap = {
|
||||
type: creditTransaction.type,
|
||||
amount: creditTransaction.amount,
|
||||
remainingAmount: creditTransaction.remainingAmount,
|
||||
description: creditTransaction.description,
|
||||
createdAt: creditTransaction.createdAt,
|
||||
updatedAt: creditTransaction.updatedAt,
|
||||
expirationDate: creditTransaction.expirationDate,
|
||||
expirationDateProcessedAt: creditTransaction.expirationDateProcessedAt,
|
||||
paymentId: creditTransaction.paymentId,
|
||||
} as const;
|
||||
|
||||
// Create a safe action for getting credit transactions
|
||||
export const getCreditTransactionsAction = userActionClient
|
||||
.schema(getCreditTransactionsSchema)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
try {
|
||||
const { pageIndex, pageSize, search, sorting } = parsedInput;
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
|
||||
// Search logic: text fields use ilike, and if search is a number, also search amount fields
|
||||
const searchConditions = [];
|
||||
if (search) {
|
||||
// Always search text fields
|
||||
searchConditions.push(
|
||||
ilike(creditTransaction.type, `%${search}%`),
|
||||
ilike(creditTransaction.paymentId, `%${search}%`),
|
||||
ilike(creditTransaction.description, `%${search}%`)
|
||||
);
|
||||
|
||||
// If search is a valid number, also search numeric fields
|
||||
const numericSearch = Number.parseInt(search, 10);
|
||||
if (!Number.isNaN(numericSearch)) {
|
||||
searchConditions.push(
|
||||
eq(creditTransaction.amount, numericSearch),
|
||||
eq(creditTransaction.remainingAmount, numericSearch)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const where = search
|
||||
? and(
|
||||
eq(creditTransaction.userId, currentUser.id),
|
||||
or(...searchConditions)
|
||||
)
|
||||
: eq(creditTransaction.userId, currentUser.id);
|
||||
|
||||
const offset = pageIndex * pageSize;
|
||||
|
||||
// Get the sort configuration
|
||||
const sortConfig = sorting[0];
|
||||
const sortField = sortConfig?.id
|
||||
? sortFieldMap[sortConfig.id as keyof typeof sortFieldMap]
|
||||
: creditTransaction.createdAt;
|
||||
const sortDirection = sortConfig?.desc ? desc : asc;
|
||||
|
||||
const db = await getDb();
|
||||
const [items, [{ count }]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: creditTransaction.id,
|
||||
userId: creditTransaction.userId,
|
||||
type: creditTransaction.type,
|
||||
description: creditTransaction.description,
|
||||
amount: creditTransaction.amount,
|
||||
remainingAmount: creditTransaction.remainingAmount,
|
||||
paymentId: creditTransaction.paymentId,
|
||||
expirationDate: creditTransaction.expirationDate,
|
||||
expirationDateProcessedAt:
|
||||
creditTransaction.expirationDateProcessedAt,
|
||||
createdAt: creditTransaction.createdAt,
|
||||
updatedAt: creditTransaction.updatedAt,
|
||||
})
|
||||
.from(creditTransaction)
|
||||
.where(where)
|
||||
.orderBy(sortDirection(sortField))
|
||||
.limit(pageSize)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql`count(*)` })
|
||||
.from(creditTransaction)
|
||||
.where(where),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total: Number(count),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('get credit transactions error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch credit transactions',
|
||||
};
|
||||
}
|
||||
});
|
@ -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,19 +38,14 @@ 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 {
|
||||
const { pageIndex, pageSize, search, sorting } = parsedInput;
|
||||
|
||||
// search by name, email, and customerId
|
||||
const where = search
|
||||
? or(
|
||||
ilike(user.name, `%${search}%`),
|
||||
ilike(user.email, `%${search}%`),
|
||||
ilike(user.customerId, `%${search}%`)
|
||||
)
|
||||
? or(ilike(user.name, `%${search}%`), ilike(user.email, `%${search}%`))
|
||||
: undefined;
|
||||
|
||||
const offset = pageIndex * pageSize;
|
||||
@ -73,8 +70,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,33 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { validateTurnstileToken } from '@/lib/captcha';
|
||||
import { actionClient } from '@/lib/safe-action';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Captcha validation schema
|
||||
const captchaSchema = z.object({
|
||||
captchaToken: z.string().min(1, { error: 'Captcha token is required' }),
|
||||
});
|
||||
|
||||
// Create a safe action for captcha validation
|
||||
export const validateCaptchaAction = actionClient
|
||||
.schema(captchaSchema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const { captchaToken } = parsedInput;
|
||||
|
||||
try {
|
||||
const isValid = await validateTurnstileToken(captchaToken);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
valid: isValid,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Captcha validation error:', error);
|
||||
return {
|
||||
success: false,
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : 'Something went wrong',
|
||||
};
|
||||
}
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
@ -1,102 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from '@/components/ui/carousel';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { GeneratedImage, ProviderTiming } from '../lib/image-types';
|
||||
import type { ProviderKey } from '../lib/provider-config';
|
||||
import { ImageDisplay } from './ImageDisplay';
|
||||
|
||||
interface ImageCarouselProps {
|
||||
providers: ProviderKey[];
|
||||
images: GeneratedImage[];
|
||||
timings: Record<ProviderKey, ProviderTiming>;
|
||||
failedProviders: ProviderKey[];
|
||||
enabledProviders: Record<ProviderKey, boolean>;
|
||||
providerToModel: Record<ProviderKey, string>;
|
||||
}
|
||||
|
||||
export function ImageCarousel({
|
||||
providers,
|
||||
images,
|
||||
timings,
|
||||
failedProviders,
|
||||
enabledProviders,
|
||||
providerToModel,
|
||||
}: ImageCarouselProps) {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
api.on('select', () => {
|
||||
setCurrentSlide(api.selectedScrollSnap());
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Carousel setApi={setApi} opts={{ align: 'start', loop: true }}>
|
||||
<CarouselContent>
|
||||
{providers.map((provider, i) => {
|
||||
const imageData = images?.find(
|
||||
(img) => img.provider === provider
|
||||
)?.image;
|
||||
const timing = timings[provider];
|
||||
|
||||
return (
|
||||
<CarouselItem key={provider}>
|
||||
<ImageDisplay
|
||||
modelId={
|
||||
images?.find((img) => img.provider === provider)?.modelId ||
|
||||
providerToModel[provider]
|
||||
}
|
||||
provider={provider}
|
||||
image={imageData}
|
||||
timing={timing}
|
||||
failed={failedProviders.includes(provider)}
|
||||
enabled={enabledProviders[provider]}
|
||||
/>
|
||||
<div className="text-center text-sm text-muted-foreground mt-4">
|
||||
{i + 1} of {providers.length}
|
||||
</div>
|
||||
</CarouselItem>
|
||||
);
|
||||
})}
|
||||
</CarouselContent>
|
||||
|
||||
<CarouselPrevious className="left-0 bg-background/80 backdrop-blur-sm" />
|
||||
<CarouselNext className="right-0 bg-background/80 backdrop-blur-sm" />
|
||||
</Carousel>
|
||||
|
||||
{/* Dot Indicators */}
|
||||
<div className="absolute -bottom-6 left-0 right-0">
|
||||
<div className="flex justify-center gap-1">
|
||||
{providers.map((_, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
className={cn(
|
||||
'h-1.5 rounded-full transition-all',
|
||||
index === currentSlide
|
||||
? 'w-4 bg-primary'
|
||||
: 'w-1.5 bg-primary/50'
|
||||
)}
|
||||
onClick={() => api?.scrollTo(index)}
|
||||
>
|
||||
<span className="sr-only">Go to image {index + 1}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,195 +0,0 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AlertCircle, Download, ImageIcon, Share } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { imageHelpers } from '../lib/image-helpers';
|
||||
import type { ProviderTiming } from '../lib/image-types';
|
||||
import { Stopwatch } from './Stopwatch';
|
||||
|
||||
interface ImageDisplayProps {
|
||||
provider: string;
|
||||
image: string | null | undefined;
|
||||
timing?: ProviderTiming;
|
||||
failed?: boolean;
|
||||
fallbackIcon?: React.ReactNode;
|
||||
enabled?: boolean;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export function ImageDisplay({
|
||||
provider,
|
||||
image,
|
||||
timing,
|
||||
failed,
|
||||
fallbackIcon,
|
||||
modelId,
|
||||
}: ImageDisplayProps) {
|
||||
const [isZoomed, setIsZoomed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isZoomed) {
|
||||
window.history.pushState({ zoomed: true }, '');
|
||||
}
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isZoomed) {
|
||||
setIsZoomed(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePopState = () => {
|
||||
if (isZoomed) {
|
||||
setIsZoomed(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isZoomed) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, [isZoomed]);
|
||||
|
||||
const handleImageClick = (e: React.MouseEvent) => {
|
||||
if (image) {
|
||||
e.stopPropagation();
|
||||
setIsZoomed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActionClick = (
|
||||
e: React.MouseEvent,
|
||||
imageData: string,
|
||||
provider: string
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
imageHelpers.shareOrDownload(imageData, provider).catch((error) => {
|
||||
console.error('Failed to share/download image:', error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full aspect-square group bg-zinc-50 rounded-lg',
|
||||
image && !failed && 'cursor-pointer',
|
||||
(!image || failed) && 'border-1 border-zinc-100'
|
||||
)}
|
||||
onClick={handleImageClick}
|
||||
>
|
||||
{(image || failed) && (
|
||||
<div className="absolute top-2 left-2 max-w-[75%] bg-white/95 px-2 py-1 flex items-center gap-2 rounded-lg">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Label className="text-xs text-gray-900 truncate min-w-0 grow">
|
||||
{imageHelpers.formatModelId(modelId)}
|
||||
</Label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{modelId}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
{image && !failed ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`data:image/png;base64,${image}`}
|
||||
alt={`Generated by ${provider}`}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="absolute bottom-2 left-2 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => handleActionClick(e, image, provider)}
|
||||
>
|
||||
<span className="sm:hidden">
|
||||
<Share className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="hidden sm:block">
|
||||
<Download className="h-4 w-4" />
|
||||
</span>
|
||||
</Button>
|
||||
{timing?.elapsed && (
|
||||
<div className="absolute bottom-2 right-2 bg-black/70 backdrop-blur-sm rounded-md px-2 py-1 shadow">
|
||||
<span className="text-xs text-white/90 font-medium">
|
||||
{(timing.elapsed / 1000).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{failed ? (
|
||||
fallbackIcon || <AlertCircle className="h-8 w-8 text-red-500" />
|
||||
) : image ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`data:image/png;base64,${image}`}
|
||||
alt={`Generated by ${provider}`}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="absolute bottom-2 left-2 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => handleActionClick(e, image, provider)}
|
||||
>
|
||||
<span className="sm:hidden">
|
||||
<Share className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="hidden sm:block">
|
||||
<Download className="h-4 w-4" />
|
||||
</span>
|
||||
</Button>
|
||||
</>
|
||||
) : timing?.startTime ? (
|
||||
<>
|
||||
{/* <div className="text-zinc-400 mb-2">{provider}</div> */}
|
||||
<Stopwatch startTime={timing.startTime} />
|
||||
</>
|
||||
) : (
|
||||
<ImageIcon className="h-12 w-12 text-zinc-300" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isZoomed &&
|
||||
image &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center cursor-pointer min-h-[100dvh] w-screen"
|
||||
onClick={() => setIsZoomed(false)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`data:image/png;base64,${image}`}
|
||||
alt={`Generated by ${provider}`}
|
||||
className="max-h-[90dvh] max-w-[90vw] object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,120 +0,0 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { AlertCircle, ChevronDown, Settings } from 'lucide-react';
|
||||
import type {
|
||||
GeneratedImage,
|
||||
ImageError,
|
||||
ProviderTiming,
|
||||
} from '../lib/image-types';
|
||||
import {
|
||||
PROVIDER_ORDER,
|
||||
type ProviderKey,
|
||||
initializeProviderRecord,
|
||||
} from '../lib/provider-config';
|
||||
import { ImageCarousel } from './ImageCarousel';
|
||||
import { ImageDisplay } from './ImageDisplay';
|
||||
|
||||
interface ImageGeneratorProps {
|
||||
images: GeneratedImage[];
|
||||
errors: ImageError[];
|
||||
failedProviders: ProviderKey[];
|
||||
timings: Record<ProviderKey, ProviderTiming>;
|
||||
enabledProviders: Record<ProviderKey, boolean>;
|
||||
toggleView: () => void;
|
||||
}
|
||||
|
||||
export function ImageGenerator({
|
||||
images,
|
||||
errors,
|
||||
failedProviders,
|
||||
timings,
|
||||
enabledProviders,
|
||||
toggleView,
|
||||
}: ImageGeneratorProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* If there are errors, render a collapsible alert */}
|
||||
{errors.length > 0 && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex items-center gap-2 text-destructive"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{errors.length} {errors.length === 1 ? 'error' : 'errors'}{' '}
|
||||
occurred
|
||||
<ChevronDown className="h-4 w-4 transition-transform duration-200 data-[state=open]:rotate-180" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-2 mt-2">
|
||||
{errors.map((err, index) => (
|
||||
<Alert key={index} variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="ml-3">
|
||||
<AlertTitle className="capitalize">
|
||||
{err.provider} Error
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-1 text-sm">
|
||||
{err.message}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold">Generated Images</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
className=""
|
||||
onClick={() => toggleView()}
|
||||
size="icon"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mobile layout: Carousel */}
|
||||
<div className="sm:hidden">
|
||||
<ImageCarousel
|
||||
providers={PROVIDER_ORDER}
|
||||
images={images}
|
||||
timings={timings}
|
||||
failedProviders={failedProviders}
|
||||
enabledProviders={enabledProviders}
|
||||
providerToModel={initializeProviderRecord<string>()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop layout: Grid */}
|
||||
<div className="hidden sm:grid sm:grid-cols-2 2xl:grid-cols-4 gap-6">
|
||||
{PROVIDER_ORDER.map((provider) => {
|
||||
const imageItem = images.find((img) => img.provider === provider);
|
||||
const imageData = imageItem?.image;
|
||||
const timing = timings[provider];
|
||||
return (
|
||||
<ImageDisplay
|
||||
key={provider}
|
||||
provider={provider}
|
||||
image={imageData}
|
||||
timing={timing}
|
||||
failed={failedProviders.includes(provider)}
|
||||
enabled={enabledProviders[provider]}
|
||||
modelId={imageItem?.modelId ?? ''}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowUpRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { QualityModeToggle } from './QualityModeToggle';
|
||||
|
||||
export const ImageGeneratorHeader = () => {
|
||||
return (
|
||||
<header className="mb-4">
|
||||
<div className="mx-auto flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-xl flex sm:text-xl sm:font-bold antialiased font-semibold">
|
||||
<span className="mr-2">🏞️</span> AI Image Generator
|
||||
</h1>
|
||||
</div>
|
||||
{/* <Link href={`${process.env.NEXT_PUBLIC_APP_URL}`} target="_blank">
|
||||
<Button size="icon" className="block sm:hidden">
|
||||
<ArrowUpRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link> */}
|
||||
|
||||
{/* <QualityModeToggle onValueChange={() => {}} value="performance" /> */}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
@ -1,144 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useImageGeneration } from '../hooks/use-image-generation';
|
||||
import {
|
||||
MODEL_CONFIGS,
|
||||
type ModelMode,
|
||||
PROVIDERS,
|
||||
PROVIDER_ORDER,
|
||||
type ProviderKey,
|
||||
initializeProviderRecord,
|
||||
} from '../lib/provider-config';
|
||||
import type { Suggestion } from '../lib/suggestions';
|
||||
import { ImageGeneratorHeader } from './ImageGeneratorHeader';
|
||||
import { ModelCardCarousel } from './ModelCardCarousel';
|
||||
import { ModelSelect } from './ModelSelect';
|
||||
import { PromptInput } from './PromptInput';
|
||||
|
||||
export function ImagePlayground({
|
||||
suggestions,
|
||||
}: {
|
||||
suggestions: Suggestion[];
|
||||
}) {
|
||||
const {
|
||||
images,
|
||||
timings,
|
||||
failedProviders,
|
||||
isLoading,
|
||||
startGeneration,
|
||||
activePrompt,
|
||||
} = useImageGeneration();
|
||||
|
||||
const [showProviders, setShowProviders] = useState(true);
|
||||
const [selectedModels, setSelectedModels] = useState<
|
||||
Record<ProviderKey, string>
|
||||
>(MODEL_CONFIGS.performance);
|
||||
const [enabledProviders, setEnabledProviders] = useState(
|
||||
initializeProviderRecord(true)
|
||||
);
|
||||
const [mode, setMode] = useState<ModelMode>('performance');
|
||||
const toggleView = () => {
|
||||
setShowProviders((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleModeChange = (newMode: ModelMode) => {
|
||||
setMode(newMode);
|
||||
setSelectedModels(MODEL_CONFIGS[newMode]);
|
||||
setShowProviders(true);
|
||||
};
|
||||
|
||||
const handleModelChange = (providerKey: ProviderKey, model: string) => {
|
||||
setSelectedModels((prev) => ({ ...prev, [providerKey]: model }));
|
||||
};
|
||||
|
||||
const handleProviderToggle = (provider: string, enabled: boolean) => {
|
||||
setEnabledProviders((prev) => ({
|
||||
...prev,
|
||||
[provider]: enabled,
|
||||
}));
|
||||
};
|
||||
|
||||
const providerToModel = {
|
||||
replicate: selectedModels.replicate,
|
||||
openai: selectedModels.openai,
|
||||
fireworks: selectedModels.fireworks,
|
||||
fal: selectedModels.fal,
|
||||
};
|
||||
|
||||
const handlePromptSubmit = (newPrompt: string) => {
|
||||
const activeProviders = PROVIDER_ORDER.filter((p) => enabledProviders[p]);
|
||||
if (activeProviders.length > 0) {
|
||||
startGeneration(newPrompt, activeProviders, providerToModel);
|
||||
}
|
||||
setShowProviders(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-background py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto">
|
||||
{/* header */}
|
||||
{/* <ImageGeneratorHeader /> */}
|
||||
|
||||
{/* input prompt */}
|
||||
<PromptInput
|
||||
onSubmit={handlePromptSubmit}
|
||||
isLoading={isLoading}
|
||||
showProviders={showProviders}
|
||||
onToggleProviders={toggleView}
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
suggestions={suggestions}
|
||||
/>
|
||||
|
||||
{/* models carousel */}
|
||||
{(() => {
|
||||
const getModelProps = () =>
|
||||
(Object.keys(PROVIDERS) as ProviderKey[]).map((key) => {
|
||||
const provider = PROVIDERS[key];
|
||||
const imageItem = images.find((img) => img.provider === key);
|
||||
const imageData = imageItem?.image;
|
||||
const modelId = imageItem?.modelId ?? 'N/A';
|
||||
const timing = timings[key];
|
||||
|
||||
return {
|
||||
label: provider.displayName,
|
||||
models: provider.models,
|
||||
value: selectedModels[key],
|
||||
providerKey: key,
|
||||
onChange: (model: string, providerKey: ProviderKey) =>
|
||||
handleModelChange(providerKey, model),
|
||||
iconPath: provider.iconPath,
|
||||
color: provider.color,
|
||||
enabled: enabledProviders[key],
|
||||
onToggle: (enabled: boolean) =>
|
||||
handleProviderToggle(key, enabled),
|
||||
image: imageData,
|
||||
modelId,
|
||||
timing,
|
||||
failed: failedProviders.includes(key),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:hidden">
|
||||
<ModelCardCarousel models={getModelProps()} />
|
||||
</div>
|
||||
<div className="hidden md:grid md:grid-cols-2 2xl:grid-cols-4 gap-8">
|
||||
{getModelProps().map((props) => (
|
||||
<ModelSelect key={props.label} {...props} />
|
||||
))}
|
||||
</div>
|
||||
{activePrompt && activePrompt.length > 0 && (
|
||||
<div className="text-center mt-8 text-muted-foreground">
|
||||
{activePrompt}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,119 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from '@/components/ui/carousel';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { ProviderTiming } from '../lib/image-types';
|
||||
import type { ProviderKey } from '../lib/provider-config';
|
||||
import { ModelSelect } from './ModelSelect';
|
||||
|
||||
interface ModelCardCarouselProps {
|
||||
models: Array<{
|
||||
label: string;
|
||||
models: string[];
|
||||
iconPath: string;
|
||||
color: string;
|
||||
value: string;
|
||||
providerKey: ProviderKey;
|
||||
enabled?: boolean;
|
||||
onToggle?: (enabled: boolean) => void;
|
||||
onChange: (value: string, providerKey: ProviderKey) => void;
|
||||
image: string | null | undefined;
|
||||
timing?: ProviderTiming;
|
||||
failed?: boolean;
|
||||
modelId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ModelCardCarousel({ models }: ModelCardCarouselProps) {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
const initialized = useRef(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!api || initialized.current) return;
|
||||
|
||||
// Force scroll in multiple ways
|
||||
api.scrollTo(0, false);
|
||||
api.scrollPrev(); // Reset any potential offset
|
||||
api.scrollTo(0, false);
|
||||
|
||||
initialized.current = true;
|
||||
setCurrentSlide(0);
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
const onSelect = () => {
|
||||
setCurrentSlide(api.selectedScrollSnap());
|
||||
};
|
||||
|
||||
api.on('select', onSelect);
|
||||
return () => {
|
||||
api.off('select', onSelect);
|
||||
return;
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full mb-8">
|
||||
<Carousel
|
||||
setApi={setApi}
|
||||
opts={{
|
||||
align: 'start',
|
||||
dragFree: false,
|
||||
containScroll: 'trimSnaps',
|
||||
loop: true,
|
||||
}}
|
||||
>
|
||||
<CarouselContent>
|
||||
{models.map((model, i) => (
|
||||
<CarouselItem key={model.label}>
|
||||
<ModelSelect
|
||||
{...model}
|
||||
onChange={(value, providerKey) =>
|
||||
model.onChange(value, providerKey)
|
||||
}
|
||||
/>
|
||||
<div className="text-center text-sm text-muted-foreground mt-4">
|
||||
{i + 1} of {models.length}
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
|
||||
<CarouselPrevious className="left-0 bg-background/80 backdrop-blur-sm" />
|
||||
<CarouselNext className="right-0 bg-background/80 backdrop-blur-sm" />
|
||||
</Carousel>
|
||||
|
||||
{/* Dot Indicators */}
|
||||
<div className="absolute -bottom-6 left-0 right-0">
|
||||
<div className="flex justify-center gap-1">
|
||||
{models.map((_, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
className={cn(
|
||||
'h-1.5 rounded-full transition-all',
|
||||
index === currentSlide
|
||||
? 'w-4 bg-primary'
|
||||
: 'w-1.5 bg-primary/50'
|
||||
)}
|
||||
onClick={() => api?.scrollTo(index)}
|
||||
>
|
||||
<span className="sr-only">Go to model {index + 1}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,152 +0,0 @@
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Link from 'next/link';
|
||||
import { imageHelpers } from '../lib/image-helpers';
|
||||
import type { ProviderTiming } from '../lib/image-types';
|
||||
import {
|
||||
FireworksIcon,
|
||||
OpenAIIcon,
|
||||
ReplicateIcon,
|
||||
falAILogo,
|
||||
} from '../lib/logos';
|
||||
import type { ProviderKey } from '../lib/provider-config';
|
||||
import { ImageDisplay } from './ImageDisplay';
|
||||
|
||||
interface ModelSelectProps {
|
||||
label: string;
|
||||
models: string[];
|
||||
value: string;
|
||||
providerKey: ProviderKey;
|
||||
onChange: (value: string, providerKey: ProviderKey) => void;
|
||||
iconPath: string;
|
||||
color: string;
|
||||
enabled?: boolean;
|
||||
onToggle?: (enabled: boolean) => void;
|
||||
image: string | null | undefined;
|
||||
timing?: ProviderTiming;
|
||||
failed?: boolean;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS = {
|
||||
openai: OpenAIIcon,
|
||||
replicate: ReplicateIcon,
|
||||
fireworks: FireworksIcon,
|
||||
fal: falAILogo,
|
||||
} as const;
|
||||
|
||||
const PROVIDER_LINKS = {
|
||||
openai: 'openai',
|
||||
replicate: 'replicate',
|
||||
fireworks: 'fireworks',
|
||||
fal: 'fal',
|
||||
} as const;
|
||||
|
||||
export function ModelSelect({
|
||||
label,
|
||||
models,
|
||||
value,
|
||||
providerKey,
|
||||
onChange,
|
||||
enabled = true,
|
||||
image,
|
||||
timing,
|
||||
failed,
|
||||
modelId,
|
||||
}: ModelSelectProps) {
|
||||
const Icon = PROVIDER_ICONS[providerKey];
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn('w-full transition-opacity', enabled ? '' : 'opacity-50')}
|
||||
>
|
||||
<CardContent className="h-full">
|
||||
<div className="flex items-center justify-between gap-2 mb-4">
|
||||
<div className="flex flex-col items-center gap-4 w-full transition-opacity duration-200">
|
||||
{/* model provider icon */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
className="bg-primary hover:opacity-80 p-2 rounded-full"
|
||||
href={
|
||||
'https://sdk.vercel.ai/providers/ai-sdk-providers/' +
|
||||
PROVIDER_LINKS[providerKey]
|
||||
}
|
||||
target="_blank"
|
||||
>
|
||||
<div className="text-primary-foreground">
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
className="hover:opacity-80"
|
||||
href={
|
||||
'https://sdk.vercel.ai/providers/ai-sdk-providers/' +
|
||||
PROVIDER_LINKS[providerKey]
|
||||
}
|
||||
target="_blank"
|
||||
>
|
||||
<h3 className="font-semibold text-lg">{label}</h3>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* models in provider */}
|
||||
<div className="flex justify-center items-center w-full">
|
||||
<Select
|
||||
defaultValue={value}
|
||||
value={value}
|
||||
onValueChange={(selectedValue) =>
|
||||
onChange(selectedValue, providerKey)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="cursor-pointer w-full">
|
||||
<SelectValue placeholder={value || 'Select a model'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model} value={model} className="">
|
||||
<span className="hidden xl:inline">
|
||||
{imageHelpers.formatModelId(model).length > 30
|
||||
? imageHelpers.formatModelId(model).slice(0, 30) +
|
||||
'...'
|
||||
: imageHelpers.formatModelId(model)}
|
||||
</span>
|
||||
<span className="hidden lg:inline xl:hidden">
|
||||
{imageHelpers.formatModelId(model).length > 20
|
||||
? imageHelpers.formatModelId(model).slice(0, 20) +
|
||||
'...'
|
||||
: imageHelpers.formatModelId(model)}
|
||||
</span>
|
||||
|
||||
<span className="lg:hidden">
|
||||
{imageHelpers.formatModelId(model)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageDisplay
|
||||
modelId={modelId}
|
||||
provider={providerKey}
|
||||
image={image}
|
||||
timing={timing}
|
||||
failed={failed}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -1,119 +0,0 @@
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowUp, ArrowUpRight, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { type Suggestion, getRandomSuggestions } from '../lib/suggestions';
|
||||
|
||||
type QualityMode = 'performance' | 'quality';
|
||||
|
||||
// showProviders/onToggleProviders/mode/onModeChange are not used yet
|
||||
interface PromptInputProps {
|
||||
onSubmit: (prompt: string) => void;
|
||||
isLoading?: boolean;
|
||||
showProviders: boolean;
|
||||
onToggleProviders: () => void;
|
||||
mode: QualityMode;
|
||||
onModeChange: (mode: QualityMode) => void;
|
||||
suggestions: Suggestion[];
|
||||
}
|
||||
|
||||
export function PromptInput({
|
||||
suggestions: initSuggestions,
|
||||
isLoading,
|
||||
onSubmit,
|
||||
}: PromptInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>(initSuggestions);
|
||||
|
||||
const updateSuggestions = () => {
|
||||
setSuggestions(getRandomSuggestions());
|
||||
};
|
||||
const handleSuggestionSelect = (prompt: string) => {
|
||||
setInput(prompt);
|
||||
// onSubmit(prompt);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isLoading && input.trim()) {
|
||||
onSubmit(input);
|
||||
}
|
||||
};
|
||||
|
||||
// const handleRefreshSuggestions = () => {
|
||||
// setCurrentSuggestions(getRandomSuggestions());
|
||||
// };
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (!isLoading && input.trim()) {
|
||||
onSubmit(input);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mb-8">
|
||||
<div className="bg-card rounded-xl p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter your prompt here"
|
||||
rows={3}
|
||||
className="text-base bg-transparent border-muted p-2 resize-none placeholder:text-muted-foreground text-foreground focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
{/* refresh suggestions */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={updateSuggestions}
|
||||
className="flex items-center justify-between cursor-pointer px-2 rounded-lg py-1 bg-background text-sm hover:opacity-70 group transition-opacity duration-200"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 text-muted-foreground group-hover:opacity-70" />
|
||||
</button>
|
||||
{/* suggestions */}
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
onClick={() => handleSuggestionSelect(suggestion.prompt)}
|
||||
className={cn(
|
||||
'flex items-center justify-between cursor-pointer px-2 rounded-lg py-1 bg-background text-sm hover:opacity-70 group transition-opacity duration-200',
|
||||
index > 2
|
||||
? 'hidden md:flex'
|
||||
: index > 1
|
||||
? 'hidden sm:flex'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
<span className="text-foreground text-xs sm:text-sm">
|
||||
{suggestion.text.toLowerCase()}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowUpRight className="ml-1 h-2 w-2 sm:h-3 sm:w-3 text-muted-foreground group-hover:opacity-70" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* submit prompt */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="h-8 w-8 cursor-pointer rounded-full bg-primary flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-3 h-3 text-primary-foreground animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="w-5 h-5 text-primary-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import { Lightbulb } from 'lucide-react';
|
||||
import type { Suggestion } from '../lib/suggestions';
|
||||
|
||||
interface PromptSuggestionsProps {
|
||||
suggestions: Suggestion[];
|
||||
onSelect: (prompt: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function PromptSuggestions({
|
||||
suggestions,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
}: PromptSuggestionsProps) {
|
||||
return (
|
||||
<div className="relative flex-grow overflow-hidden">
|
||||
<ScrollArea className="w-full whitespace-nowrap rounded-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-2 py-1">
|
||||
{suggestions.map((suggestion) => (
|
||||
<Button
|
||||
key={suggestion.text}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1.5"
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(suggestion.prompt)}
|
||||
>
|
||||
<Lightbulb className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{suggestion.text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" className="h-2.5" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,56 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Sparkles, Zap } from 'lucide-react';
|
||||
|
||||
export type QualityMode = 'performance' | 'quality';
|
||||
|
||||
interface QualityModeToggleProps {
|
||||
value: QualityMode;
|
||||
onValueChange: (value: QualityMode) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function QualityModeToggle({
|
||||
onValueChange,
|
||||
disabled = false,
|
||||
}: QualityModeToggleProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 min-w-[240px]">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onValueChange('performance');
|
||||
toast({
|
||||
description: 'Switching to faster models for quicker generation',
|
||||
duration: 2000,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
Performance
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onValueChange('quality');
|
||||
toast({
|
||||
description:
|
||||
'Switching to higher quality models for better results',
|
||||
duration: 2000,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Quality
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function Stopwatch({ startTime }: { startTime: number }) {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setElapsed(Date.now() - startTime);
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [startTime]);
|
||||
|
||||
return (
|
||||
<div className="text-lg text-zinc-500 font-mono">
|
||||
{(elapsed / 1000).toFixed(1)}s
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,172 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { GenerateImageResponse } from '../lib/api-types';
|
||||
import type {
|
||||
ImageError,
|
||||
ImageResult,
|
||||
ProviderTiming,
|
||||
} from '../lib/image-types';
|
||||
import {
|
||||
type ProviderKey,
|
||||
initializeProviderRecord,
|
||||
} from '../lib/provider-config';
|
||||
|
||||
interface UseImageGenerationReturn {
|
||||
images: ImageResult[];
|
||||
errors: ImageError[];
|
||||
timings: Record<ProviderKey, ProviderTiming>;
|
||||
failedProviders: ProviderKey[];
|
||||
isLoading: boolean;
|
||||
startGeneration: (
|
||||
prompt: string,
|
||||
providers: ProviderKey[],
|
||||
providerToModel: Record<ProviderKey, string>
|
||||
) => Promise<void>;
|
||||
resetState: () => void;
|
||||
activePrompt: string;
|
||||
}
|
||||
|
||||
export function useImageGeneration(): UseImageGenerationReturn {
|
||||
const [images, setImages] = useState<ImageResult[]>([]);
|
||||
const [errors, setErrors] = useState<ImageError[]>([]);
|
||||
const [timings, setTimings] = useState<Record<ProviderKey, ProviderTiming>>(
|
||||
initializeProviderRecord<ProviderTiming>()
|
||||
);
|
||||
const [failedProviders, setFailedProviders] = useState<ProviderKey[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [activePrompt, setActivePrompt] = useState('');
|
||||
|
||||
const resetState = () => {
|
||||
setImages([]);
|
||||
setErrors([]);
|
||||
setTimings(initializeProviderRecord<ProviderTiming>());
|
||||
setFailedProviders([]);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const startGeneration = async (
|
||||
prompt: string,
|
||||
providers: ProviderKey[],
|
||||
providerToModel: Record<ProviderKey, string>
|
||||
) => {
|
||||
setActivePrompt(prompt);
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// Initialize images array with null values
|
||||
setImages(
|
||||
providers.map((provider) => ({
|
||||
provider,
|
||||
image: null,
|
||||
modelId: providerToModel[provider],
|
||||
}))
|
||||
);
|
||||
|
||||
// Clear previous state
|
||||
setErrors([]);
|
||||
setFailedProviders([]);
|
||||
|
||||
// Initialize timings with start times
|
||||
const now = Date.now();
|
||||
setTimings(
|
||||
Object.fromEntries(
|
||||
providers.map((provider) => [provider, { startTime: now }])
|
||||
) as Record<ProviderKey, ProviderTiming>
|
||||
);
|
||||
|
||||
// Helper to fetch a single provider
|
||||
const generateImage = async (provider: ProviderKey, modelId: string) => {
|
||||
const startTime = now;
|
||||
console.log(
|
||||
`Generate image request [provider=${provider}, modelId=${modelId}]`
|
||||
);
|
||||
try {
|
||||
const request = {
|
||||
prompt,
|
||||
provider,
|
||||
modelId,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/generate-images', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
const data = (await response.json()) as GenerateImageResponse;
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `Server error: ${response.status}`);
|
||||
}
|
||||
|
||||
const completionTime = Date.now();
|
||||
const elapsed = completionTime - startTime;
|
||||
setTimings((prev) => ({
|
||||
...prev,
|
||||
[provider]: {
|
||||
startTime,
|
||||
completionTime,
|
||||
elapsed,
|
||||
},
|
||||
}));
|
||||
|
||||
console.log(
|
||||
`Successful image response [provider=${provider}, modelId=${modelId}, elapsed=${elapsed}ms]`
|
||||
);
|
||||
|
||||
// Update image in state
|
||||
setImages((prevImages) =>
|
||||
prevImages.map((item) =>
|
||||
item.provider === provider
|
||||
? { ...item, image: data.image ?? null, modelId }
|
||||
: item
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Error [provider=${provider}, modelId=${modelId}]:`,
|
||||
err
|
||||
);
|
||||
setFailedProviders((prev) => [...prev, provider]);
|
||||
setErrors((prev) => [
|
||||
...prev,
|
||||
{
|
||||
provider,
|
||||
message:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'An unexpected error occurred',
|
||||
},
|
||||
]);
|
||||
|
||||
setImages((prevImages) =>
|
||||
prevImages.map((item) =>
|
||||
item.provider === provider
|
||||
? { ...item, image: null, modelId }
|
||||
: item
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Generate images for all active providers
|
||||
const fetchPromises = providers.map((provider) => {
|
||||
const modelId = providerToModel[provider];
|
||||
return generateImage(provider, modelId);
|
||||
});
|
||||
|
||||
await Promise.all(fetchPromises);
|
||||
} catch (error) {
|
||||
console.error('Error fetching images:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
images,
|
||||
errors,
|
||||
timings,
|
||||
failedProviders,
|
||||
isLoading,
|
||||
startGeneration,
|
||||
resetState,
|
||||
activePrompt,
|
||||
};
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
import type { ProviderKey } from './provider-config';
|
||||
|
||||
export interface GenerateImageRequest {
|
||||
prompt: string;
|
||||
provider: ProviderKey;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export interface GenerateImageResponse {
|
||||
image?: string;
|
||||
error?: string;
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
export const imageHelpers = {
|
||||
base64ToBlob: (base64Data: string, type = 'image/png'): Blob => {
|
||||
const byteString = atob(base64Data);
|
||||
const arrayBuffer = new ArrayBuffer(byteString.length);
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
uint8Array[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new Blob([uint8Array], { type });
|
||||
},
|
||||
|
||||
generateImageFileName: (provider: string): string => {
|
||||
const uniqueId = Math.random().toString(36).substring(2, 8);
|
||||
return `${provider}-${uniqueId}`.replace(/[^a-z0-9-]/gi, '');
|
||||
},
|
||||
|
||||
shareOrDownload: async (
|
||||
imageData: string,
|
||||
provider: string
|
||||
): Promise<void> => {
|
||||
const fileName = imageHelpers.generateImageFileName(provider);
|
||||
const blob = imageHelpers.base64ToBlob(imageData);
|
||||
const file = new File([blob], `${fileName}.png`, { type: 'image/png' });
|
||||
|
||||
try {
|
||||
if (navigator.share) {
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title: `Image generated by ${provider}`,
|
||||
});
|
||||
} else {
|
||||
throw new Error('Share API not available');
|
||||
}
|
||||
} catch (error) {
|
||||
// Fall back to download for any error (including share cancellation)
|
||||
console.error('Error sharing/downloading:', error);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = `${fileName}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
},
|
||||
|
||||
formatModelId: (modelId: string): string => {
|
||||
return modelId.split('/').pop() || modelId;
|
||||
},
|
||||
};
|
@ -1,24 +0,0 @@
|
||||
import type { ProviderKey } from './provider-config';
|
||||
|
||||
export interface GeneratedImage {
|
||||
provider: ProviderKey;
|
||||
image: string | null;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
export interface ImageResult {
|
||||
provider: ProviderKey;
|
||||
image: string | null;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
export interface ImageError {
|
||||
provider: ProviderKey;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ProviderTiming {
|
||||
startTime?: number;
|
||||
completionTime?: number;
|
||||
elapsed?: number;
|
||||
}
|
@ -1,82 +0,0 @@
|
||||
export const FireworksIcon = ({ size = 16 }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
width={size}
|
||||
viewBox="0 0 638 315"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="white"
|
||||
>
|
||||
<g transform="scale(0.8) translate(75,-10)">
|
||||
<path
|
||||
d="M318.563 221.755C300.863 221.755 284.979 211.247 278.206 194.978L196.549 0H244.342L318.842 178.361L393.273 0H441.066L358.92 195.048C352.112 211.247 336.263 221.755 318.563 221.755Z"
|
||||
className="fill-logo"
|
||||
/>
|
||||
<path
|
||||
d="M425.111 314.933C407.481 314.933 391.667 304.494 384.824 288.366C377.947 272.097 381.507 253.524 393.936 240.921L542.657 90.2803L561.229 134.094L425.076 271.748L619.147 270.666L637.72 314.479L425.146 315.003L425.076 314.933H425.111Z"
|
||||
className="fill-logo"
|
||||
/>
|
||||
<path
|
||||
d="M0 314.408L18.5727 270.595L212.643 271.677L76.525 133.988L95.0977 90.1748L243.819 240.816C256.247 253.384 259.843 272.026 252.93 288.26C246.088 304.424 230.203 314.827 212.643 314.827L0.0698221 314.339L0 314.408Z"
|
||||
className="fill-logo"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const OpenAIIcon = ({ size = 16 }) => {
|
||||
return (
|
||||
<svg
|
||||
data-testid="geist-icon"
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: 'currentcolor' }}
|
||||
>
|
||||
<path
|
||||
transform="scale(0.8) translate(2,2)"
|
||||
d="M14.9449 6.54871C15.3128 5.45919 15.1861 4.26567 14.5978 3.27464C13.7131 1.75461 11.9345 0.972595 10.1974 1.3406C9.42464 0.481584 8.3144 -0.00692594 7.15045 7.42132e-05C5.37487 -0.00392587 3.79946 1.1241 3.2532 2.79113C2.11256 3.02164 1.12799 3.72615 0.551837 4.72468C-0.339497 6.24071 -0.1363 8.15175 1.05451 9.45178C0.686626 10.5413 0.813308 11.7348 1.40162 12.7258C2.28637 14.2459 4.06498 15.0279 5.80204 14.6599C6.5743 15.5189 7.68504 16.0074 8.849 15.9999C10.6256 16.0044 12.2015 14.8754 12.7478 13.2069C13.8884 12.9764 14.873 12.2718 15.4491 11.2733C16.3394 9.75728 16.1357 7.84774 14.9454 6.54771L14.9449 6.54871ZM8.85001 14.9544C8.13907 14.9554 7.45043 14.7099 6.90468 14.2604C6.92951 14.2474 6.97259 14.2239 7.00046 14.2069L10.2293 12.3668C10.3945 12.2743 10.4959 12.1008 10.4949 11.9133V7.42173L11.8595 8.19925C11.8742 8.20625 11.8838 8.22025 11.8858 8.23625V11.9558C11.8838 13.6099 10.5263 14.9509 8.85001 14.9544ZM2.32133 12.2028C1.9651 11.5958 1.8369 10.8843 1.95902 10.1938C1.98284 10.2078 2.02489 10.2333 2.05479 10.2503L5.28366 12.0903C5.44733 12.1848 5.65003 12.1848 5.81421 12.0903L9.75604 9.84429V11.3993C9.75705 11.4153 9.74945 11.4308 9.73678 11.4408L6.47295 13.3004C5.01915 14.1264 3.1625 13.6354 2.32184 12.2028H2.32133ZM1.47155 5.24819C1.82626 4.64017 2.38619 4.17516 3.05305 3.93366C3.05305 3.96116 3.05152 4.00966 3.05152 4.04366V7.72424C3.05051 7.91124 3.15186 8.08475 3.31654 8.17725L7.25838 10.4228L5.89376 11.2003C5.88008 11.2093 5.86285 11.2108 5.84765 11.2043L2.58331 9.34327C1.13255 8.51426 0.63494 6.68272 1.47104 5.24869L1.47155 5.24819ZM12.6834 7.82274L8.74157 5.57669L10.1062 4.79968C10.1199 4.79068 10.1371 4.78918 10.1523 4.79568L13.4166 6.65522C14.8699 7.48373 15.3681 9.31827 14.5284 10.7523C14.1732 11.3593 13.6138 11.8243 12.9474 12.0663V8.27575C12.9489 8.08875 12.8481 7.91574 12.6839 7.82274H12.6834ZM14.0414 5.8057C14.0176 5.7912 13.9756 5.7662 13.9457 5.7492L10.7168 3.90916C10.5531 3.81466 10.3504 3.81466 10.1863 3.90916L6.24442 6.15521V4.60017C6.2434 4.58417 6.251 4.56867 6.26367 4.55867L9.52751 2.70063C10.9813 1.87311 12.84 2.36563 13.6781 3.80066C14.0323 4.40667 14.1605 5.11618 14.0404 5.8057H14.0414ZM5.50257 8.57726L4.13744 7.79974C4.12275 7.79274 4.11312 7.77874 4.11109 7.76274V4.04316C4.11211 2.38713 5.47368 1.0451 7.15197 1.0461C7.86189 1.0461 8.54902 1.2921 9.09476 1.74011C9.06993 1.75311 9.02737 1.77661 8.99899 1.79361L5.77012 3.63365C5.60493 3.72615 5.50358 3.89916 5.50459 4.08666L5.50257 8.57626V8.57726ZM6.24391 7.00022L7.99972 5.9997L9.75553 6.99972V9.00027L7.99972 10.0003L6.24391 9.00027V7.00022Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const ReplicateIcon = ({ size = 16 }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
width={size}
|
||||
viewBox="0 0 1500 1500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ color: 'currentcolor' }}
|
||||
>
|
||||
<g fill="white" transform="scale(0.8) translate(450,450)">
|
||||
<polygon points="1000,427.6 1000,540.6 603.4,540.6 603.4,1000 477,1000 477,427.6" />
|
||||
<polygon points="1000,213.8 1000,327 364.8,327 364.8,1000 238.4,1000 238.4,213.8" />
|
||||
<polygon points="1000,0 1000,113.2 126.4,113.2 126.4,1000 0,1000 0,0" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const falAILogo = ({ size = 16 }: { size: number }) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 170 171"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M109.571 0.690002C112.515 0.690002 114.874 3.08348 115.155 6.01352C117.665 32.149 138.466 52.948 164.603 55.458C167.534 55.7394 169.927 58.0985 169.927 61.042V110.255C169.927 113.198 167.534 115.557 164.603 115.839C138.466 118.349 117.665 139.148 115.155 165.283C114.874 168.213 112.515 170.607 109.571 170.607H60.3553C57.4116 170.607 55.0524 168.213 54.7709 165.283C52.2608 139.148 31.4601 118.349 5.32289 115.839C2.39266 115.557 -0.000976562 113.198 -0.000976562 110.255V61.042C-0.000976562 58.0985 2.39267 55.7394 5.3229 55.458C31.4601 52.948 52.2608 32.149 54.7709 6.01351C55.0524 3.08348 57.4116 0.690002 60.3553 0.690002H109.571ZM34.1182 85.5045C34.1182 113.776 57.0124 136.694 85.2539 136.694C113.495 136.694 136.39 113.776 136.39 85.5045C136.39 57.2332 113.495 34.3147 85.2539 34.3147C57.0124 34.3147 34.1182 57.2332 34.1182 85.5045Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
@ -1,106 +0,0 @@
|
||||
export type ProviderKey = 'replicate' | 'openai' | 'fireworks' | 'fal';
|
||||
export type ModelMode = 'performance' | 'quality';
|
||||
|
||||
export const PROVIDERS: Record<
|
||||
ProviderKey,
|
||||
{
|
||||
displayName: string;
|
||||
iconPath: string;
|
||||
color: string;
|
||||
models: string[];
|
||||
}
|
||||
> = {
|
||||
// https://ai-sdk.dev/providers/ai-sdk-providers/replicate#image-models
|
||||
replicate: {
|
||||
displayName: 'Replicate',
|
||||
iconPath: '/provider-icons/replicate.svg',
|
||||
color: 'from-purple-500 to-blue-500',
|
||||
models: [
|
||||
'black-forest-labs/flux-1.1-pro',
|
||||
'black-forest-labs/flux-1.1-pro-ultra',
|
||||
'black-forest-labs/flux-dev',
|
||||
'black-forest-labs/flux-pro',
|
||||
'black-forest-labs/flux-schnell',
|
||||
'ideogram-ai/ideogram-v2',
|
||||
'ideogram-ai/ideogram-v2-turbo',
|
||||
'luma/photon',
|
||||
'luma/photon-flash',
|
||||
'recraft-ai/recraft-v3',
|
||||
// 'recraft-ai/recraft-v3-svg', // added by Fox
|
||||
// 'stability-ai/stable-diffusion-3.5-medium', // added by Fox
|
||||
'stability-ai/stable-diffusion-3.5-large',
|
||||
'stability-ai/stable-diffusion-3.5-large-turbo',
|
||||
],
|
||||
},
|
||||
// https://ai-sdk.dev/providers/ai-sdk-providers/openai#image-models
|
||||
openai: {
|
||||
displayName: 'OpenAI',
|
||||
iconPath: '/provider-icons/openai.svg',
|
||||
color: 'from-blue-500 to-cyan-500',
|
||||
models: [
|
||||
// 'gpt-image-1', // added by Fox
|
||||
'dall-e-2',
|
||||
'dall-e-3',
|
||||
],
|
||||
},
|
||||
// https://ai-sdk.dev/providers/ai-sdk-providers/fireworks#image-models
|
||||
fireworks: {
|
||||
displayName: 'Fireworks',
|
||||
iconPath: '/provider-icons/fireworks.svg',
|
||||
color: 'from-orange-500 to-red-500',
|
||||
models: [
|
||||
'accounts/fireworks/models/flux-1-dev-fp8',
|
||||
'accounts/fireworks/models/flux-1-schnell-fp8',
|
||||
'accounts/fireworks/models/playground-v2-5-1024px-aesthetic',
|
||||
'accounts/fireworks/models/japanese-stable-diffusion-xl',
|
||||
'accounts/fireworks/models/playground-v2-1024px-aesthetic',
|
||||
'accounts/fireworks/models/SSD-1B',
|
||||
'accounts/fireworks/models/stable-diffusion-xl-1024-v1-0',
|
||||
],
|
||||
},
|
||||
// https://ai-sdk.dev/providers/ai-sdk-providers/fal#image-models
|
||||
fal: {
|
||||
displayName: 'Fal',
|
||||
iconPath: '/provider-icons/fal.svg',
|
||||
color: 'from-orange-500 to-red-500',
|
||||
models: [
|
||||
'fal-ai/flux/dev', // added by Fox
|
||||
'fal-ai/flux-pro/kontext',
|
||||
'fal-ai/flux-pro/kontext/max',
|
||||
'fal-ai/flux-lora',
|
||||
'fal-ai/fast-sdxl',
|
||||
'fal-ai/flux-pro/v1.1-ultra',
|
||||
'fal-ai/ideogram/v2',
|
||||
'fal-ai/recraft-v3',
|
||||
'fal-ai/hyper-sdxl',
|
||||
// 'fal-ai/stable-diffusion-3.5-large',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const MODEL_CONFIGS: Record<ModelMode, Record<ProviderKey, string>> = {
|
||||
performance: {
|
||||
replicate: 'black-forest-labs/flux-1.1-pro',
|
||||
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',
|
||||
openai: 'dall-e-3',
|
||||
fireworks: 'accounts/fireworks/models/flux-1-dev-fp8',
|
||||
fal: 'fal-ai/flux-pro/v1.1-ultra',
|
||||
},
|
||||
};
|
||||
|
||||
export const PROVIDER_ORDER: ProviderKey[] = [
|
||||
'replicate',
|
||||
'openai',
|
||||
'fireworks',
|
||||
'fal',
|
||||
];
|
||||
|
||||
export const initializeProviderRecord = <T>(defaultValue?: T) =>
|
||||
Object.fromEntries(
|
||||
PROVIDER_ORDER.map((key) => [key, defaultValue])
|
||||
) as Record<ProviderKey, T>;
|
@ -1,138 +0,0 @@
|
||||
export interface Suggestion {
|
||||
text: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
const artStyles = ['anime', 'art nouveau', 'ukiyo-e', 'watercolor'];
|
||||
|
||||
const basePrompts: { text: string; prompt: string }[] = [
|
||||
{
|
||||
text: 'Salamander Dusk',
|
||||
prompt: 'A salamander at dusk in a forest pond',
|
||||
},
|
||||
{
|
||||
text: 'Sultry Chicken',
|
||||
prompt:
|
||||
'A sultry chicken peering around the corner from shadows, clearly up to no good',
|
||||
},
|
||||
{
|
||||
text: 'Cat Vercel',
|
||||
prompt: 'A cat launching its website on Vercel',
|
||||
},
|
||||
{
|
||||
text: 'Red Panda',
|
||||
prompt:
|
||||
'A red panda sipping tea under cherry blossoms at sunset with Mount Fuji in the background',
|
||||
},
|
||||
{
|
||||
text: 'Beach Otter',
|
||||
prompt: 'A mischievous otter surfing the waves in Bali at golden hour',
|
||||
},
|
||||
{
|
||||
text: 'Badger Ramen',
|
||||
prompt: 'A pensive honey badger eating a bowl of ramen in Osaka',
|
||||
},
|
||||
{
|
||||
text: 'Zen Frog',
|
||||
prompt:
|
||||
'A frog meditating on a lotus leaf in a tranquil forest pond at dawn, surrounded by fireflies',
|
||||
},
|
||||
{
|
||||
text: 'Macaw Love',
|
||||
prompt:
|
||||
'A colorful macaw delivering a love letter, flying over the Grand Canyon at sunrise',
|
||||
},
|
||||
{
|
||||
text: 'Fox Painting',
|
||||
prompt: 'A fox walking through a field of lavender with a golden sunset',
|
||||
},
|
||||
{
|
||||
text: 'Armadillo Aerospace',
|
||||
prompt:
|
||||
'An armadillo in a rocket at countdown preparing to blast off to Mars',
|
||||
},
|
||||
{
|
||||
text: 'Penguin Delight',
|
||||
prompt: 'A penguin in pajamas eating ice cream while watching television',
|
||||
},
|
||||
{
|
||||
text: 'Echidna Library',
|
||||
prompt:
|
||||
'An echidna reading a book in a cozy library built into the branches of a eucalyptus tree',
|
||||
},
|
||||
{
|
||||
text: 'Capybara Onsen',
|
||||
prompt:
|
||||
'A capybara relaxing in a hot spring surrounded by snow-covered mountains with a waterfall in the background',
|
||||
},
|
||||
{
|
||||
text: 'Lion Throne',
|
||||
prompt:
|
||||
'A regal lion wearing a crown, sitting on a throne in a jungle palace, with waterfalls in the distance',
|
||||
},
|
||||
{
|
||||
text: 'Dolphin Glow',
|
||||
prompt:
|
||||
'A dolphin leaping through a glowing ring of bioluminescence under a starry sky',
|
||||
},
|
||||
{
|
||||
text: 'Owl Detective',
|
||||
prompt:
|
||||
'An owl wearing a monocle and top hat, solving a mystery in a misty forest at midnight',
|
||||
},
|
||||
{
|
||||
text: 'Jellyfish Cathedral',
|
||||
prompt:
|
||||
'A jellyfish floating gracefully in an underwater cathedral made of coral and glass',
|
||||
},
|
||||
{
|
||||
text: 'Platypus River',
|
||||
prompt: 'A platypus foraging in a river with a sunset in the background',
|
||||
},
|
||||
{
|
||||
text: 'Chameleon Urban',
|
||||
prompt:
|
||||
'A chameleon blending into a graffiti-covered wall in an urban jungle',
|
||||
},
|
||||
{
|
||||
text: 'Tortoise Oasis',
|
||||
prompt:
|
||||
'A giant tortoise slowly meandering its way to an oasis in the desert',
|
||||
},
|
||||
{
|
||||
text: 'Hummingbird Morning',
|
||||
prompt:
|
||||
'A hummingbird sipping nectar from a purple bougainvillea at sunrise, captured mid-flight',
|
||||
},
|
||||
{
|
||||
text: 'Polar Bear',
|
||||
prompt:
|
||||
'A polar bear clambering onto an iceberg to greet a friendly harbor seal as dusk falls',
|
||||
},
|
||||
{
|
||||
text: 'Lemur Sunbathing',
|
||||
prompt:
|
||||
'A ring-tailed lemur sunbathing on a rock in Madagascar in early morning light',
|
||||
},
|
||||
];
|
||||
|
||||
function shuffle<T>(array: T[]): T[] {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
export function getRandomSuggestions(count = 5): Suggestion[] {
|
||||
const shuffledPrompts = shuffle(basePrompts);
|
||||
const shuffledStyles = shuffle(artStyles);
|
||||
|
||||
return shuffledPrompts.slice(0, count).map((item, index) => ({
|
||||
text: item.text,
|
||||
prompt: `${item.prompt}, in the style of ${
|
||||
shuffledStyles[index % shuffledStyles.length]
|
||||
}`,
|
||||
}));
|
||||
}
|
@ -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>
|
||||
);
|
||||
});
|
@ -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';
|
||||
@ -74,7 +74,7 @@ export default async function HomePage(props: HomePageProps) {
|
||||
|
||||
<NewsletterCard />
|
||||
|
||||
<CrispChat />
|
||||
<DiscordWidget />
|
||||
</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>
|
||||
);
|
||||
}
|
@ -1,8 +1,6 @@
|
||||
import { ImagePlayground } from '@/ai/image/components/ImagePlayground';
|
||||
import { getRandomSuggestions } from '@/ai/image/lib/suggestions';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
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,19 +25,29 @@ 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>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{/* Image Playground Component */}
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<ImagePlayground suggestions={getRandomSuggestions(5)} />
|
||||
<div>
|
||||
<h1 className="text-4xl text-foreground">{t('content')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { WebContentAnalyzer } from '@/ai/text/components/web-content-analyzer';
|
||||
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,65 +25,28 @@ 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">
|
||||
<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>
|
||||
</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,22 +1,11 @@
|
||||
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';
|
||||
|
||||
interface UsersLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
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();
|
||||
// Check if user is admin
|
||||
const session = await getSession();
|
||||
if (!session || (session.user.role !== 'admin' && !isDemo)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getTranslations('Dashboard.admin');
|
||||
|
||||
const breadcrumbs = [
|
||||
|
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" />;
|
||||
}
|
@ -23,22 +23,18 @@ export default async function BillingLayout({ children }: BillingLayoutProps) {
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<div className="px-4 lg:px-6 space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t('billing.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('billing.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
<div className="px-4 lg:px-6 py-16">
|
||||
<div className="max-w-6xl mx-auto space-y-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t('billing.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('billing.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</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,14 +1,5 @@
|
||||
import BillingCard from '@/components/settings/billing/billing-card';
|
||||
|
||||
/**
|
||||
* Billing page, show billing information
|
||||
*/
|
||||
export default function BillingPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<BillingCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <BillingCard />;
|
||||
}
|
||||
|
@ -1,46 +0,0 @@
|
||||
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
interface CreditsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function CreditsLayout({ children }: CreditsLayoutProps) {
|
||||
const t = await getTranslations('Dashboard.settings');
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
label: t('title'),
|
||||
isCurrentPage: false,
|
||||
},
|
||||
{
|
||||
label: t('credits.title'),
|
||||
isCurrentPage: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<div className="px-4 lg:px-6 space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t('credits.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('credits.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
import CreditsPageClient from '@/components/settings/credits/credits-page-client';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { Routes } from '@/routes';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Credits page, show credit balance and transactions
|
||||
*/
|
||||
export default function CreditsPage() {
|
||||
// If credits are disabled, redirect to billing page
|
||||
if (!websiteConfig.credits.enableCredits) {
|
||||
redirect(Routes.SettingsBilling);
|
||||
}
|
||||
|
||||
return <CreditsPageClient />;
|
||||
}
|
@ -25,22 +25,18 @@ export default async function NotificationsLayout({
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<div className="px-4 lg:px-6 space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t('notification.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('notification.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
<div className="px-4 lg:px-6 py-16">
|
||||
<div className="max-w-6xl mx-auto space-y-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t('notification.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('notification.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</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" />;
|
||||
}
|
@ -2,10 +2,8 @@ import { NewsletterFormCard } from '@/components/settings/notification/newslette
|
||||
|
||||
export default function NotificationPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<NewsletterFormCard />
|
||||
</div>
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<NewsletterFormCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -23,22 +23,18 @@ export default async function ProfileLayout({ children }: ProfileLayoutProps) {
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<div className="px-4 lg:px-6 space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t('profile.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('profile.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
<div className="px-4 lg:px-6 py-16">
|
||||
<div className="max-w-6xl mx-auto space-y-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t('profile.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('profile.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</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" />;
|
||||
}
|
@ -3,13 +3,9 @@ import { UpdateNameCard } from '@/components/settings/profile/update-name-card';
|
||||
|
||||
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 />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<UpdateAvatarCard />
|
||||
</div>
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<UpdateAvatarCard />
|
||||
<UpdateNameCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -25,22 +25,18 @@ export default async function SecurityLayout({
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<div className="px-4 lg:px-6 space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t('security.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('security.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
<div className="px-4 lg:px-6 py-16">
|
||||
<div className="max-w-6xl mx-auto space-y-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t('security.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('security.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</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,20 +1,11 @@
|
||||
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">
|
||||
<DeleteAccountCard />
|
||||
</div>
|
||||
<div className="grid gap-8 grid-cols-1">
|
||||
<PasswordCardWrapper />
|
||||
<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>
|
||||
);
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user