Merge remote-tracking branch 'origin/main' into cloudflare
This commit is contained in:
commit
9b68e3095e
@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Best practices for using Vercel AI SDK
|
||||
globs: **/*.{ts,tsx}
|
||||
globs: *.tsx,*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
|
43
.cursor/rules/database-state-management.mdc
Normal file
43
.cursor/rules/database-state-management.mdc
Normal file
@ -0,0 +1,43 @@
|
||||
---
|
||||
description:
|
||||
globs: *.tsx,*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
# Database and State Management Guide
|
||||
|
||||
## Database (Drizzle ORM)
|
||||
- Schema definitions in `src/db/schema.ts`
|
||||
- Migrations in `drizzle/`
|
||||
- Use `db:generate` to create new migration files based on schema changes
|
||||
- Use `db:migrate` to apply pending migrations to the database
|
||||
- Use `db:push` to sync schema changes directly to the database (development only)
|
||||
- Use `db:studio` to view and manage database data through the Drizzle Studio UI
|
||||
- Follow naming conventions for tables and columns
|
||||
- Use proper data types and constraints
|
||||
- Implement proper indexes
|
||||
- Handle relationships properly
|
||||
- Use transactions when needed
|
||||
|
||||
## State Management (Zustand)
|
||||
- Store definitions in `src/stores/`
|
||||
- Keep stores modular and focused
|
||||
- Use TypeScript for store types
|
||||
- Implement proper state updates
|
||||
- Handle async operations properly
|
||||
- Use selectors for derived state
|
||||
- Implement proper error handling
|
||||
- Use middleware when needed
|
||||
- Keep store logic pure
|
||||
- Document complex state logic
|
||||
|
||||
## Data Flow
|
||||
1. Server-side data fetching in server components
|
||||
2. Client-side state in Zustand stores
|
||||
3. Form state in React Hook Form
|
||||
4. API calls through server actions
|
||||
5. Database operations through Drizzle
|
||||
6. File storage through AWS S3
|
||||
7. Proper error handling at each layer
|
||||
8. Type safety throughout
|
||||
9. Proper validation with Zod
|
||||
10. Proper caching strategies
|
@ -1,10 +1,10 @@
|
||||
---
|
||||
description: Best practices for date and time manipulation with date-fns
|
||||
globs: **/*.{ts,tsx,js,jsx}
|
||||
globs: *.ts,*.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
- Use the `format` function for consistent date formatting across your application.
|
||||
- Implement proper timezone handling using the `utcToZonedTime` function.
|
||||
- Utilize the `intervalToDuration` function for calculating time differences.
|
||||
- Leverage the `isWithinInterval` function for date range checks.
|
||||
- Leverage the `isWithinInterval` function for date range checks.
|
||||
|
39
.cursor/rules/development-workflow.mdc
Normal file
39
.cursor/rules/development-workflow.mdc
Normal file
@ -0,0 +1,39 @@
|
||||
---
|
||||
description:
|
||||
globs: *.tsx,*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
# Development Workflow Guide
|
||||
|
||||
## Available Scripts
|
||||
- `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
|
||||
- `pnpm format`: Format code with Biome
|
||||
- `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
|
||||
- `pnpm email`: Start email template development server
|
||||
|
||||
## Development Process
|
||||
1. Use TypeScript for all new code
|
||||
2. Follow Biome formatting rules
|
||||
3. Write server actions in `src/actions/`
|
||||
4. Use Zustand for client-side state
|
||||
5. Implement database changes through Drizzle migrations
|
||||
6. Use Radix UI components for consistent UI
|
||||
7. Follow the established directory structure
|
||||
8. Write tests for new features
|
||||
9. Update content collections when adding new content
|
||||
10. Use environment variables from `env.example`
|
||||
|
||||
## Code Style
|
||||
- Use functional components with hooks
|
||||
- Implement proper error handling
|
||||
- Follow TypeScript best practices
|
||||
- Use proper type definitions
|
||||
- Document complex logic
|
||||
- Keep components small and focused
|
||||
- Use proper naming conventions
|
@ -1,6 +1,7 @@
|
||||
---
|
||||
description: Best practices for using Drizzle ORM with database
|
||||
globs: **/*.{ts}
|
||||
globs: *.tsx,*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
- Use Drizzle's type-safe query builder for better code completion and safety.
|
||||
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Best practices for Next.js applications and routing
|
||||
globs: **/*.{ts,tsx}
|
||||
globs: *.tsx,*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
|
36
.cursor/rules/project-structure.mdc
Normal file
36
.cursor/rules/project-structure.mdc
Normal file
@ -0,0 +1,36 @@
|
||||
---
|
||||
description:
|
||||
globs: **/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
# Project Structure Guide
|
||||
|
||||
## Core Directories
|
||||
- `src/app/`: Next.js app router pages and layouts
|
||||
- `src/components/`: Reusable React components
|
||||
- `src/lib/`: Utility functions and shared code
|
||||
- `src/db/`: Database schema and migrations using Drizzle ORM
|
||||
- `src/stores/`: Zustand state management
|
||||
- `src/actions/`: Server actions and API routes
|
||||
- `src/hooks/`: Custom React hooks
|
||||
- `src/types/`: TypeScript type definitions
|
||||
- `src/i18n/`: Internationalization setup
|
||||
- `src/mail/`: Email templates and mail functionality
|
||||
- `src/payment/`: Payment integration
|
||||
- `src/analytics/`: Analytics and tracking
|
||||
- `src/storage/`: File storage integration
|
||||
|
||||
## Configuration Files
|
||||
- `next.config.ts`: Next.js configuration
|
||||
- `drizzle.config.ts`: Database configuration
|
||||
- `biome.json`: Code formatting and linting rules
|
||||
- `tsconfig.json`: TypeScript configuration
|
||||
- `components.json`: UI components configuration
|
||||
|
||||
## Content Management
|
||||
- `content/`: MDX content files
|
||||
- `content-collections.ts`: Content collection configuration
|
||||
|
||||
## Environment
|
||||
- `env.example`: Environment variables template
|
||||
- `global.d.ts`: Global TypeScript declarations
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Best practices for using Radix UI components
|
||||
globs: **/*.{ts,tsx}
|
||||
globs: *.tsx,*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Best practices for React component development
|
||||
globs: **/*.{ts,tsx,js,jsx}
|
||||
globs: *.tsx,*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
|
@ -1,10 +1,10 @@
|
||||
---
|
||||
description: Best practices for form handling with React Hook Form
|
||||
globs: **/*.{ts,tsx}
|
||||
globs: *.tsx,*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
- Use the `useForm` hook for efficient form state management.
|
||||
- Implement validation using Zod with `@hookform/resolvers` for type-safe form validation.
|
||||
- Utilize the `Controller` component for integrating with custom inputs.
|
||||
- Leverage the `useFormContext` hook for sharing form state across components.
|
||||
- Leverage the `useFormContext` hook for sharing form state across components.
|
||||
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Best practices for integrating Stripe payments
|
||||
globs: **/*.{ts,tsx}
|
||||
globs: *.tsx,*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Best practices for styling with Tailwind CSS
|
||||
globs: **/*.{ts,tsx,css}
|
||||
globs: *.tsx,*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
description: TypeScript coding standards and type safety guidelines
|
||||
globs: **/*.{ts,tsx}
|
||||
globs: *.tsx,*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
@ -11,4 +11,4 @@ alwaysApply: false
|
||||
- Use strict null checks to prevent null and undefined errors
|
||||
- Implement proper type inference using generics for reusable components.
|
||||
- Utilize type guards and assertions for runtime type checking.
|
||||
- Use `pnpm` as default package manager if run Command in Terminal.
|
||||
- Use `pnpm` as default package manager if run Command in Terminal.
|
||||
|
54
.cursor/rules/ui-components.mdc
Normal file
54
.cursor/rules/ui-components.mdc
Normal file
@ -0,0 +1,54 @@
|
||||
---
|
||||
description:
|
||||
globs: **/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
# UI and Components Guide
|
||||
|
||||
## Component Structure
|
||||
- Components in `src/components/`
|
||||
- Follow atomic design principles
|
||||
- Use Radix UI primitives
|
||||
- Implement proper accessibility
|
||||
- Use Tailwind CSS for styling
|
||||
- Follow consistent naming
|
||||
- Keep components focused
|
||||
- Implement proper error states
|
||||
- Handle loading states
|
||||
- Use proper TypeScript types
|
||||
|
||||
## UI Libraries
|
||||
- Radix UI for primitives
|
||||
- Tailwind CSS for styling
|
||||
- Framer Motion for animations
|
||||
- React Hook Form for forms
|
||||
- Zod for validation
|
||||
- Lucide React for icons
|
||||
- Tabler Icons for additional icons
|
||||
- Sonner for toasts
|
||||
- Vaul for drawers
|
||||
- Embla Carousel for carousels
|
||||
|
||||
## Styling Guidelines
|
||||
- Use Tailwind CSS classes
|
||||
- Follow design system tokens
|
||||
- Implement dark mode support
|
||||
- Use proper spacing scale
|
||||
- Follow color palette
|
||||
- Implement responsive design
|
||||
- Use proper typography
|
||||
- Handle hover/focus states
|
||||
- Implement proper transitions
|
||||
- Use proper z-index scale
|
||||
|
||||
## Accessibility
|
||||
- Use semantic HTML
|
||||
- Implement proper ARIA labels
|
||||
- Handle keyboard navigation
|
||||
- Support screen readers
|
||||
- Use proper color contrast
|
||||
- Implement focus management
|
||||
- Handle dynamic content
|
||||
- Support reduced motion
|
||||
- Test with assistive tools
|
||||
- Follow WCAG guidelines
|
@ -1,6 +1,7 @@
|
||||
---
|
||||
description: Best practices for schema validation with Zod
|
||||
globs: **/*.{ts,tsx}
|
||||
globs: *.tsx,*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
- Define clear and reusable schemas for data validation
|
||||
|
@ -1,10 +1,10 @@
|
||||
---
|
||||
description: Best practices for state management with Zustand
|
||||
globs: **/*.{ts,tsx}
|
||||
globs: *.tsx,*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
- Use the `create` function to define your store for simplicity and performance.
|
||||
- Implement middleware like `persist` for persisting state across sessions.
|
||||
- Utilize the `useStore` hook for accessing store state in components.
|
||||
- Leverage the `immer` middleware for easier state updates with mutable syntax.
|
||||
- Leverage the `immer` middleware for easier state updates with mutable syntax.
|
||||
|
@ -244,6 +244,10 @@ will replace the `/docs` page with your `page.tsx`.
|
||||
|
||||
</Accordions>
|
||||
|
||||
## Video Tutorials
|
||||
|
||||
<YoutubeVideo url="https://www.youtube.com/embed/BPnK-YbISHQ?si=TH_tI3e4MCgMHzGr" />
|
||||
|
||||
## Learn More
|
||||
|
||||
New to here? Don't worry, we are welcome for your questions.
|
||||
|
@ -244,6 +244,10 @@ export const source = loader({
|
||||
|
||||
</Accordions>
|
||||
|
||||
## 视频教程
|
||||
|
||||
<YoutubeVideo url="https://www.youtube.com/embed/BPnK-YbISHQ?si=TH_tI3e4MCgMHzGr" />
|
||||
|
||||
## 了解更多
|
||||
|
||||
刚来这里?别担心,我们欢迎您的问题。
|
||||
刚来这里?别担心,我们欢迎您的问题。
|
@ -337,6 +337,8 @@ Images are automatically optimized for `next/image`.
|
||||

|
||||
```
|
||||
|
||||

|
||||
|
||||
## Optional
|
||||
|
||||
Some optional plugins you can enable.
|
||||
|
@ -246,7 +246,7 @@ console.log('Hello World');
|
||||
```
|
||||
````
|
||||
|
||||
### 高亮行
|
||||
### 高亮行
|
||||
|
||||
````md
|
||||
```tsx
|
||||
@ -335,6 +335,8 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||

|
||||
```
|
||||
|
||||

|
||||
|
||||
## 可选功能
|
||||
|
||||
一些您可以启用的可选插件。
|
||||
|
15
env.example
15
env.example
@ -76,7 +76,15 @@ NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=""
|
||||
NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Analytics
|
||||
# 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#setup
|
||||
# -----------------------------------------------------------------------------
|
||||
# Google Analytics (https://analytics.google.com)
|
||||
@ -101,6 +109,11 @@ NEXT_PUBLIC_OPENPANEL_CLIENT_ID=""
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT="https://plausible.io/js/script.js"
|
||||
# -----------------------------------------------------------------------------
|
||||
# Ahrefs Analytics (https://ahrefs.com)
|
||||
# https://mksaas.com/docs/analytics#ahrefs
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_AHREFS_WEBSITE_ID=""
|
||||
# -----------------------------------------------------------------------------
|
||||
# Seline Analytics
|
||||
# https://mksaas.com/docs/analytics#seline
|
||||
# -----------------------------------------------------------------------------
|
||||
|
@ -440,7 +440,57 @@
|
||||
"admin": {
|
||||
"title": "Admin",
|
||||
"users": {
|
||||
"title": "Users"
|
||||
"title": "Users",
|
||||
"fakeData": "Note: Faked data for demonstration, some features are disabled",
|
||||
"error": "Failed to get users",
|
||||
"search": "Search users...",
|
||||
"columns": {
|
||||
"columns": "Columns",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"role": "Role",
|
||||
"createdAt": "Created At",
|
||||
"customerId": "Customer ID",
|
||||
"status": "Status",
|
||||
"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": {
|
||||
"verified": "Email Verified",
|
||||
"unverified": "Email Unverified"
|
||||
},
|
||||
"emailCopied": "Email copied to clipboard",
|
||||
"banned": "Banned",
|
||||
"active": "Active",
|
||||
"joined": "Joined",
|
||||
"updated": "Updated",
|
||||
"ban": {
|
||||
"reason": "Ban Reason",
|
||||
"reasonPlaceholder": "Enter the reason for banning this user",
|
||||
"defaultReason": "Spamming",
|
||||
"never": "Never",
|
||||
"expires": "Ban Expires",
|
||||
"selectDate": "Select Date",
|
||||
"button": "Ban User",
|
||||
"success": "User has been banned",
|
||||
"error": "Failed to ban user"
|
||||
},
|
||||
"unban": {
|
||||
"button": "Unban User",
|
||||
"success": "User has been unbanned",
|
||||
"error": "Failed to unban user"
|
||||
},
|
||||
"close": "Close"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
@ -441,7 +441,57 @@
|
||||
"admin": {
|
||||
"title": "系统管理",
|
||||
"users": {
|
||||
"title": "用户管理"
|
||||
"title": "用户管理",
|
||||
"fakeData": "注:只为演示功能,数据为假数据,封禁功能不可用",
|
||||
"error": "获取用户失败",
|
||||
"search": "搜索用户...",
|
||||
"columns": {
|
||||
"columns": "显示列",
|
||||
"name": "姓名",
|
||||
"email": "邮箱",
|
||||
"role": "角色",
|
||||
"createdAt": "创建时间",
|
||||
"customerId": "客户ID",
|
||||
"status": "状态",
|
||||
"banReason": "封禁原因",
|
||||
"banExpires": "封禁到期时间"
|
||||
},
|
||||
"noResults": "没有结果",
|
||||
"firstPage": "第一页",
|
||||
"lastPage": "最后一页",
|
||||
"nextPage": "下一页",
|
||||
"previousPage": "上一页",
|
||||
"rowsPerPage": "每页行数",
|
||||
"page": "页",
|
||||
"loading": "加载中...",
|
||||
"admin": "管理员",
|
||||
"user": "用户",
|
||||
"email": {
|
||||
"verified": "邮箱已验证",
|
||||
"unverified": "邮箱未验证"
|
||||
},
|
||||
"emailCopied": "邮箱已复制到剪贴板",
|
||||
"banned": "账号被封禁",
|
||||
"active": "账号正常",
|
||||
"joined": "加入时间",
|
||||
"updated": "更新时间",
|
||||
"ban": {
|
||||
"reason": "封禁原因",
|
||||
"reasonPlaceholder": "请输入封禁该用户的原因",
|
||||
"defaultReason": "垃圾信息",
|
||||
"never": "永不解禁",
|
||||
"expires": "封禁到期时间",
|
||||
"selectDate": "选择日期",
|
||||
"button": "封禁用户",
|
||||
"success": "用户已被封禁",
|
||||
"error": "封禁用户失败"
|
||||
},
|
||||
"unban": {
|
||||
"button": "解除封禁",
|
||||
"success": "用户已被解除封禁",
|
||||
"error": "解除封禁失败"
|
||||
},
|
||||
"close": "关闭"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
@ -16,6 +16,10 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
|
||||
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
|
||||
// vercel has limits on image optimization, 1000 images per month
|
||||
unoptimized: process.env.DISABLE_IMAGE_OPTIMIZATION === 'true',
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
|
@ -102,7 +102,7 @@
|
||||
"next-themes": "^0.4.4",
|
||||
"postgres": "^3.4.5",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "9.6.3",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-remove-scroll": "^2.6.3",
|
||||
|
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@ -252,8 +252,8 @@ importers:
|
||||
specifier: ^19.0.0
|
||||
version: 19.0.0
|
||||
react-day-picker:
|
||||
specifier: 9.6.3
|
||||
version: 9.6.3(react@19.0.0)
|
||||
specifier: 8.10.1
|
||||
version: 8.10.1(date-fns@4.1.0)(react@19.0.0)
|
||||
react-dom:
|
||||
specifier: ^19.0.0
|
||||
version: 19.0.0(react@19.0.0)
|
||||
@ -1114,9 +1114,6 @@ packages:
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@date-fns/tz@1.2.0':
|
||||
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
@ -4578,9 +4575,6 @@ packages:
|
||||
data-uri-to-buffer@2.0.2:
|
||||
resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==}
|
||||
|
||||
date-fns-jalali@4.1.0-0:
|
||||
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
|
||||
|
||||
date-fns@4.1.0:
|
||||
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||
|
||||
@ -6194,11 +6188,11 @@ packages:
|
||||
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
react-day-picker@9.6.3:
|
||||
resolution: {integrity: sha512-rDqCSKAl5MLX0z1fLkYcBenQK4ANlYaAhUR0ruVSVAhAa7/ZmKQqgDpXPoS7bYEkgBRH06LO1qNFP1Ki8uiZpw==}
|
||||
engines: {node: '>=18'}
|
||||
react-day-picker@8.10.1:
|
||||
resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
date-fns: ^2.28.0 || ^3.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
|
||||
react-dom@19.0.0:
|
||||
resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==}
|
||||
@ -8616,8 +8610,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
'@date-fns/tz@1.2.0': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.0.0)':
|
||||
dependencies:
|
||||
react: 19.0.0
|
||||
@ -11888,8 +11880,6 @@ snapshots:
|
||||
|
||||
data-uri-to-buffer@2.0.2: {}
|
||||
|
||||
date-fns-jalali@4.1.0-0: {}
|
||||
|
||||
date-fns@4.1.0: {}
|
||||
|
||||
debounce@2.0.0: {}
|
||||
@ -13930,11 +13920,9 @@ snapshots:
|
||||
iconv-lite: 0.6.3
|
||||
unpipe: 1.0.0
|
||||
|
||||
react-day-picker@9.6.3(react@19.0.0):
|
||||
react-day-picker@8.10.1(date-fns@4.1.0)(react@19.0.0):
|
||||
dependencies:
|
||||
'@date-fns/tz': 1.2.0
|
||||
date-fns: 4.1.0
|
||||
date-fns-jalali: 4.1.0-0
|
||||
react: 19.0.0
|
||||
|
||||
react-dom@19.0.0(react@19.0.0):
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 166 KiB |
85
src/actions/get-users.ts
Normal file
85
src/actions/get-users.ts
Normal file
@ -0,0 +1,85 @@
|
||||
'use server';
|
||||
|
||||
import db from '@/db';
|
||||
import { user } from '@/db/schema';
|
||||
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),
|
||||
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 = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
createdAt: user.createdAt,
|
||||
role: user.role,
|
||||
banned: user.banned,
|
||||
customerId: user.customerId,
|
||||
banReason: user.banReason,
|
||||
banExpires: user.banExpires,
|
||||
} as const;
|
||||
|
||||
// Create a safe action for getting users
|
||||
export const getUsersAction = actionClient
|
||||
.schema(getUsersSchema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
try {
|
||||
const { pageIndex, pageSize, search, sorting } = parsedInput;
|
||||
|
||||
const where = search
|
||||
? or(ilike(user.name, `%${search}%`), ilike(user.email, `%${search}%`))
|
||||
: undefined;
|
||||
|
||||
const offset = pageIndex * pageSize;
|
||||
|
||||
// Get the sort configuration
|
||||
const sortConfig = sorting[0];
|
||||
const sortField = sortConfig?.id
|
||||
? sortFieldMap[sortConfig.id as keyof typeof sortFieldMap]
|
||||
: user.createdAt;
|
||||
const sortDirection = sortConfig?.desc ? desc : asc;
|
||||
|
||||
const [items, [{ count }]] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(where)
|
||||
.orderBy(sortDirection(sortField))
|
||||
.limit(pageSize)
|
||||
.offset(offset),
|
||||
db.select({ count: sql`count(*)` }).from(user).where(where),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total: Number(count),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('get users error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch users',
|
||||
};
|
||||
}
|
||||
});
|
29
src/analytics/ahrefs-analytics.tsx
Normal file
29
src/analytics/ahrefs-analytics.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import Script from 'next/script';
|
||||
|
||||
/**
|
||||
* Ahrefs Analytics
|
||||
*
|
||||
* https://ahrefs.com/
|
||||
* https://mksaas.com/docs/analytics#ahrefs
|
||||
*/
|
||||
export function AhrefsAnalytics() {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const websiteId = process.env.NEXT_PUBLIC_AHREFS_WEBSITE_ID as string;
|
||||
if (!websiteId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Script
|
||||
async
|
||||
type="text/javascript"
|
||||
data-key={websiteId}
|
||||
src="https://analytics.ahrefs.com/analytics.js"
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,12 +1,13 @@
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { Analytics as VercelAnalytics } from '@vercel/analytics/react';
|
||||
import { SpeedInsights } from '@vercel/speed-insights/next';
|
||||
import { AhrefsAnalytics } from './ahrefs-analytics';
|
||||
import DataFastAnalytics from './data-fast-analytics';
|
||||
import GoogleAnalytics from './google-analytics';
|
||||
import OpenPanelAnalytics from './open-panel-analytics';
|
||||
import { PlausibleAnalytics } from './plausible-analytics';
|
||||
import { SelineAnalytics } from './seline-analytics';
|
||||
import { UmamiAnalytics } from './umami-analytics';
|
||||
import { Analytics as VercelAnalytics } from '@vercel/analytics/react';
|
||||
import { SpeedInsights } from '@vercel/speed-insights/next';
|
||||
|
||||
/**
|
||||
* Analytics Components all in one
|
||||
@ -33,6 +34,9 @@ export function Analytics() {
|
||||
{/* plausible analytics */}
|
||||
<PlausibleAnalytics />
|
||||
|
||||
{/* ahrefs analytics */}
|
||||
<AhrefsAnalytics />
|
||||
|
||||
{/* datafast analytics */}
|
||||
<DataFastAnalytics />
|
||||
|
||||
|
@ -6,6 +6,7 @@ import Script from 'next/script';
|
||||
* DataFast Analytics
|
||||
*
|
||||
* https://datafa.st
|
||||
* https://mksaas.com/docs/analytics#datafast
|
||||
*/
|
||||
export default function DataFastAnalytics() {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
|
@ -6,6 +6,7 @@ import { GoogleAnalytics as NextGoogleAnalytics } from '@next/third-parties/goog
|
||||
* Google Analytics
|
||||
*
|
||||
* https://analytics.google.com
|
||||
* https://mksaas.com/docs/analytics#google
|
||||
* https://nextjs.org/docs/app/building-your-application/optimizing/third-party-libraries#google-analytics
|
||||
*/
|
||||
export default function GoogleAnalytics() {
|
||||
|
@ -3,6 +3,8 @@ import { OpenPanelComponent } from '@openpanel/nextjs';
|
||||
/**
|
||||
* OpenPanel Analytics (https://openpanel.dev)
|
||||
*
|
||||
* https://openpanel.dev
|
||||
* https://mksaas.com/docs/analytics#openpanel
|
||||
* https://docs.openpanel.dev/docs/sdks/nextjs#options
|
||||
*/
|
||||
export default function OpenPanelAnalytics() {
|
||||
|
@ -10,6 +10,7 @@ import Script from 'next/script';
|
||||
* you do not need to add new script to this component.
|
||||
*
|
||||
* https://plausible.io
|
||||
* https://mksaas.com/docs/analytics#plausible
|
||||
*/
|
||||
export function PlausibleAnalytics() {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
|
@ -6,6 +6,7 @@ import Script from 'next/script';
|
||||
* Seline Analytics
|
||||
*
|
||||
* https://seline.com
|
||||
* https://mksaas.com/docs/analytics#seline
|
||||
* https://seline.com/docs/install-seline
|
||||
* https://seline.com/docs/stripe
|
||||
*/
|
||||
|
@ -6,6 +6,7 @@ import Script from 'next/script';
|
||||
* Umami Analytics
|
||||
*
|
||||
* https://umami.is
|
||||
* https://mksaas.com/docs/analytics#umami
|
||||
*/
|
||||
export function UmamiAnalytics() {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
|
@ -166,10 +166,7 @@ export default async function BlogPostPage(props: NextPageProps) {
|
||||
{/* 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 max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg">
|
||||
<CustomMDXContent
|
||||
code={post.body}
|
||||
includeFumadocsComponents={true}
|
||||
/>
|
||||
<CustomMDXContent code={post.body} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start my-16">
|
||||
|
@ -1,614 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Cover page",
|
||||
"type": "Cover page",
|
||||
"status": "In Process",
|
||||
"target": "18",
|
||||
"limit": "5",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Table of contents",
|
||||
"type": "Table of contents",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "24",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Executive summary",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "10",
|
||||
"limit": "13",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Technical approach",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "27",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Design",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "2",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"header": "Capabilities",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "20",
|
||||
"limit": "8",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"header": "Integration with existing systems",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "21",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"header": "Innovation and Advantages",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "25",
|
||||
"limit": "26",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"header": "Overview of EMR's Innovative Solutions",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "7",
|
||||
"limit": "23",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"header": "Advanced Algorithms and Machine Learning",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "28",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"header": "Adaptive Communication Protocols",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "9",
|
||||
"limit": "31",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"header": "Advantages Over Current Technologies",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "0",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"header": "Past Performance",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "33",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"header": "Customer Feedback and Satisfaction Levels",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "34",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"header": "Implementation Challenges and Solutions",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "3",
|
||||
"limit": "35",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"header": "Security Measures and Data Protection Policies",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "6",
|
||||
"limit": "36",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"header": "Scalability and Future Proofing",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "4",
|
||||
"limit": "37",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"header": "Cost-Benefit Analysis",
|
||||
"type": "Plain language",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "38",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"header": "User Training and Onboarding Experience",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "17",
|
||||
"limit": "39",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"header": "Future Development Roadmap",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "11",
|
||||
"limit": "40",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"header": "System Architecture Overview",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "24",
|
||||
"limit": "18",
|
||||
"reviewer": "Maya Johnson"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"header": "Risk Management Plan",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "22",
|
||||
"reviewer": "Carlos Rodriguez"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"header": "Compliance Documentation",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "31",
|
||||
"limit": "27",
|
||||
"reviewer": "Sarah Chen"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"header": "API Documentation",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "8",
|
||||
"limit": "12",
|
||||
"reviewer": "Raj Patel"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"header": "User Interface Mockups",
|
||||
"type": "Visual",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "25",
|
||||
"reviewer": "Leila Ahmadi"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"header": "Database Schema",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "20",
|
||||
"reviewer": "Thomas Wilson"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"header": "Testing Methodology",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "14",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"header": "Deployment Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "30",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"header": "Budget Breakdown",
|
||||
"type": "Financial",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"header": "Market Analysis",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Sophia Martinez"
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"header": "Competitor Comparison",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"header": "Maintenance Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "23",
|
||||
"reviewer": "Alex Thompson"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"header": "User Personas",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "24",
|
||||
"reviewer": "Nina Patel"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"header": "Accessibility Compliance",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"header": "Performance Metrics",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "David Kim"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"header": "Disaster Recovery Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"header": "Third-party Integrations",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"header": "User Feedback Summary",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "15",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"header": "Localization Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "12",
|
||||
"limit": "19",
|
||||
"reviewer": "Maria Garcia"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"header": "Mobile Compatibility",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "James Wilson"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"header": "Data Migration Plan",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"header": "Quality Assurance Protocols",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Priya Singh"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"header": "Stakeholder Analysis",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "11",
|
||||
"limit": "14",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"header": "Environmental Impact Assessment",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"header": "Intellectual Property Rights",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "20",
|
||||
"reviewer": "Sarah Johnson"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"header": "Customer Support Framework",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"header": "Version Control Strategy",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"header": "Continuous Integration Pipeline",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Michael Chen"
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"header": "Regulatory Compliance",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"header": "User Authentication System",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"header": "Data Analytics Framework",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"header": "Cloud Infrastructure",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"header": "Network Security Measures",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Lisa Wong"
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"header": "Project Timeline",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"header": "Resource Allocation",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 56,
|
||||
"header": "Team Structure and Roles",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"header": "Communication Protocols",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"header": "Success Metrics",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 59,
|
||||
"header": "Internationalization Support",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 60,
|
||||
"header": "Backup and Recovery Procedures",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"header": "Monitoring and Alerting System",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Daniel Park"
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"header": "Code Review Guidelines",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "15",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"header": "Documentation Standards",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"header": "Release Management Process",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"header": "Feature Prioritization Matrix",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Emma Davis"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"header": "Technical Debt Assessment",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"header": "Capacity Planning",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"header": "Service Level Agreements",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Assign reviewer"
|
||||
}
|
||||
]
|
35
src/app/[locale]/(protected)/admin/users/layout.tsx
Normal file
35
src/app/[locale]/(protected)/admin/users/layout.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
interface UsersLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function UsersLayout({ children }: UsersLayoutProps) {
|
||||
const t = await getTranslations('Dashboard.admin');
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
label: t('title'),
|
||||
isCurrentPage: false,
|
||||
},
|
||||
{
|
||||
label: t('users.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">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
5
src/app/[locale]/(protected)/admin/users/loading.tsx
Normal file
5
src/app/[locale]/(protected)/admin/users/loading.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
export default function Loading() {
|
||||
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||
}
|
@ -1,46 +1,11 @@
|
||||
import { ChartAreaInteractive } from '@/components/dashboard/chart-area-interactive';
|
||||
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
|
||||
import { DataTable } from '@/components/dashboard/data-table';
|
||||
import { SectionCards } from '@/components/dashboard/section-cards';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import data from './data.json';
|
||||
import { UsersPageClient } from '@/components/admin/users-page';
|
||||
|
||||
/**
|
||||
* Admin users page
|
||||
* Users page
|
||||
*
|
||||
* NOTICE: This is a demo page for the admin, no real data is used,
|
||||
* we will show real data in the future
|
||||
* This page is used to manage users for the admin,
|
||||
* it is protected and only accessible to the admin role
|
||||
*/
|
||||
export default function AdminUsersPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
label: t('Dashboard.admin.title'),
|
||||
isCurrentPage: false,
|
||||
},
|
||||
{
|
||||
label: t('Dashboard.admin.users.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">
|
||||
<SectionCards />
|
||||
<div className="px-4 lg:px-6">
|
||||
<ChartAreaInteractive />
|
||||
</div>
|
||||
<DataTable data={data} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
export default function UsersPage() {
|
||||
return <UsersPageClient />;
|
||||
}
|
||||
|
@ -136,7 +136,6 @@ export default async function DocPage({ params }: DocPageProps) {
|
||||
);
|
||||
},
|
||||
}}
|
||||
includeFumadocsComponents={true}
|
||||
/>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
|
298
src/components/admin/user-detail-viewer.tsx
Normal file
298
src/components/admin/user-detail-viewer.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
import { UserAvatar } from '@/components/layout/user-avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '@/components/ui/drawer';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { formatDate } from '@/lib/formatter';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUsersStore } from '@/stores/users-store';
|
||||
import {
|
||||
CalendarIcon,
|
||||
Loader2Icon,
|
||||
MailCheckIcon,
|
||||
MailQuestionIcon,
|
||||
UserRoundCheckIcon,
|
||||
UserRoundXIcon,
|
||||
} from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface UserDetailViewerProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
const t = useTranslations('Dashboard.admin.users');
|
||||
const isMobile = useIsMobile();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [banReason, setBanReason] = useState(t('ban.defaultReason'));
|
||||
const [banExpiresAt, setBanExpiresAt] = useState<Date | undefined>();
|
||||
const triggerRefresh = useUsersStore((state) => state.triggerRefresh);
|
||||
|
||||
// show fake data in demo website
|
||||
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
|
||||
|
||||
const handleBan = async () => {
|
||||
if (!banReason) {
|
||||
setError(t('ban.error'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.id) {
|
||||
setError('User ID is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await authClient.admin.banUser({
|
||||
userId: user.id,
|
||||
banReason,
|
||||
banExpiresIn: banExpiresAt
|
||||
? Math.floor((banExpiresAt.getTime() - Date.now()) / 1000)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
toast.success(t('ban.success'));
|
||||
// Reset form
|
||||
setBanReason('');
|
||||
setBanExpiresAt(undefined);
|
||||
// Trigger refresh
|
||||
triggerRefresh();
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.error('Failed to ban user:', error);
|
||||
setError(error.message || t('ban.error'));
|
||||
toast.error(error.message || t('ban.error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnban = async () => {
|
||||
if (!user.id) {
|
||||
setError('User ID is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await authClient.admin.unbanUser({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
toast.success(t('unban.success'));
|
||||
// Trigger refresh
|
||||
triggerRefresh();
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.error('Failed to unban user:', error);
|
||||
setError(error.message || t('unban.error'));
|
||||
toast.error(error.message || t('unban.error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer direction={isMobile ? 'bottom' : 'right'}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant="link"
|
||||
className="cursor-pointer text-foreground w-fit px-0 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
<UserAvatar
|
||||
name={user.name}
|
||||
image={user.image}
|
||||
className="size-8 border"
|
||||
/>
|
||||
<span className="hover:underline hover:underline-offset-4">
|
||||
{isDemo ? 'MkSaaS User' : user.name}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="gap-1">
|
||||
<div className="flex items-center gap-4">
|
||||
<UserAvatar
|
||||
name={user.name}
|
||||
image={user.image}
|
||||
className="size-12 border"
|
||||
/>
|
||||
<div>
|
||||
<DrawerTitle>{isDemo ? 'MkSaaS User' : user.name}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
{isDemo ? 'example@mksaas.com' : user.email}
|
||||
</DrawerDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* role */}
|
||||
<Badge
|
||||
variant={user.role === 'admin' ? 'default' : 'outline'}
|
||||
className="px-1.5"
|
||||
>
|
||||
{user.role === 'admin' ? t('admin') : t('user')}
|
||||
</Badge>
|
||||
{/* email verified */}
|
||||
<Badge variant="outline" className="px-1.5 hover:bg-accent">
|
||||
{user.emailVerified ? (
|
||||
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
|
||||
) : (
|
||||
<MailQuestionIcon className="stroke-red-500 dark:stroke-red-400" />
|
||||
)}
|
||||
{user.emailVerified
|
||||
? t('email.verified')
|
||||
: t('email.unverified')}
|
||||
</Badge>
|
||||
|
||||
{/* user banned */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="px-1.5 hover:bg-accent">
|
||||
{user.banned ? (
|
||||
<UserRoundXIcon className="stroke-red-500 dark:stroke-red-400" />
|
||||
) : (
|
||||
<UserRoundCheckIcon className="stroke-green-500 dark:stroke-green-400" />
|
||||
)}
|
||||
{user.banned ? t('banned') : t('active')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* information */}
|
||||
<div className="text-muted-foreground">
|
||||
{t('joined')}: {formatDate(user.createdAt)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t('updated')}: {formatDate(user.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* error */}
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
|
||||
{/* ban or unban user */}
|
||||
{user.banned ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="">
|
||||
{t('ban.reason')}: {user.banReason}
|
||||
</div>
|
||||
<div className="">
|
||||
{t('ban.expires')}:{' '}
|
||||
{(user.banExpires && formatDate(user.banExpires)) ||
|
||||
t('ban.never')}
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleUnban}
|
||||
disabled={isLoading || isDemo}
|
||||
className="mt-4 cursor-pointer"
|
||||
>
|
||||
{isLoading && (
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{t('unban.button')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleBan();
|
||||
}}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ban-reason">{t('ban.reason')}</Label>
|
||||
<Textarea
|
||||
id="ban-reason"
|
||||
value={banReason}
|
||||
onChange={(e) => setBanReason(e.target.value)}
|
||||
placeholder={t('ban.reasonPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t('ban.expires')}</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start text-left font-normal cursor-pointer',
|
||||
!banExpiresAt && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon />
|
||||
{banExpiresAt ? (
|
||||
formatDate(banExpiresAt)
|
||||
) : (
|
||||
<span>{t('ban.selectDate')}</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={banExpiresAt}
|
||||
onSelect={setBanExpiresAt}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={isLoading || !banReason || isDemo}
|
||||
className="mt-4 cursor-pointer"
|
||||
>
|
||||
{isLoading && (
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{t('ban.button')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
72
src/components/admin/users-page.tsx
Normal file
72
src/components/admin/users-page.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { getUsersAction } from '@/actions/get-users';
|
||||
import { UsersTable } from '@/components/admin/users-table';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { useUsersStore } from '@/stores/users-store';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function UsersPageClient() {
|
||||
const t = useTranslations('Dashboard.admin.users');
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [search, setSearch] = useState('');
|
||||
const [data, setData] = useState<User[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const refreshTrigger = useUsersStore((state) => state.refreshTrigger);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await getUsersAction({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
sorting,
|
||||
});
|
||||
|
||||
if (result?.data?.success) {
|
||||
setData(result.data.data?.items || []);
|
||||
setTotal(result.data.data?.total || 0);
|
||||
} else {
|
||||
const errorMessage = result?.data?.error || t('error');
|
||||
toast.error(errorMessage);
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
toast.error(t('error'));
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUsers();
|
||||
}, [pageIndex, pageSize, search, sorting, refreshTrigger]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UsersTable
|
||||
data={data}
|
||||
total={total}
|
||||
pageIndex={pageIndex}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
loading={loading}
|
||||
onSearch={setSearch}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSortingChange={setSorting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
520
src/components/admin/users-table.tsx
Normal file
520
src/components/admin/users-table.tsx
Normal file
@ -0,0 +1,520 @@
|
||||
'use client';
|
||||
|
||||
import { UserDetailViewer } from '@/components/admin/user-detail-viewer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { formatDate } from '@/lib/formatter';
|
||||
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
ArrowUpDownIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
MailCheckIcon,
|
||||
MailQuestionIcon,
|
||||
UserRoundCheckIcon,
|
||||
UserRoundXIcon,
|
||||
} from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Label } from '../ui/label';
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: any;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={className}>{title}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="cursor-pointer flex items-center gap-2"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
>
|
||||
{title}
|
||||
<ArrowUpDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UsersTableProps {
|
||||
data: User[];
|
||||
total: number;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
search: string;
|
||||
loading?: boolean;
|
||||
onSearch: (search: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
onSortingChange?: (sorting: SortingState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* https://ui.shadcn.com/docs/components/data-table
|
||||
*/
|
||||
export function UsersTable({
|
||||
data,
|
||||
total,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
loading,
|
||||
onSearch,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onSortingChange,
|
||||
}: UsersTableProps) {
|
||||
const t = useTranslations('Dashboard.admin.users');
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
// show fake data in demo website
|
||||
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
|
||||
|
||||
// Map column IDs to translation keys
|
||||
const columnIdToTranslationKey = {
|
||||
name: 'columns.name' as const,
|
||||
email: 'columns.email' as const,
|
||||
role: 'columns.role' as const,
|
||||
createdAt: 'columns.createdAt' as const,
|
||||
customerId: 'columns.customerId' as const,
|
||||
banned: 'columns.status' as const,
|
||||
banReason: 'columns.banReason' as const,
|
||||
banExpires: 'columns.banExpires' as const,
|
||||
} as const;
|
||||
|
||||
// Table columns definition
|
||||
const columns: ColumnDef<User>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('columns.name')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
return <UserDetailViewer user={user} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('columns.email')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-sm px-1.5 cursor-pointer hover:bg-accent"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(user.email);
|
||||
toast.success(t('emailCopied'));
|
||||
}}
|
||||
>
|
||||
{user.emailVerified ? (
|
||||
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
|
||||
) : (
|
||||
<MailQuestionIcon className="stroke-red-500 dark:stroke-red-400" />
|
||||
)}
|
||||
{isDemo ? 'example@mksaas.com' : user.email}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('columns.role')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
const role = user.role || 'user';
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
<Badge
|
||||
variant={role === 'admin' ? 'default' : 'outline'}
|
||||
className="px-1.5"
|
||||
>
|
||||
{role === 'admin' ? t('admin') : t('user')}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('columns.createdAt')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
{formatDate(user.createdAt)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'customerId',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={t('columns.customerId')}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
{user.customerId ? (
|
||||
<a
|
||||
href={getStripeDashboardCustomerUrl(user.customerId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline hover:underline-offset-4"
|
||||
>
|
||||
{!isDemo ? user.customerId : 'cus_abcdef123456'}
|
||||
</a>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'banned',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('columns.status')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
<Badge variant="outline" className="px-1.5 hover:bg-accent">
|
||||
{user.banned ? (
|
||||
<UserRoundXIcon className="stroke-red-500 dark:stroke-red-400" />
|
||||
) : (
|
||||
<UserRoundCheckIcon className="stroke-green-500 dark:stroke-green-400" />
|
||||
)}
|
||||
{user.banned ? t('banned') : t('active')}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'banReason',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('columns.banReason')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
{user.banReason || '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'banExpires',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={t('columns.banExpires')}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
{user.banExpires ? formatDate(user.banExpires) : '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
pageCount: Math.ceil(total / pageSize),
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
pagination: { pageIndex, pageSize },
|
||||
},
|
||||
onSortingChange: (updater) => {
|
||||
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
||||
setSorting(next);
|
||||
onSortingChange?.(next);
|
||||
},
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: (updater) => {
|
||||
const next =
|
||||
typeof updater === 'function'
|
||||
? updater({ pageIndex, pageSize })
|
||||
: updater;
|
||||
if (next.pageIndex !== pageIndex) onPageChange(next.pageIndex);
|
||||
if (next.pageSize !== pageSize) onPageSizeChange(next.pageSize);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full flex-col justify-start gap-6 space-y-4">
|
||||
<div className="flex items-center justify-between px-4 lg:px-6 gap-4">
|
||||
<div className="flex flex-1 items-center gap-4">
|
||||
<Input
|
||||
placeholder={t('search')}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
onSearch(event.target.value);
|
||||
onPageChange(0);
|
||||
}}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
{isDemo && (
|
||||
<span className="text-sm text-primary">{t('fakeData')}</span>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||
{/* <IconLayoutColumns /> */}
|
||||
<span className="inline">{t('columns.columns')}</span>
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize cursor-pointer"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{t(
|
||||
columnIdToTranslationKey[
|
||||
column.id as keyof typeof columnIdToTranslationKey
|
||||
] || 'columns.columns'
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted sticky top-0 z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t('loading')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-4">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t('noResults')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{/* empty here for now */}
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
{t('rowsPerPage')}
|
||||
</Label>
|
||||
<Select
|
||||
value={`${pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
onPageSizeChange(Number(value));
|
||||
onPageChange(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-20 cursor-pointer"
|
||||
id="rows-per-page"
|
||||
>
|
||||
<SelectValue placeholder={pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
{t('page')} {pageIndex + 1} {' / '}
|
||||
{Math.max(1, Math.ceil(total / pageSize))}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => onPageChange(0)}
|
||||
disabled={pageIndex === 0}
|
||||
>
|
||||
<span className="sr-only">{t('firstPage')}</span>
|
||||
<ChevronsLeftIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer size-8"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(pageIndex - 1)}
|
||||
disabled={pageIndex === 0}
|
||||
>
|
||||
<span className="sr-only">{t('previousPage')}</span>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer size-8"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(pageIndex + 1)}
|
||||
disabled={pageIndex + 1 >= Math.ceil(total / pageSize)}
|
||||
>
|
||||
<span className="sr-only">{t('nextPage')}</span>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
onPageChange(Math.max(0, Math.ceil(total / pageSize) - 1))
|
||||
}
|
||||
disabled={pageIndex + 1 >= Math.ceil(total / pageSize)}
|
||||
>
|
||||
<span className="sr-only">{t('lastPage')}</span>
|
||||
<ChevronsRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -10,72 +10,79 @@ export default function LogoCloudSection() {
|
||||
|
||||
<div className="mx-auto mt-20 flex max-w-4xl flex-wrap items-center justify-center gap-x-12 gap-y-8 sm:gap-x-16 sm:gap-y-12">
|
||||
<img
|
||||
className="h-4 w-fit dark:invert"
|
||||
src="/svg/nextjs_logo_light.svg"
|
||||
alt="Nextjs Logo"
|
||||
className="h-5 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/nvidia.svg"
|
||||
alt="Nvidia Logo"
|
||||
height="20"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-4 w-fit dark:invert"
|
||||
src="/svg/tailwindcss.svg"
|
||||
alt="Tailwind CSS Logo"
|
||||
src="https://html.tailus.io/blocks/customers/column.svg"
|
||||
alt="Column Logo"
|
||||
height="16"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-6 w-fit dark:invert"
|
||||
src="/svg/resend-wordmark-black.svg"
|
||||
alt="Resend Logo"
|
||||
height="28"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-5 w-fit dark:invert"
|
||||
src="/svg/vercel.svg"
|
||||
alt="Vercel Logo"
|
||||
height="20"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-4 w-fit dark:invert"
|
||||
src="/svg/github.svg"
|
||||
src="https://html.tailus.io/blocks/customers/github.svg"
|
||||
alt="GitHub Logo"
|
||||
height="16"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-5 w-fit dark:invert"
|
||||
src="/svg/cursor_wordmark_light.svg"
|
||||
alt="Cursor Logo"
|
||||
src="https://html.tailus.io/blocks/customers/nike.svg"
|
||||
alt="Nike Logo"
|
||||
height="20"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-5 w-fit dark:invert"
|
||||
src="/svg/lemonsqueezy.svg"
|
||||
alt="Lemon Squeezy Logo"
|
||||
className="h-4 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/laravel.svg"
|
||||
alt="Laravel Logo"
|
||||
height="16"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-7 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/lilly.svg"
|
||||
alt="Lilly Logo"
|
||||
height="28"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-5 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/lemonsqueezy.svg"
|
||||
alt="Lemon Squeezy Logo"
|
||||
height="20"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-6 w-fit dark:invert"
|
||||
src="/svg/openai.svg"
|
||||
src="https://html.tailus.io/blocks/customers/openai.svg"
|
||||
alt="OpenAI Logo"
|
||||
height="24"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-4 w-fit dark:invert"
|
||||
src="/svg/zapier.svg"
|
||||
alt="Zapier Logo"
|
||||
src="https://html.tailus.io/blocks/customers/tailwindcss.svg"
|
||||
alt="Tailwind CSS Logo"
|
||||
height="16"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-5 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/vercel.svg"
|
||||
alt="Vercel Logo"
|
||||
height="20"
|
||||
width="auto"
|
||||
/>
|
||||
<img
|
||||
className="h-4 w-fit dark:invert"
|
||||
src="/svg/nvidia.svg"
|
||||
alt="NVIDIA Logo"
|
||||
className="h-5 w-fit dark:invert"
|
||||
src="https://html.tailus.io/blocks/customers/zapier.svg"
|
||||
alt="Zapier Logo"
|
||||
height="20"
|
||||
width="auto"
|
||||
/>
|
||||
|
28
src/components/docs/image-wrapper.tsx
Normal file
28
src/components/docs/image-wrapper.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { ImageZoom } from 'fumadocs-ui/components/image-zoom';
|
||||
import type { ComponentProps, FC } from 'react';
|
||||
|
||||
interface ImageWrapperProps extends ComponentProps<'img'> {
|
||||
src: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
export const ImageWrapper = ({ src, alt }: ImageWrapperProps) => {
|
||||
if (!src) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageZoom
|
||||
src={src}
|
||||
alt={alt || 'image'}
|
||||
width={1400}
|
||||
height={787}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
priority
|
||||
/>
|
||||
);
|
||||
};
|
39
src/components/docs/youtube-video.tsx
Normal file
39
src/components/docs/youtube-video.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
interface YoutubeVideoProps {
|
||||
url: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* YoutubeVideo component
|
||||
*
|
||||
* How to get the URL of the YouTube video?
|
||||
* 1. Go to the YouTube video you want to embed
|
||||
* 2. Click on the share button and copy the embed URL
|
||||
* 3. Paste the URL into the url prop
|
||||
*
|
||||
* @param {string} url - The URL of the YouTube video
|
||||
* @param {number} width - The width of the video
|
||||
* @param {number} height - The height of the video
|
||||
*/
|
||||
export const YoutubeVideo = ({
|
||||
url,
|
||||
width = 560,
|
||||
height = 315,
|
||||
}: YoutubeVideoProps) => {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<iframe
|
||||
width={width}
|
||||
height={height}
|
||||
src={url}
|
||||
title="YouTube video player"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
className="w-full aspect-video"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
22
src/components/icons/telegram.tsx
Normal file
22
src/components/icons/telegram.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
/**
|
||||
* https://icon-sets.iconify.design/fa6-brands/telegram/
|
||||
*/
|
||||
export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={496}
|
||||
height={512}
|
||||
viewBox="0 0 496 512"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M248 8C111.033 8 0 119.033 0 256s111.033 248 248 248s248-111.033 248-248S384.967 8 248 8m114.952 168.66c-3.732 39.215-19.881 134.378-28.1 178.3c-3.476 18.584-10.322 24.816-16.948 25.425c-14.4 1.326-25.338-9.517-39.287-18.661c-21.827-14.308-34.158-23.215-55.346-37.177c-24.485-16.135-8.612-25 5.342-39.5c3.652-3.793 67.107-61.51 68.335-66.746c.153-.655.3-3.1-1.154-4.384s-3.59-.849-5.135-.5q-3.283.746-104.608 69.142q-14.845 10.194-26.894 9.934c-8.855-.191-25.888-5.006-38.551-9.123c-15.531-5.048-27.875-7.717-26.8-16.291q.84-6.7 18.45-13.7q108.446-47.248 144.628-62.3c68.872-28.647 83.183-33.623 92.511-33.789c2.052-.034 6.639.474 9.61 2.885a10.45 10.45 0 0 1 3.53 6.716a43.8 43.8 0 0 1 .417 9.769"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
@ -27,7 +27,7 @@ export function ModeSwitcherHorizontal() {
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border bg-background p-1">
|
||||
<div className="flex items-center gap-2 rounded-full border p-1">
|
||||
<div className="size-6 px-0 rounded-full" />
|
||||
<div className="size-6 px-0 rounded-full" />
|
||||
<div className="size-6 px-0 rounded-full" />
|
||||
@ -36,7 +36,7 @@ export function ModeSwitcherHorizontal() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border bg-background p-1">
|
||||
<div className="flex items-center gap-2 rounded-full border p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
@ -38,10 +38,7 @@ export function CustomPage({
|
||||
<Card className="mb-8">
|
||||
<CardContent>
|
||||
<div className="max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg">
|
||||
<CustomMDXContent
|
||||
code={content}
|
||||
includeFumadocsComponents={false}
|
||||
/>
|
||||
<CustomMDXContent code={content} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
@ -41,7 +41,7 @@ export function ReleaseCard({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg">
|
||||
<CustomMDXContent code={content} includeFumadocsComponents={false} />
|
||||
<CustomMDXContent code={content} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { ImageWrapper } from '@/components/docs/image-wrapper';
|
||||
import { Wrapper } from '@/components/docs/wrapper';
|
||||
import { YoutubeVideo } from '@/components/docs/youtube-video';
|
||||
import { MDXContent } from '@content-collections/mdx/react';
|
||||
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { File, Files, Folder } from 'fumadocs-ui/components/files';
|
||||
import { ImageZoom } from 'fumadocs-ui/components/image-zoom';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
import { TypeTable } from 'fumadocs-ui/components/type-table';
|
||||
@ -15,7 +16,6 @@ import type { ComponentProps, FC } from 'react';
|
||||
interface CustomMDXContentProps {
|
||||
code: string;
|
||||
customComponents?: Record<string, any>;
|
||||
includeFumadocsComponents?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -25,54 +25,29 @@ interface CustomMDXContentProps {
|
||||
export async function CustomMDXContent({
|
||||
code,
|
||||
customComponents = {},
|
||||
includeFumadocsComponents = true,
|
||||
}: CustomMDXContentProps) {
|
||||
// Start with default components
|
||||
const baseComponents: Record<string, any> = {
|
||||
...defaultMdxComponents,
|
||||
...LucideIcons,
|
||||
...((await import('lucide-react')) as unknown as MDXComponents),
|
||||
YoutubeVideo,
|
||||
Tabs,
|
||||
Tab,
|
||||
TypeTable,
|
||||
Accordion,
|
||||
Accordions,
|
||||
Steps,
|
||||
Step,
|
||||
Wrapper,
|
||||
File,
|
||||
Folder,
|
||||
Files,
|
||||
blockquote: Callout as unknown as FC<ComponentProps<'blockquote'>>,
|
||||
img: ImageWrapper,
|
||||
...customComponents,
|
||||
};
|
||||
|
||||
// Add Fumadocs UI components if requested
|
||||
if (includeFumadocsComponents) {
|
||||
Object.assign(baseComponents, {
|
||||
Tabs,
|
||||
Tab,
|
||||
TypeTable,
|
||||
Accordion,
|
||||
Accordions,
|
||||
Steps,
|
||||
Step,
|
||||
Wrapper,
|
||||
File,
|
||||
Folder,
|
||||
Files,
|
||||
blockquote: Callout as unknown as FC<ComponentProps<'blockquote'>>,
|
||||
img: (props: ComponentProps<'img'>) => {
|
||||
if (!props.src) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageZoom
|
||||
src={props.src}
|
||||
alt={props.alt || 'image'}
|
||||
width={1400}
|
||||
height={787}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
priority
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<MDXContent code={code} components={baseComponents as MDXComponents} />
|
||||
);
|
||||
|
@ -60,15 +60,11 @@ function Calendar({
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
PreviousMonthButton: (props) => (
|
||||
<button {...props}>
|
||||
<ChevronLeft className={cn("size-4", props.className)} />
|
||||
</button>
|
||||
IconLeft: ({ className, ...props }) => (
|
||||
<ChevronLeft className={cn("size-4", className)} {...props} />
|
||||
),
|
||||
NextMonthButton: (props) => (
|
||||
<button {...props}>
|
||||
<ChevronRight className={cn("size-4", props.className)} />
|
||||
</button>
|
||||
IconRight: ({ className, ...props }) => (
|
||||
<ChevronRight className={cn("size-4", className)} {...props} />
|
||||
),
|
||||
}}
|
||||
{...props}
|
||||
|
@ -27,6 +27,9 @@ import { useTranslations } from 'next-intl';
|
||||
export function getSidebarLinks(): NestedMenuItem[] {
|
||||
const t = useTranslations('Dashboard');
|
||||
|
||||
// if is demo website, allow user to access admin and user pages, but data is fake
|
||||
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
|
||||
|
||||
return [
|
||||
{
|
||||
title: t('dashboard.title'),
|
||||
@ -37,7 +40,7 @@ export function getSidebarLinks(): NestedMenuItem[] {
|
||||
{
|
||||
title: t('admin.title'),
|
||||
icon: <SettingsIcon className="size-4 shrink-0" />,
|
||||
authorizeOnly: ['admin'],
|
||||
authorizeOnly: isDemo ? ['admin', 'user'] : ['admin'],
|
||||
items: [
|
||||
{
|
||||
title: t('admin.users.title'),
|
||||
|
@ -7,6 +7,7 @@ import { GitHubIcon } from '@/components/icons/github';
|
||||
import { InstagramIcon } from '@/components/icons/instagram';
|
||||
import { LinkedInIcon } from '@/components/icons/linkedin';
|
||||
import { MastodonIcon } from '@/components/icons/mastodon';
|
||||
import { TelegramIcon } from '@/components/icons/telegram';
|
||||
import { TikTokIcon } from '@/components/icons/tiktok';
|
||||
import { XTwitterIcon } from '@/components/icons/x';
|
||||
import { YouTubeIcon } from '@/components/icons/youtube';
|
||||
@ -106,5 +107,13 @@ export function getSocialLinks(): MenuItem[] {
|
||||
});
|
||||
}
|
||||
|
||||
if (websiteConfig.metadata.social?.telegram) {
|
||||
socialLinks.push({
|
||||
title: 'Telegram',
|
||||
href: websiteConfig.metadata.social.telegram,
|
||||
icon: <TelegramIcon className="size-4 shrink-0" />,
|
||||
});
|
||||
}
|
||||
|
||||
return socialLinks;
|
||||
}
|
||||
|
@ -2,3 +2,5 @@ import type { auth } from './auth';
|
||||
|
||||
// https://www.better-auth.com/docs/concepts/typescript#additional-fields
|
||||
export type Session = typeof auth.$Infer.Session;
|
||||
|
||||
export type User = typeof auth.$Infer.Session.user;
|
||||
|
@ -108,8 +108,12 @@ export const auth = betterAuth({
|
||||
},
|
||||
user: {
|
||||
// https://www.better-auth.com/docs/concepts/database#extending-core-schema
|
||||
// additionalFields: {
|
||||
// },
|
||||
additionalFields: {
|
||||
customerId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
// https://www.better-auth.com/docs/concepts/users-accounts#delete-user
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
@ -142,7 +146,13 @@ export const auth = betterAuth({
|
||||
plugins: [
|
||||
// https://www.better-auth.com/docs/plugins/admin
|
||||
// support user management, ban/unban user, manage user roles, etc.
|
||||
admin(),
|
||||
admin({
|
||||
// https://www.better-auth.com/docs/plugins/admin#default-ban-reason
|
||||
// defaultBanReason: 'Spamming',
|
||||
defaultBanExpiresIn: undefined,
|
||||
bannedUserMessage:
|
||||
'You have been banned from this application. Please contact support if you believe this is an error.',
|
||||
}),
|
||||
],
|
||||
onAPIError: {
|
||||
// https://www.better-auth.com/docs/reference/options#onapierror
|
||||
|
@ -78,3 +78,15 @@ export function getUrlWithLocaleInCallbackUrl(
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Stripe dashboard customer URL
|
||||
* @param customerId - The Stripe customer ID
|
||||
* @returns The Stripe dashboard customer URL
|
||||
*/
|
||||
export function getStripeDashboardCustomerUrl(customerId: string): string {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `https://dashboard.stripe.com/test/customers/${customerId}`;
|
||||
}
|
||||
return `https://dashboard.stripe.com/customers/${customerId}`;
|
||||
}
|
||||
|
12
src/stores/users-store.ts
Normal file
12
src/stores/users-store.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface UsersState {
|
||||
refreshTrigger: number;
|
||||
triggerRefresh: () => void;
|
||||
}
|
||||
|
||||
export const useUsersStore = create<UsersState>((set) => ({
|
||||
refreshTrigger: 0,
|
||||
triggerRefresh: () =>
|
||||
set((state) => ({ refreshTrigger: state.refreshTrigger + 1 })),
|
||||
}));
|
1
src/types/index.d.ts
vendored
1
src/types/index.d.ts
vendored
@ -57,6 +57,7 @@ export interface SocialConfig {
|
||||
facebook?: string;
|
||||
instagram?: string;
|
||||
tiktok?: string;
|
||||
telegram?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user