feat: open source Mkdirs
@@ -0,0 +1,33 @@
|
||||
# Project Overview
|
||||
|
||||
Mkdirs is a directory website template that allows users to launch AI-powered directory websites in minutes. It comes packed with features like AI integration, listings, payment processing, submission forms, CMS, blog, authentication, newsletter, SEO optimization, and theming support.
|
||||
|
||||
## Key Information
|
||||
|
||||
- **Project Name**: Mkdirs Template
|
||||
- **Website**: https://mkdirs.com
|
||||
- **Documentation**: Available at https://docs.mkdirs.com
|
||||
- **Tech Stack**: Next.js, Sanity CMS, TypeScript, Tailwind CSS
|
||||
|
||||
## Main Features
|
||||
|
||||
- AI-powered directory functionality
|
||||
- Listing management
|
||||
- Payment processing
|
||||
- User authentication
|
||||
- Content management system (Sanity)
|
||||
- Blog functionality
|
||||
- Newsletter integration
|
||||
- SEO optimization
|
||||
- Theming support
|
||||
|
||||
## Project Structure
|
||||
|
||||
The project follows a standard Next.js application structure with additional Sanity CMS integration:
|
||||
|
||||
- `src/app`: Contains the application routes and pages
|
||||
- `src/components`: Reusable React components
|
||||
- `src/lib`: Utility functions and libraries
|
||||
- `src/sanity`: Sanity CMS related configuration and schemas
|
||||
- `src/actions`: Server actions (Next.js)
|
||||
- `scripts`: Utility scripts for various operations
|
||||
@@ -0,0 +1,43 @@
|
||||
# Tech Stack and Architecture
|
||||
|
||||
This document outlines the key technologies used in the Mkdirs template and how they work together.
|
||||
|
||||
## Core Technologies
|
||||
|
||||
- **Next.js**: The React framework used for building the application with server-side rendering, API routes, and server components
|
||||
- **TypeScript**: Used for type-safe code across the entire codebase
|
||||
- **Sanity CMS**: Headless CMS for managing content
|
||||
- **Tailwind CSS**: Utility-first CSS framework for styling
|
||||
- **Radix UI**: Unstyled, accessible UI components
|
||||
- **NextAuth**: Authentication solution for Next.js applications
|
||||
- **Stripe**: Payment processing
|
||||
- **React Email**: For creating and sending emails
|
||||
- **React Hook Form**: Form handling
|
||||
- **Zod**: Data validation
|
||||
|
||||
## File Structure
|
||||
|
||||
- **package.json**: Contains all dependencies and scripts
|
||||
- **next.config.mjs**: Next.js configuration
|
||||
- **tailwind.config.ts**: Tailwind CSS configuration
|
||||
- **sanity.config.ts**: Sanity CMS configuration
|
||||
- **src/app**: Next.js app router pages and layouts
|
||||
- **src/components**: Reusable UI components
|
||||
- **src/lib**: Utility functions and shared code
|
||||
- **src/sanity**: Sanity schema definitions and utilities
|
||||
|
||||
## Key Architectural Patterns
|
||||
|
||||
- **App Router**: Uses Next.js App Router for routing
|
||||
- **Server Components**: Leverages React Server Components for better performance
|
||||
- **Server Actions**: Uses Next.js Server Actions for server-side data mutations
|
||||
- **Content Management**: Sanity CMS for structured content
|
||||
- **Authentication**: Secure authentication with NextAuth
|
||||
- **API Routes**: Backend functionality exposed through API routes
|
||||
- **Responsive Design**: Mobile-first approach with Tailwind CSS
|
||||
|
||||
## Build and Deployment
|
||||
|
||||
- **Development**: `pnpm dev` for local development
|
||||
- **Build**: `pnpm build` for production builds
|
||||
- **Docker Support**: Includes Dockerfile for containerization
|
||||
@@ -0,0 +1,50 @@
|
||||
# Application Structure
|
||||
|
||||
This document outlines the structure of the Next.js application and key entry points.
|
||||
|
||||
## App Router Structure
|
||||
|
||||
The application uses Next.js App Router with a split between website and Sanity Studio routes:
|
||||
|
||||
- **src/app/(website)**: Public-facing website routes
|
||||
- **src/app/(sanity)**: Sanity Studio admin routes
|
||||
- **src/app/api**: API routes for various functionalities
|
||||
|
||||
## Key Routes
|
||||
|
||||
- **/**: Main landing page
|
||||
- **/auth**: Authentication-related pages (sign-in, sign-up)
|
||||
- **/[item|category|tag|collection]**: Directory listings pages
|
||||
- **/blog**: Blog section
|
||||
- **/dashboard**: User dashboard
|
||||
- **/studio**: Sanity Studio CMS interface
|
||||
|
||||
## Authentication
|
||||
|
||||
Authentication is handled through NextAuth with various providers and credentials authentication:
|
||||
|
||||
- **src/auth.ts**: Main authentication configuration
|
||||
- **src/auth.config.ts**: Auth providers and callbacks
|
||||
|
||||
## Components Organization
|
||||
|
||||
Components are organized into several categories:
|
||||
|
||||
- **UI Components**: Basic reusable UI elements
|
||||
- **Layout Components**: Page layouts and structural components
|
||||
- **Feature Components**: Components specific to certain features
|
||||
- **Form Components**: Input and form-related components
|
||||
|
||||
## Data Flow
|
||||
|
||||
The application follows these data flow patterns:
|
||||
|
||||
1. **Server Components**: Fetch data directly from the database/CMS
|
||||
2. **Server Actions**: Handle data mutations and form submissions
|
||||
3. **API Routes**: Provide data endpoints for client components
|
||||
4. **Client Components**: Use hooks for client-side data fetching and state management
|
||||
|
||||
## Middleware
|
||||
|
||||
- **src/middleware.ts**: Handles authentication, redirects, and other middleware functionality
|
||||
- **src/routes.ts**: Defines route configurations and access control
|
||||
@@ -0,0 +1,63 @@
|
||||
# Sanity CMS Integration
|
||||
|
||||
This document outlines how Sanity CMS is integrated into the Mkdirs template.
|
||||
|
||||
## Configuration Files
|
||||
|
||||
- **sanity.config.ts**: Main Sanity configuration file
|
||||
- **sanity.cli.ts**: CLI configuration for Sanity
|
||||
- **sanity-typegen.json**: Configuration for type generation
|
||||
- **schema.json**: Generated Sanity schemas in JSON format
|
||||
- **sanity.types.ts**: Generated TypeScript types for Sanity schemas
|
||||
|
||||
## Schema Structure
|
||||
|
||||
The Sanity schema is organized in the `src/sanity` directory:
|
||||
|
||||
- **src/sanity/schemas**: Contains all content type definitions
|
||||
- **src/sanity/lib**: Utility functions for working with Sanity
|
||||
- **src/sanity/plugins**: Additional plugins for Sanity Studio
|
||||
- **src/sanity/components**: Custom components in Sanity Studio
|
||||
|
||||
## Content Types
|
||||
|
||||
Key content types defined in the schema include:
|
||||
|
||||
- **Item**: Directory listing entries
|
||||
- **Category**: Listing categories
|
||||
- **Tag**: Listing tags
|
||||
- **Collection**: Listing collections
|
||||
- **Group**: Listing category groups
|
||||
- **Blog Posts**: Blog content
|
||||
- **Authors**: Content creators
|
||||
- **Pages**: Static page content
|
||||
- **Settings**: Global site settings
|
||||
|
||||
## Data Fetching
|
||||
|
||||
Sanity data is fetched using GROQ queries through the following methods:
|
||||
|
||||
1. **Client API**: Using the Sanity client to fetch data
|
||||
2. **Next.js Data Fetching**: Server components that directly fetch Sanity data
|
||||
3. **API Routes**: API endpoints that serve Sanity data
|
||||
|
||||
## Preview Functionality
|
||||
|
||||
The template includes a preview mode for content editing:
|
||||
|
||||
- **Draft Preview**: See draft content before publishing
|
||||
|
||||
## Studio Customization
|
||||
|
||||
The Sanity Studio interface is customized for the Mkdirs use case:
|
||||
|
||||
- **Custom Desk Structure**: Organized content editing interface
|
||||
- **Custom Input Components**: Specialized input fields for directories
|
||||
- **Sanity Plugins**: Additional functionality through Sanity plugins
|
||||
|
||||
## Type Generation
|
||||
|
||||
Types are automatically generated from Sanity schemas:
|
||||
|
||||
- Run `pnpm typegen` to update TypeScript types after schema changes
|
||||
- Generated types ensure type safety when working with Sanity content
|
||||
@@ -0,0 +1,33 @@
|
||||
# AI Integration
|
||||
|
||||
This document outlines the AI capabilities integrated into the Mkdirs template.
|
||||
|
||||
## AI Providers
|
||||
|
||||
The template supports multiple AI providers through AI SDK:
|
||||
|
||||
- **OpenAI**: Via `@ai-sdk/openai`
|
||||
- **Google AI**: Via `@ai-sdk/google`
|
||||
- **DeepSeek**: Via `@ai-sdk/deepseek`
|
||||
|
||||
## AI Features
|
||||
|
||||
AI is integrated into various parts of the application:
|
||||
|
||||
- **Content Generation**: Assist in creating listings information
|
||||
|
||||
## AI Configuration
|
||||
|
||||
AI providers can be configured in the application settings:
|
||||
|
||||
- **API Keys**: Securely stored provider API keys
|
||||
- **Model Selection**: Choose which models to use
|
||||
|
||||
## Privacy and Ethics
|
||||
|
||||
The AI implementation follows best practices:
|
||||
|
||||
- **User Consent**: Clear disclosure of AI usage
|
||||
- **Data Privacy**: No unnecessary data sharing with AI providers
|
||||
- **Human Review**: Critical AI-generated content requires human approval
|
||||
- **Transparency**: Clear indication of AI-generated content
|
||||
@@ -0,0 +1,53 @@
|
||||
# Development Guide
|
||||
|
||||
This document provides guidelines for developing and contributing to the Mkdirs template.
|
||||
|
||||
## Development Setup
|
||||
|
||||
1. Clone the repository
|
||||
2. Install dependencies with `pnpm install`
|
||||
3. Set up environment variables (copy `.env.example` to `.env`)
|
||||
4. Run the development server with `pnpm dev`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Key environment variables needed for development:
|
||||
|
||||
- **NEXT_PUBLIC_SITE_URL**: Base URL of the site
|
||||
- **NEXT_PUBLIC_SANITY_PROJECT_ID**: Sanity project ID
|
||||
- **NEXT_PUBLIC_SANITY_DATASET**: Sanity dataset name
|
||||
- **SANITY_API_TOKEN**: Sanity API token
|
||||
- **NEXTAUTH_SECRET**: Secret for NextAuth
|
||||
- **STRIPE_SECRET_KEY**: Stripe API secret key
|
||||
|
||||
## Code Style and Linting
|
||||
|
||||
The project uses Biome for code formatting and linting:
|
||||
|
||||
- **biome.json**: Biome configuration
|
||||
- Run `pnpm lint` to check for code style issues
|
||||
- Run `pnpm format` to format the codebase
|
||||
|
||||
## Scripts
|
||||
|
||||
Utility scripts for common tasks:
|
||||
|
||||
- **scripts**: Directory containing utility scripts
|
||||
- **Batch Operations**: Scripts for batch operations on items
|
||||
- **Email Export**: Tools for exporting user emails
|
||||
- **Microlink Integration**: Scripts for working with microlink
|
||||
|
||||
## Build and Deployment
|
||||
|
||||
Steps for building and deploying the application:
|
||||
|
||||
1. Run `pnpm build` to create a production build
|
||||
2. Verify the build with `pnpm start`
|
||||
3. Deploy to your hosting platform of choice
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
For Docker-based deployment:
|
||||
|
||||
- **Dockerfile**: Docker configuration
|
||||
- **next.config.docker.mjs**: Next.js configuration for Docker
|
||||
@@ -0,0 +1,73 @@
|
||||
# Best Practices
|
||||
|
||||
This document outlines best practices for working with the Mkdirs codebase.
|
||||
|
||||
## Code Organization
|
||||
|
||||
Follow these guidelines for code organization:
|
||||
|
||||
- **Component Isolation**: Keep components focused on a single responsibility
|
||||
- **Server vs. Client**: Clearly separate server and client components
|
||||
- **Type Definitions**: Place shared types in the `src/types` directory
|
||||
- **Utility Functions**: Extract reusable logic to the `src/lib` directory
|
||||
- **Page Structure**: Maintain consistent page component structure
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
Ensure optimal performance:
|
||||
|
||||
- **Server Components**: Use React Server Components for data-fetching operations
|
||||
- **Image Optimization**: Use Next.js Image component with proper sizing
|
||||
- **Bundle Size**: Monitor and optimize bundle size
|
||||
- **Lazy Loading**: Implement lazy loading for below-the-fold content
|
||||
- **Caching Strategy**: Leverage Next.js caching capabilities
|
||||
|
||||
## State Management
|
||||
|
||||
Guidelines for state management:
|
||||
|
||||
- **Local State**: Use React's useState for component-specific state
|
||||
- **Form State**: Use React Hook Form for form state management
|
||||
- **Server State**: Prefer server components over client-side data fetching
|
||||
- **URL State**: Use URL parameters for shareable state
|
||||
- **Context API**: Use sparingly for deeply nested shared state
|
||||
|
||||
## Error Handling
|
||||
|
||||
Implement robust error handling:
|
||||
|
||||
- **Error Boundaries**: Use React Error Boundaries for client components
|
||||
- **Form Validation**: Implement Zod validation for all forms
|
||||
- **API Errors**: Handle API errors gracefully with fallbacks
|
||||
- **Logging**: Log errors for debugging and monitoring
|
||||
- **User Feedback**: Provide clear error messages to users
|
||||
|
||||
## Accessibility
|
||||
|
||||
Ensure accessibility compliance:
|
||||
|
||||
- **Semantic HTML**: Use appropriate HTML elements
|
||||
- **ARIA Attributes**: Add ARIA attributes where necessary
|
||||
- **Keyboard Navigation**: Ensure all interactions work with keyboard
|
||||
- **Color Contrast**: Maintain sufficient contrast ratios
|
||||
- **Screen Readers**: Test with screen readers
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
Follow these security guidelines:
|
||||
|
||||
- **Input Validation**: Validate all user inputs server-side
|
||||
- **Authentication**: Use NextAuth for secure authentication
|
||||
- **Authorization**: Implement proper authorization checks
|
||||
- **CSRF Protection**: Use proper CSRF tokens
|
||||
- **XSS Prevention**: Sanitize user-generated content
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Approach to testing:
|
||||
|
||||
- **Component Testing**: Test UI components in isolation
|
||||
- **Integration Testing**: Test feature workflows
|
||||
- **Mocking**: Mock external services in tests
|
||||
- **Test Coverage**: Aim for good coverage of critical paths
|
||||
- **E2E Testing**: Implement end-to-end tests for critical flows
|
||||
@@ -0,0 +1,114 @@
|
||||
# "http://localhost:3000" or "https://mkdirs.com"
|
||||
NEXT_PUBLIC_APP_URL=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Dataset & Storage (Sanity)
|
||||
# -----------------------------------------------------------------------------
|
||||
# https://www.sanity.io/manage
|
||||
NEXT_PUBLIC_SANITY_DATASET=
|
||||
NEXT_PUBLIC_SANITY_PROJECT_ID=
|
||||
SANITY_API_TOKEN=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Email (Resend)
|
||||
# -----------------------------------------------------------------------------
|
||||
# https://resend.com/api-keys
|
||||
RESEND_API_KEY=
|
||||
RESEND_EMAIL_FROM=
|
||||
RESEND_EMAIL_ADMIN=
|
||||
RESEND_AUDIENCE_ID=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Payment (Stripe)
|
||||
# -----------------------------------------------------------------------------
|
||||
STRIPE_API_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
NEXT_PUBLIC_STRIPE_PRO_PRICE_ID=
|
||||
NEXT_PUBLIC_STRIPE_SPONSOR_PRICE_ID=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Authentication (NextAuth)
|
||||
# -----------------------------------------------------------------------------
|
||||
# https://authjs.dev/getting-started/deployment#auth_secret
|
||||
# Secret can be generated with `openssl rand -base64 32`
|
||||
AUTH_SECRET=
|
||||
|
||||
# https://authjs.dev/getting-started/authentication/oauth
|
||||
# https://console.cloud.google.com/apis/credentials
|
||||
AUTH_GOOGLE_ID=
|
||||
AUTH_GOOGLE_SECRET=
|
||||
|
||||
# https://authjs.dev/getting-started/authentication/oauth
|
||||
# https://github.com/settings/developers
|
||||
AUTH_GITHUB_ID=
|
||||
AUTH_GITHUB_SECRET=
|
||||
|
||||
# https://authjs.dev/getting-started/deployment#auth_trust_host
|
||||
# In a Docker environment, make sure to set either trustHost: true in your Auth.js configuration
|
||||
# or the AUTH_TRUST_HOST environment variable to true.
|
||||
# AUTH_TRUST_HOST=true
|
||||
|
||||
# https://authjs.dev/getting-started/deployment#auth_url
|
||||
# This environment variable is mostly unnecessary with v5 as the host is inferred from the request headers.
|
||||
# However, if you are using a different base path, you can set this environment variable as well.
|
||||
# AUTH_URL=https://demo.mkdirs.com/api/auth
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Analytics (OpenPanel & Google)
|
||||
# -----------------------------------------------------------------------------
|
||||
# OpenPanel Analytics (https://openpanel.dev)
|
||||
NEXT_PUBLIC_OPENPANEL_CLIENT_ID=
|
||||
|
||||
# Google Analytics (https://analytics.google.com)
|
||||
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=
|
||||
|
||||
# Umami Analytics (https://umami.is)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=""
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT="https://cloud.umami.is/script.js"
|
||||
|
||||
# Plausible Analytics (https://plausible.io)
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT="https://plausible.io/js/script.js"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Notifications (discord)
|
||||
# -----------------------------------------------------------------------------
|
||||
DISCORD_WEBHOOK_URL=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# AI SDK (https://sdk.vercel.ai)
|
||||
# default ai provider: ``, available values: `google`, `deepseek`, `openai`, `xai`, `openrouter`
|
||||
# google/deepseek/xai/openrouter are tested, openai is not tested yet
|
||||
# -----------------------------------------------------------------------------
|
||||
DEFAULT_AI_PROVIDER=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Google Generative AI (https://aistudio.google.com/apikey)
|
||||
# NOTE: if you set `DEFAULT_AI_PROVIDER=google`, you should set this API_KEY
|
||||
# -----------------------------------------------------------------------------
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# DeepSeek (https://platform.deepseek.com/api_keys)
|
||||
# NOTE: if you set `DEFAULT_AI_PROVIDER=deepseek`, you should set this API_KEY
|
||||
# -----------------------------------------------------------------------------
|
||||
DEEPSEEK_API_KEY=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OpenAI (https://platform.openai.com/settings/organization/api-keys)
|
||||
# NOTE: if you set `DEFAULT_AI_PROVIDER=openai`, you should set this API_KEY
|
||||
# -----------------------------------------------------------------------------
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# XAI (https://console.x.ai/)
|
||||
# NOTE: if you set `DEFAULT_AI_PROVIDER=xai`, you should set this API_KEY
|
||||
# -----------------------------------------------------------------------------
|
||||
XAI_API_KEY=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OpenRouter (https://openrouter.ai/settings/keys)
|
||||
# NOTE: if you set `DEFAULT_AI_PROVIDER=openrouter`, you should set this API_KEY
|
||||
# -----------------------------------------------------------------------------
|
||||
OPENROUTER_API_KEY=
|
||||
OPENROUTER_MODEL=
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: javayhu
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Mkdirs Community Support
|
||||
url: https://github.com/MkdirsHQ/mkdirs/discussions
|
||||
about: Please ask and answer questions here.
|
||||
- name: Mkdirs Documentation
|
||||
url: https://docs.mkdirs.com
|
||||
about: Please check out the documentation here.
|
||||
- name: Mkdirs Video Tutorials
|
||||
url: https://www.youtube.com/@MkdirsHQ
|
||||
about: Please check out the video tutorials here.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[feat]"
|
||||
labels: enhancement
|
||||
assignees: javayhu
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
/.docs/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# idea
|
||||
/.idea/
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"biomejs.biome",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"eamodio.gitlens"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"quickfix.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
".next": true,
|
||||
".content-collections": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Code Agents (Codex, Cursor, etc.) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Mkdirs is a Next.js 14 directory website template with Sanity CMS, enabling AI-powered directory sites with listings, payments, authentication, blog, and newsletter features.
|
||||
|
||||
## Commands
|
||||
|
||||
- **Dev server**: `pnpm dev`
|
||||
- **Build**: `pnpm build`
|
||||
- **Start production**: `pnpm start`
|
||||
- **Lint**: `pnpm lint` (Biome - checks and auto-fixes)
|
||||
- **Lint with unsafe fixes**: `pnpm lint:fix`
|
||||
- **Format**: `pnpm format` (Biome)
|
||||
- **Generate Sanity types**: `pnpm typegen` (run after schema changes)
|
||||
- **Email preview**: `pnpm email` (starts email dev server on port 3333)
|
||||
- **Batch item operations**: `pnpm item:import`, `pnpm item:fetch`, `pnpm item:update`, `pnpm item:remove`
|
||||
- **Batch all**: `pnpm batch` (or `pnpm batch:import`, `pnpm batch:update`, `pnpm batch:remove`)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Route Structure (Next.js App Router)
|
||||
|
||||
The app uses two top-level route groups:
|
||||
|
||||
- `src/app/(website)/` - Main website with nested groups:
|
||||
- `(public)/` - Public pages: home, search, item, category, tag, collection, blog, pricing
|
||||
- `(protected)/` - Auth-required pages: dashboard, settings, submit, edit
|
||||
- `(newsletter)/` - Newsletter unsubscribe
|
||||
- `auth/` - Login, register, reset password, email verification
|
||||
- `src/app/(sanity)/` - Sanity Studio admin interface (accessible at `/studio`)
|
||||
- `src/app/api/` - API routes: auth, webhook (Stripe), og images, draft mode, send-email, upload-image
|
||||
|
||||
### Route Protection
|
||||
|
||||
`src/routes.ts` defines public routes, auth routes, and API auth prefix. `src/middleware.ts` enforces access control. Authenticated users on auth routes redirect to `/dashboard`.
|
||||
|
||||
### Data Layer
|
||||
|
||||
- **Sanity CMS** is the primary content store. Schemas live in `src/sanity/schemas/documents/` organized by domain: `directory/` (item, category, tag, collection, group), `blog/` (post, author), `page/`, `order/`, `auth/`, and `settings.ts`.
|
||||
- **`src/data/`** contains data access functions (item.ts, blog.ts, collection.ts, user.ts, account.ts, order.ts, submission.ts, etc.) used by server components and actions.
|
||||
- **`src/sanity/lib/`** has Sanity client utilities and GROQ query helpers.
|
||||
- **`sanity.types.ts`** contains auto-generated TypeScript types from Sanity schemas (regenerate with `pnpm typegen`).
|
||||
|
||||
### Server Actions
|
||||
|
||||
`src/actions/` contains all server actions for mutations: authentication (login, register, reset), item operations (submit, edit, publish, unpublish), payment (checkout sessions, customer portal), settings, newsletter subscription, and admin operations.
|
||||
|
||||
### Key Integrations
|
||||
|
||||
- **Auth**: NextAuth v5 (beta.18) configured in `src/auth.ts` and `src/auth.config.ts`. Supports credentials + OAuth providers.
|
||||
- **Payments**: Stripe via `src/lib/stripe.ts`, with checkout session creation in actions and webhook handling in `src/app/api/webhook/route.ts`.
|
||||
- **AI**: Vercel AI SDK with multiple providers (OpenAI, Google, DeepSeek, xAI, OpenRouter) for content generation assistance.
|
||||
- **Email**: React Email templates in `emails/` sent via Resend (`src/lib/mail.ts`).
|
||||
- **Analytics**: OpenPanel integration (`@openpanel/nextjs`).
|
||||
- **Image metadata**: Microlink (`@microlink/mql`) for fetching website screenshots/metadata.
|
||||
|
||||
### Components Organization
|
||||
|
||||
`src/components/` is organized by feature domain: `auth/`, `blog/`, `item/`, `category/`, `collection/`, `tag/`, `dashboard/`, `payment/`, `pricing/`, `search/`, `submit/`, `edit/`, `publish/`, `newsletter/`, `settings/`, `home/` (+ `home2/`, `home3/` variants), `layout/`, `shared/`, `icons/`, and `ui/` (shadcn/ui primitives).
|
||||
|
||||
### Configuration
|
||||
|
||||
- `src/config/site.ts` - Site-wide settings (name, URL, description)
|
||||
- `src/config/price.ts` - Pricing plans configuration
|
||||
- `src/config/dashboard.ts` - Dashboard navigation
|
||||
- `src/config/hero.ts`, `footer.ts`, `faq.ts`, `marketing.ts` - Landing page sections
|
||||
- `src/lib/constants.ts` - Shared constants
|
||||
- `src/lib/schemas.ts` - Zod validation schemas used across forms and actions
|
||||
|
||||
### Styling
|
||||
|
||||
Tailwind CSS with `tailwind.config.ts`. UI primitives are Radix UI-based shadcn/ui components in `src/components/ui/`. Biome ignores `src/components/ui/*.tsx` (generated code).
|
||||
|
||||
### Environment
|
||||
|
||||
Copy `.env.example` to `.env`. Key variables: `NEXT_PUBLIC_SITE_URL`, `NEXT_PUBLIC_SANITY_PROJECT_ID`, `NEXT_PUBLIC_SANITY_DATASET`, `SANITY_API_TOKEN`, `NEXTAUTH_SECRET`, `STRIPE_SECRET_KEY`, Resend API key, and AI provider keys.
|
||||
@@ -0,0 +1,79 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Mkdirs is a Next.js 14 directory website template with Sanity CMS, enabling AI-powered directory sites with listings, payments, authentication, blog, and newsletter features.
|
||||
|
||||
## Commands
|
||||
|
||||
- **Dev server**: `pnpm dev`
|
||||
- **Build**: `pnpm build`
|
||||
- **Start production**: `pnpm start`
|
||||
- **Lint**: `pnpm lint` (Biome - checks and auto-fixes)
|
||||
- **Lint with unsafe fixes**: `pnpm lint:fix`
|
||||
- **Format**: `pnpm format` (Biome)
|
||||
- **Generate Sanity types**: `pnpm typegen` (run after schema changes)
|
||||
- **Email preview**: `pnpm email` (starts email dev server on port 3333)
|
||||
- **Batch item operations**: `pnpm item:import`, `pnpm item:fetch`, `pnpm item:update`, `pnpm item:remove`
|
||||
- **Batch all**: `pnpm batch` (or `pnpm batch:import`, `pnpm batch:update`, `pnpm batch:remove`)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Route Structure (Next.js App Router)
|
||||
|
||||
The app uses two top-level route groups:
|
||||
|
||||
- `src/app/(website)/` - Main website with nested groups:
|
||||
- `(public)/` - Public pages: home, search, item, category, tag, collection, blog, pricing
|
||||
- `(protected)/` - Auth-required pages: dashboard, settings, submit, edit
|
||||
- `(newsletter)/` - Newsletter unsubscribe
|
||||
- `auth/` - Login, register, reset password, email verification
|
||||
- `src/app/(sanity)/` - Sanity Studio admin interface (accessible at `/studio`)
|
||||
- `src/app/api/` - API routes: auth, webhook (Stripe), og images, draft mode, send-email, upload-image
|
||||
|
||||
### Route Protection
|
||||
|
||||
`src/routes.ts` defines public routes, auth routes, and API auth prefix. `src/middleware.ts` enforces access control. Authenticated users on auth routes redirect to `/dashboard`.
|
||||
|
||||
### Data Layer
|
||||
|
||||
- **Sanity CMS** is the primary content store. Schemas live in `src/sanity/schemas/documents/` organized by domain: `directory/` (item, category, tag, collection, group), `blog/` (post, author), `page/`, `order/`, `auth/`, and `settings.ts`.
|
||||
- **`src/data/`** contains data access functions (item.ts, blog.ts, collection.ts, user.ts, account.ts, order.ts, submission.ts, etc.) used by server components and actions.
|
||||
- **`src/sanity/lib/`** has Sanity client utilities and GROQ query helpers.
|
||||
- **`sanity.types.ts`** contains auto-generated TypeScript types from Sanity schemas (regenerate with `pnpm typegen`).
|
||||
|
||||
### Server Actions
|
||||
|
||||
`src/actions/` contains all server actions for mutations: authentication (login, register, reset), item operations (submit, edit, publish, unpublish), payment (checkout sessions, customer portal), settings, newsletter subscription, and admin operations.
|
||||
|
||||
### Key Integrations
|
||||
|
||||
- **Auth**: NextAuth v5 (beta.18) configured in `src/auth.ts` and `src/auth.config.ts`. Supports credentials + OAuth providers.
|
||||
- **Payments**: Stripe via `src/lib/stripe.ts`, with checkout session creation in actions and webhook handling in `src/app/api/webhook/route.ts`.
|
||||
- **AI**: Vercel AI SDK with multiple providers (OpenAI, Google, DeepSeek, xAI, OpenRouter) for content generation assistance.
|
||||
- **Email**: React Email templates in `emails/` sent via Resend (`src/lib/mail.ts`).
|
||||
- **Analytics**: OpenPanel integration (`@openpanel/nextjs`).
|
||||
- **Image metadata**: Microlink (`@microlink/mql`) for fetching website screenshots/metadata.
|
||||
|
||||
### Components Organization
|
||||
|
||||
`src/components/` is organized by feature domain: `auth/`, `blog/`, `item/`, `category/`, `collection/`, `tag/`, `dashboard/`, `payment/`, `pricing/`, `search/`, `submit/`, `edit/`, `publish/`, `newsletter/`, `settings/`, `home/` (+ `home2/`, `home3/` variants), `layout/`, `shared/`, `icons/`, and `ui/` (shadcn/ui primitives).
|
||||
|
||||
### Configuration
|
||||
|
||||
- `src/config/site.ts` - Site-wide settings (name, URL, description)
|
||||
- `src/config/price.ts` - Pricing plans configuration
|
||||
- `src/config/dashboard.ts` - Dashboard navigation
|
||||
- `src/config/hero.ts`, `footer.ts`, `faq.ts`, `marketing.ts` - Landing page sections
|
||||
- `src/lib/constants.ts` - Shared constants
|
||||
- `src/lib/schemas.ts` - Zod validation schemas used across forms and actions
|
||||
|
||||
### Styling
|
||||
|
||||
Tailwind CSS with `tailwind.config.ts`. UI primitives are Radix UI-based shadcn/ui components in `src/components/ui/`. Biome ignores `src/components/ui/*.tsx` (generated code).
|
||||
|
||||
### Environment
|
||||
|
||||
Copy `.env.example` to `.env`. Key variables: `NEXT_PUBLIC_SITE_URL`, `NEXT_PUBLIC_SANITY_PROJECT_ID`, `NEXT_PUBLIC_SANITY_DATASET`, `SANITY_API_TOKEN`, `NEXTAUTH_SECRET`, `STRIPE_SECRET_KEY`, Resend API key, and AI provider keys.
|
||||
@@ -0,0 +1,61 @@
|
||||
# https://github.com/nextauthjs/next-auth-example/blob/main/Dockerfile
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN npm install -g pnpm && pnpm i --frozen-lockfile
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN npm install -g pnpm \
|
||||
&& mv next.config.docker.mjs next.config.mjs \
|
||||
&& pnpm build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Mkdirs
|
||||
|
||||
Launch AI-powered directory websites in minutes
|
||||
|
||||
[](https://github.com/MkdirsHQ/mkdirs/stargazers)
|
||||
[](LICENSE)
|
||||
|
||||
Mkdirs helps you launch a directory website with listings, AI-assisted
|
||||
submissions, payments, authentication, CMS, blog, newsletter, SEO, themes,
|
||||
and more.
|
||||
|
||||
[Live Demo](https://demo.mkdirs.com) · [Documentation](https://docs.mkdirs.com) · [Installation Guide](https://docs.mkdirs.com/installation) · [Video Tutorials](https://www.youtube.com/@MkdirsHQ) · [Showcase](https://mkdirs.com/showcase)
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Directory listings, categories, collections, tags, and search
|
||||
- AI-assisted website submission and content generation
|
||||
- User authentication and dashboard
|
||||
- Free, paid, and sponsored submissions with Stripe
|
||||
- Sanity CMS and blog
|
||||
- Email and newsletter integration with Resend
|
||||
- SEO, analytics, themes, and responsive layouts
|
||||
- Vercel and Docker deployment
|
||||
|
||||
## Tech Stack
|
||||
|
||||
Next.js, React, TypeScript, Tailwind CSS, Sanity, Auth.js, Stripe, and Resend.
|
||||
|
||||
For installation, configuration, customization, deployment, and video
|
||||
tutorials, visit the [Mkdirs documentation](https://docs.mkdirs.com).
|
||||
|
||||
## Links
|
||||
|
||||
- [Website](https://mkdirs.com)
|
||||
- [Demo](https://demo.mkdirs.com)
|
||||
- [Documentation](https://docs.mkdirs.com)
|
||||
- [YouTube](https://www.youtube.com/@MkdirsHQ)
|
||||
- [Discord](https://mksaas.link/discord)
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [Apache License 2.0](LICENSE). You may use, modify, and
|
||||
distribute the code, including for commercial purposes, subject to the terms
|
||||
of the license.
|
||||
|
||||
The license does not grant permission to use the Mkdirs name, logo, or other
|
||||
trademarks to identify or promote derived products. Reasonable use to describe
|
||||
the origin of the software, such as “Built with Mkdirs,” is permitted.
|
||||
|
||||
Copyright © Mkdirs.
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"files": {
|
||||
"ignore": [
|
||||
"src/components/ui/*.tsx"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"ignore": [],
|
||||
"attributePosition": "auto",
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80,
|
||||
"lineEnding": "lf"
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"arrowParentheses":"always",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"semicolons": "always",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"trailingCommas": "none"
|
||||
}
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { getBaseUrl } from "@/lib/utils";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import {
|
||||
anchor,
|
||||
box,
|
||||
button,
|
||||
container,
|
||||
footer,
|
||||
footerLeft,
|
||||
footerRight,
|
||||
hr,
|
||||
main,
|
||||
paragraph,
|
||||
} from "./email-formats";
|
||||
|
||||
interface ApprovalEmailProps {
|
||||
userName?: string;
|
||||
itemLink?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* https://demo.react.email/preview/welcome/stripe-welcome
|
||||
*/
|
||||
export const ApprovalEmail = ({
|
||||
userName,
|
||||
itemLink,
|
||||
}: ApprovalEmailProps) => {
|
||||
const baseUrl = getBaseUrl();
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Your submission has been approved</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={box}>
|
||||
<Img
|
||||
src={`${baseUrl}/logo.png`}
|
||||
width="32"
|
||||
height="32"
|
||||
alt="Logo"
|
||||
/>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>Hi {userName},</Text>
|
||||
<Text style={paragraph}>
|
||||
Thanks for submitting your product to{" "}
|
||||
<Link style={anchor} href={baseUrl}>
|
||||
{siteConfig.name}
|
||||
</Link>
|
||||
. We're so excited to include your product in our directory!
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Your product has been successfully added to our directory. You can
|
||||
view your listing by clicking the button below:
|
||||
</Text>
|
||||
<Button style={button} href={itemLink}>
|
||||
View your product
|
||||
</Button>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>
|
||||
We appreciate your support and contribution to our community. If
|
||||
you have any questions, please don't hesitate to contact us.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Thank you again for choosing{" "}
|
||||
<Link style={anchor} href={baseUrl}>
|
||||
{siteConfig.name}
|
||||
</Link>
|
||||
. We look forward to helping more people discover your product!
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Thanks, <br />
|
||||
The{" "}
|
||||
<Link style={anchor} href={baseUrl}>
|
||||
{siteConfig.name}
|
||||
</Link>{" "}
|
||||
team
|
||||
</Text>
|
||||
<Hr style={hr} />
|
||||
<Text style={footer}>
|
||||
<span style={footerLeft}>
|
||||
© {new Date().getFullYear()}
|
||||
All Rights Reserved.
|
||||
</span>
|
||||
<span style={footerRight}>
|
||||
<Link style={anchor} href={siteConfig.links.twitter}>
|
||||
Twitter
|
||||
</Link>
|
||||
|
||||
<Link style={anchor} href={siteConfig.links.github}>
|
||||
GitHub
|
||||
</Link>
|
||||
</span>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
ApprovalEmail.PreviewProps = {
|
||||
userName: "Javayhu",
|
||||
itemLink: "https://demo.mkdirs.com",
|
||||
} as ApprovalEmailProps;
|
||||
|
||||
export default ApprovalEmail;
|
||||
@@ -0,0 +1,62 @@
|
||||
export const main = {
|
||||
// backgroundColor: "#f6f9fc",
|
||||
// padding: "40px",
|
||||
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
export const container = {
|
||||
// backgroundColor: "#ffffff",
|
||||
// padding: "20px 0 20px",
|
||||
};
|
||||
|
||||
export const box = {
|
||||
padding: "8px",
|
||||
};
|
||||
|
||||
export const hr = {
|
||||
borderColor: "#e6ebf1",
|
||||
margin: "20px 0",
|
||||
};
|
||||
|
||||
export const paragraph = {
|
||||
color: "#525f7f",
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
textAlign: "left" as const,
|
||||
};
|
||||
|
||||
export const anchor = {
|
||||
color: "#556cd6",
|
||||
};
|
||||
|
||||
export const button = {
|
||||
backgroundColor: "#656ee8",
|
||||
borderRadius: "5px",
|
||||
color: "#fff",
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
textAlign: "center" as const,
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
};
|
||||
|
||||
export const footer = {
|
||||
width: "100%",
|
||||
color: "#8898aa",
|
||||
fontSize: "12px",
|
||||
lineHeight: "16px",
|
||||
display: "table",
|
||||
tableLayout: "fixed" as const,
|
||||
};
|
||||
|
||||
export const footerLeft = {
|
||||
display: "table-cell",
|
||||
textAlign: "left" as const,
|
||||
};
|
||||
|
||||
export const footerRight = {
|
||||
display: "table-cell",
|
||||
textAlign: "right" as const,
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { getBaseUrl } from "@/lib/utils";
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import {
|
||||
anchor,
|
||||
box,
|
||||
container,
|
||||
footer,
|
||||
footerLeft,
|
||||
footerRight,
|
||||
hr,
|
||||
main,
|
||||
paragraph,
|
||||
} from "./email-formats";
|
||||
|
||||
export const NewsletterWelcomeEmail = ({ email }: { email: string }) => {
|
||||
const baseUrl = getBaseUrl();
|
||||
const unsubscribeUrl = `${baseUrl}/unsubscribe?email=${encodeURIComponent(email)}`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Welcome to Mkdirs!</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={box}>
|
||||
<Img
|
||||
src={`${baseUrl}/logo.png`}
|
||||
width="32"
|
||||
height="32"
|
||||
alt="Logo"
|
||||
/>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>
|
||||
Welcome to our community! We're thrilled to have you join us on
|
||||
this journey of exploring cutting-edge websites and tools.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
We value your participation and feedback. Please don't hesitate to
|
||||
reach out to us if you have any questions or suggestions.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Thanks, <br />
|
||||
The{" "}
|
||||
<Link style={anchor} href={baseUrl}>
|
||||
{siteConfig.name}
|
||||
</Link>{" "}
|
||||
team
|
||||
</Text>
|
||||
<Hr style={hr} />
|
||||
<Text style={footer}>
|
||||
<span style={footerLeft}>
|
||||
© {new Date().getFullYear()}
|
||||
All Rights Reserved.
|
||||
</span>
|
||||
|
||||
<span style={footerRight}>
|
||||
<Link style={anchor} href={siteConfig.links.twitter}>
|
||||
Twitter
|
||||
</Link>
|
||||
|
||||
<Link style={anchor} href={siteConfig.links.github}>
|
||||
GitHub
|
||||
</Link>
|
||||
</span>
|
||||
</Text>
|
||||
<Text style={footer}>
|
||||
<span>
|
||||
If you wish to unsubscribe,{" "}
|
||||
<Link style={anchor} href={unsubscribeUrl} target="_blank">
|
||||
click here
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsletterWelcomeEmail;
|
||||
|
||||
NewsletterWelcomeEmail.PreviewProps = {
|
||||
email: "support@mkdirs.com",
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { getBaseUrl } from "@/lib/utils";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import {
|
||||
anchor,
|
||||
box,
|
||||
button,
|
||||
container,
|
||||
footer,
|
||||
footerLeft,
|
||||
footerRight,
|
||||
hr,
|
||||
main,
|
||||
paragraph,
|
||||
} from "./email-formats";
|
||||
|
||||
interface NotifySubmissionEmailProps {
|
||||
itemName?: string;
|
||||
reviewLink?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* https://demo.react.email/preview/welcome/stripe-welcome
|
||||
*/
|
||||
export const NotifySubmissionEmail = ({
|
||||
itemName,
|
||||
reviewLink,
|
||||
}: NotifySubmissionEmailProps) => {
|
||||
const baseUrl = getBaseUrl();
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>New submission</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={box}>
|
||||
<Img
|
||||
src={`${baseUrl}/logo.png`}
|
||||
width="32"
|
||||
height="32"
|
||||
alt="Logo"
|
||||
/>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>New submission</Text>
|
||||
<Text style={paragraph}>
|
||||
A new submission named <b>{itemName}</b> is ready to be reviewed.
|
||||
</Text>
|
||||
<Button style={button} href={reviewLink}>
|
||||
Review submission
|
||||
</Button>
|
||||
<Text style={paragraph}>
|
||||
Thanks, <br />
|
||||
The{" "}
|
||||
<Link style={anchor} href={baseUrl}>
|
||||
{siteConfig.name}
|
||||
</Link>{" "}
|
||||
team
|
||||
</Text>
|
||||
<Hr style={hr} />
|
||||
<Text style={footer}>
|
||||
<span style={footerLeft}>
|
||||
© {new Date().getFullYear()}
|
||||
All Rights Reserved.
|
||||
</span>
|
||||
<span style={footerRight}>
|
||||
<Link style={anchor} href={siteConfig.links.twitter}>
|
||||
Twitter
|
||||
</Link>
|
||||
|
||||
<Link style={anchor} href={siteConfig.links.github}>
|
||||
GitHub
|
||||
</Link>
|
||||
</span>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
NotifySubmissionEmail.PreviewProps = {
|
||||
itemName: "Mkdirs",
|
||||
reviewLink: "https://demo.mkdirs.com",
|
||||
} as NotifySubmissionEmailProps;
|
||||
|
||||
export default NotifySubmissionEmail;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { getBaseUrl } from "@/lib/utils";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import {
|
||||
anchor,
|
||||
box,
|
||||
button,
|
||||
container,
|
||||
footer,
|
||||
footerLeft,
|
||||
footerRight,
|
||||
hr,
|
||||
main,
|
||||
paragraph,
|
||||
} from "./email-formats";
|
||||
|
||||
interface NotifySubmissionToUserEmailProps {
|
||||
userName?: string;
|
||||
itemName?: string;
|
||||
statusLink?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* https://demo.react.email/preview/welcome/stripe-welcome
|
||||
*/
|
||||
export const NotifySubmissionToUserEmail = ({
|
||||
userName,
|
||||
itemName,
|
||||
statusLink,
|
||||
}: NotifySubmissionToUserEmailProps) => {
|
||||
const baseUrl = getBaseUrl();
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Thank you for your submission</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={box}>
|
||||
<Img
|
||||
src={`${baseUrl}/logo.png`}
|
||||
width="32"
|
||||
height="32"
|
||||
alt="Logo"
|
||||
/>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>Hi {userName},</Text>
|
||||
<Text style={paragraph}>Thank you for your submission.</Text>
|
||||
<Text style={paragraph}>
|
||||
Your product named <b>{itemName}</b> will be reviewed in the next
|
||||
48 hours before listing on our directory.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
You can view your submission status by clicking the button below:
|
||||
</Text>
|
||||
<Button style={button} href={statusLink}>
|
||||
View submission status
|
||||
</Button>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>
|
||||
We appreciate your support and contribution to our community. If
|
||||
you have any questions, please don't hesitate to contact us.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Thank you again for choosing{" "}
|
||||
<Link style={anchor} href={baseUrl}>
|
||||
{siteConfig.name}
|
||||
</Link>
|
||||
. We look forward to helping more people discover your product!
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Thanks, <br />
|
||||
The{" "}
|
||||
<Link style={anchor} href={baseUrl}>
|
||||
{siteConfig.name}
|
||||
</Link>{" "}
|
||||
team
|
||||
</Text>
|
||||
<Hr style={hr} />
|
||||
<Text style={footer}>
|
||||
<span style={footerLeft}>
|
||||
© {new Date().getFullYear()}
|
||||
All Rights Reserved.
|
||||
</span>
|
||||
<span style={footerRight}>
|
||||
<Link style={anchor} href={siteConfig.links.twitter}>
|
||||
Twitter
|
||||
</Link>
|
||||
|
||||
<Link style={anchor} href={siteConfig.links.github}>
|
||||
GitHub
|
||||
</Link>
|
||||
</span>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
NotifySubmissionToUserEmail.PreviewProps = {
|
||||
userName: "Javayhu",
|
||||
itemName: "Mkdirs",
|
||||
statusLink: "https://demo.mkdirs.com",
|
||||
} as NotifySubmissionToUserEmailProps;
|
||||
|
||||
export default NotifySubmissionToUserEmail;
|
||||
@@ -0,0 +1,123 @@
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { getBaseUrl } from "@/lib/utils";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import {
|
||||
anchor,
|
||||
box,
|
||||
button,
|
||||
container,
|
||||
footer,
|
||||
footerLeft,
|
||||
footerRight,
|
||||
hr,
|
||||
main,
|
||||
paragraph,
|
||||
} from "./email-formats";
|
||||
|
||||
interface PaymentSuccessEmailProps {
|
||||
userName?: string;
|
||||
itemLink?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* https://demo.react.email/preview/welcome/stripe-welcome
|
||||
*/
|
||||
export const PaymentSuccessEmail = ({
|
||||
userName,
|
||||
itemLink,
|
||||
}: PaymentSuccessEmailProps) => {
|
||||
const baseUrl = getBaseUrl();
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Thanks for your submission</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={box}>
|
||||
<Img
|
||||
src={`${baseUrl}/logo.png`}
|
||||
width="32"
|
||||
height="32"
|
||||
alt="Logo"
|
||||
/>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>Hi {userName},</Text>
|
||||
<Text style={paragraph}>
|
||||
Thanks for submitting your product to{" "}
|
||||
<Link style={anchor} href={baseUrl}>
|
||||
{siteConfig.name}
|
||||
</Link>
|
||||
. We're so excited to include your product in our directory!
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
If you have purchased the sponsor plan, please reply to this email
|
||||
to schedule when your product will be displayed.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Your product has been successfully added to our directory. You can
|
||||
view your listing by clicking the button below:
|
||||
</Text>
|
||||
<Button style={button} href={itemLink}>
|
||||
View your product
|
||||
</Button>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>
|
||||
We appreciate your support and contribution to our community. If
|
||||
you have any questions, please don't hesitate to contact us.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Thank you again for choosing{" "}
|
||||
<Link style={anchor} href={baseUrl}>
|
||||
{siteConfig.name}
|
||||
</Link>
|
||||
. We look forward to helping more people discover your product!
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Thanks, <br />
|
||||
The{" "}
|
||||
<Link style={anchor} href={baseUrl}>
|
||||
{siteConfig.name}
|
||||
</Link>{" "}
|
||||
team
|
||||
</Text>
|
||||
<Hr style={hr} />
|
||||
<Text style={footer}>
|
||||
<span style={footerLeft}>
|
||||
© {new Date().getFullYear()}
|
||||
All Rights Reserved.
|
||||
</span>
|
||||
<span style={footerRight}>
|
||||
<Link style={anchor} href={siteConfig.links.twitter}>
|
||||
Twitter
|
||||
</Link>
|
||||
|
||||
<Link style={anchor} href={siteConfig.links.github}>
|
||||
GitHub
|
||||
</Link>
|
||||
</span>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
PaymentSuccessEmail.PreviewProps = {
|
||||
userName: "Javayhu",
|
||||
itemLink: "https://demo.mkdirs.com",
|
||||
} as PaymentSuccessEmailProps;
|
||||
|
||||
export default PaymentSuccessEmail;
|
||||
@@ -0,0 +1,107 @@
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { getBaseUrl } from "@/lib/utils";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import {
|
||||
anchor,
|
||||
box,
|
||||
button,
|
||||
container,
|
||||
footer,
|
||||
footerLeft,
|
||||
footerRight,
|
||||
hr,
|
||||
main,
|
||||
paragraph,
|
||||
} from "./email-formats";
|
||||
|
||||
interface RejectionEmailProps {
|
||||
userName?: string;
|
||||
dashboardLink?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* https://demo.react.email/preview/welcome/stripe-welcome
|
||||
*/
|
||||
export const RejectionEmail = ({
|
||||
userName,
|
||||
dashboardLink,
|
||||
}: RejectionEmailProps) => {
|
||||
const baseUrl = getBaseUrl();
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Your submission has been rejected</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={box}>
|
||||
<Img
|
||||
src={`${baseUrl}/logo.png`}
|
||||
width="32"
|
||||
height="32"
|
||||
alt="Logo"
|
||||
/>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>Hi {userName},</Text>
|
||||
<Text style={paragraph}>
|
||||
Thanks for submitting your product to{" "}
|
||||
<Link style={anchor} href={baseUrl}>
|
||||
{siteConfig.name}
|
||||
</Link>
|
||||
. We're sorry to inform you that your product has been rejected.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Please check your submission and make the necessary changes.
|
||||
</Text>
|
||||
<Button style={button} href={dashboardLink}>
|
||||
View in dashboard
|
||||
</Button>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>
|
||||
Thanks, <br />
|
||||
The{" "}
|
||||
<Link style={anchor} href={baseUrl}>
|
||||
{siteConfig.name}
|
||||
</Link>{" "}
|
||||
team
|
||||
</Text>
|
||||
<Hr style={hr} />
|
||||
<Text style={footer}>
|
||||
<span style={footerLeft}>
|
||||
© {new Date().getFullYear()}
|
||||
All Rights Reserved.
|
||||
</span>
|
||||
<span style={footerRight}>
|
||||
<Link style={anchor} href={siteConfig.links.twitter}>
|
||||
Twitter
|
||||
</Link>
|
||||
|
||||
<Link style={anchor} href={siteConfig.links.github}>
|
||||
GitHub
|
||||
</Link>
|
||||
</span>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
RejectionEmail.PreviewProps = {
|
||||
userName: "Javayhu",
|
||||
itemLink: "https://demo.mkdirs.com",
|
||||
} as RejectionEmailProps;
|
||||
|
||||
export default RejectionEmail;
|
||||
@@ -0,0 +1,109 @@
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { getBaseUrl } from "@/lib/utils";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import {
|
||||
anchor,
|
||||
box,
|
||||
button,
|
||||
container,
|
||||
footer,
|
||||
footerLeft,
|
||||
footerRight,
|
||||
hr,
|
||||
main,
|
||||
paragraph,
|
||||
} from "./email-formats";
|
||||
|
||||
interface ResetPasswordEmailProps {
|
||||
userName?: string;
|
||||
resetLink?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* https://demo.react.email/preview/welcome/stripe-welcome
|
||||
*/
|
||||
export const ResetPasswordEmail = ({
|
||||
userName,
|
||||
resetLink,
|
||||
}: ResetPasswordEmailProps) => {
|
||||
const baseUrl = getBaseUrl();
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Reset your password</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={box}>
|
||||
<Img
|
||||
src={`${baseUrl}/logo.png`}
|
||||
width="32"
|
||||
height="32"
|
||||
alt="Logo"
|
||||
/>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>Hi {userName},</Text>
|
||||
<Text style={paragraph}>
|
||||
Someone recently requested a password change for your account. If
|
||||
this was you, you can set a new password here:
|
||||
</Text>
|
||||
<Button style={button} href={resetLink}>
|
||||
Reset password
|
||||
</Button>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>
|
||||
If you don't want to change your password or didn't
|
||||
request this, just ignore and delete this message.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
To keep your account secure, please don't forward this email
|
||||
to anyone.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Thanks, <br />
|
||||
The{" "}
|
||||
<Link style={anchor} href={baseUrl}>
|
||||
{siteConfig.name}
|
||||
</Link>{" "}
|
||||
team
|
||||
</Text>
|
||||
<Hr style={hr} />
|
||||
<Text style={footer}>
|
||||
<span style={footerLeft}>
|
||||
© {new Date().getFullYear()}
|
||||
All Rights Reserved.
|
||||
</span>
|
||||
<span style={footerRight}>
|
||||
<Link style={anchor} href={siteConfig.links.twitter}>
|
||||
Twitter
|
||||
</Link>
|
||||
|
||||
<Link style={anchor} href={siteConfig.links.github}>
|
||||
GitHub
|
||||
</Link>
|
||||
</span>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
ResetPasswordEmail.PreviewProps = {
|
||||
userName: "Javayhu",
|
||||
resetLink: "https://demo.mkdirs.com",
|
||||
} as ResetPasswordEmailProps;
|
||||
|
||||
export default ResetPasswordEmail;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { getBaseUrl } from "@/lib/utils";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import {
|
||||
anchor,
|
||||
box,
|
||||
button,
|
||||
container,
|
||||
footer,
|
||||
footerLeft,
|
||||
footerRight,
|
||||
hr,
|
||||
main,
|
||||
paragraph,
|
||||
} from "./email-formats";
|
||||
|
||||
interface VerifyEmailProps {
|
||||
confirmLink?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* https://demo.react.email/preview/welcome/stripe-welcome
|
||||
*/
|
||||
export const VerifyEmail = ({ confirmLink }: VerifyEmailProps) => {
|
||||
const baseUrl = getBaseUrl();
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Confirm your email address</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={box}>
|
||||
<Img
|
||||
src={`${baseUrl}/logo.png`}
|
||||
width="32"
|
||||
height="32"
|
||||
alt="Logo"
|
||||
/>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>Confirm your email address</Text>
|
||||
<Text style={paragraph}>
|
||||
Thanks for starting the new account creation process. We want to
|
||||
make sure it's really you. Please click the confirmation link to
|
||||
continue.
|
||||
</Text>
|
||||
<Button style={button} href={confirmLink}>
|
||||
Confirm Email
|
||||
</Button>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>
|
||||
If you don't want to create an account, you can ignore this
|
||||
message.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Thanks, <br />
|
||||
The{" "}
|
||||
<Link style={anchor} href={baseUrl}>
|
||||
{siteConfig.name}
|
||||
</Link>{" "}
|
||||
team
|
||||
</Text>
|
||||
<Hr style={hr} />
|
||||
<Text style={footer}>
|
||||
<span style={footerLeft}>
|
||||
© {new Date().getFullYear()}
|
||||
All Rights Reserved.
|
||||
</span>
|
||||
<span style={footerRight}>
|
||||
<Link style={anchor} href={siteConfig.links.twitter}>
|
||||
Twitter
|
||||
</Link>
|
||||
|
||||
<Link style={anchor} href={siteConfig.links.github}>
|
||||
GitHub
|
||||
</Link>
|
||||
</span>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
VerifyEmail.PreviewProps = {
|
||||
confirmLink: "https://demo.mkdirs.com",
|
||||
} as VerifyEmailProps;
|
||||
|
||||
export default VerifyEmail;
|
||||
@@ -0,0 +1,52 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
// Configure `pageExtensions` to include markdown and MDX files
|
||||
// https://nextjs.org/docs/pages/building-your-application/configuring/mdx
|
||||
// pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
|
||||
|
||||
// https://nextjs.org/docs/pages/api-reference/next-config-js/reactStrictMode
|
||||
// if enabled, new-verification-form will trigger twice and seems buggy in dev mode,
|
||||
// but it's not a problem in production, so you can try to comment it out and let it go.
|
||||
// reactStrictMode: false,
|
||||
|
||||
// https://nextjs.org/docs/architecture/nextjs-compiler#remove-console
|
||||
// Remove all console.* calls in production only
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
|
||||
images: {
|
||||
// https://vercel.com/docs/image-optimization/managing-image-optimization-costs#minimizing-image-optimization-costs
|
||||
// vercel has limits on image optimization, 1000 images per month
|
||||
unoptimized: true,
|
||||
// https://medium.com/@niniroula/nextjs-upgrade-next-image-and-dangerouslyallowsvg-c934060d79f8
|
||||
// The requested resource "https://cdn.sanity.io/images/58a2mkbj/preview/xxx.svg?fit=max&auto=format" has type "image/svg+xml"
|
||||
// but dangerouslyAllowSVG is disabled
|
||||
dangerouslyAllowSVG: true,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "avatars.githubusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "lh3.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "randomuser.me",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "cdn.sanity.io", // https://www.sanity.io/learn/course/day-one-with-sanity-studio/bringing-content-to-a-next-js-front-end
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "via.placeholder.com", // https://www.sanity.io/learn/course/day-one-with-sanity-studio/bringing-content-to-a-next-js-front-end
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,51 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Configure `pageExtensions` to include markdown and MDX files
|
||||
// https://nextjs.org/docs/pages/building-your-application/configuring/mdx
|
||||
// pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
|
||||
|
||||
// https://nextjs.org/docs/pages/api-reference/next-config-js/reactStrictMode
|
||||
// if enabled, new-verification-form will trigger twice and seems buggy in dev mode,
|
||||
// but it's not a problem in production, so you can try to comment it out and let it go.
|
||||
// reactStrictMode: false,
|
||||
|
||||
// https://nextjs.org/docs/architecture/nextjs-compiler#remove-console
|
||||
// Remove all console.* calls in production only
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
|
||||
images: {
|
||||
// https://vercel.com/docs/image-optimization/managing-image-optimization-costs#minimizing-image-optimization-costs
|
||||
// vercel has limits on image optimization, 1000 images per month
|
||||
unoptimized: true,
|
||||
// https://medium.com/@niniroula/nextjs-upgrade-next-image-and-dangerouslyallowsvg-c934060d79f8
|
||||
// The requested resource "https://cdn.sanity.io/images/58a2mkbj/preview/xxx.svg?fit=max&auto=format" has type "image/svg+xml"
|
||||
// but dangerouslyAllowSVG is disabled
|
||||
dangerouslyAllowSVG: true,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "avatars.githubusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "lh3.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "randomuser.me",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "cdn.sanity.io", // https://www.sanity.io/learn/course/day-one-with-sanity-studio/bringing-content-to-a-next-js-front-end
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "via.placeholder.com", // https://www.sanity.io/learn/course/day-one-with-sanity-studio/bringing-content-to-a-next-js-front-end
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"name": "mkdirs",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "An open-source, AI-powered directory website template built with Next.js",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://mkdirs.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MkdirsHQ/mkdirs.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Mkdirs",
|
||||
"url": "https://github.com/MkdirsHQ"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome check --write .",
|
||||
"lint:fix": "biome check --fix --unsafe .",
|
||||
"format": "biome format --write .",
|
||||
"typegen": "sanity schema extract && sanity typegen generate",
|
||||
"email": "email dev --dir emails --port 3333",
|
||||
"export-user-emails": "tsx scripts/export-user-emails.ts",
|
||||
"microlink:fetch": "tsx scripts/explore-microlink.ts fetch",
|
||||
"microlink:insights": "tsx scripts/explore-microlink.ts insights",
|
||||
"microlink:screenshot": "tsx scripts/explore-microlink.ts screenshot",
|
||||
"microlink:fullpage": "tsx scripts/explore-microlink.ts fullpage",
|
||||
"aisdk:fetch": "tsx scripts/explore-aisdk.ts fetch",
|
||||
"aisdk:structure": "tsx scripts/explore-aisdk.ts structure",
|
||||
"item:remove": "tsx scripts/batch-item.ts remove",
|
||||
"item:import": "tsx scripts/batch-item.ts import",
|
||||
"item:update": "tsx scripts/batch-item.ts update",
|
||||
"item:fetch": "tsx scripts/batch-item.ts fetch",
|
||||
"batch": "tsx scripts/batch-all.ts",
|
||||
"batch:remove": "tsx scripts/batch-all.ts remove",
|
||||
"batch:import": "tsx scripts/batch-all.ts import",
|
||||
"batch:update": "tsx scripts/batch-all.ts update"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/deepseek": "^0.1.0",
|
||||
"@ai-sdk/google": "^1.0.15",
|
||||
"@ai-sdk/openai": "^1.0.18",
|
||||
"@ai-sdk/xai": "^1.2.16",
|
||||
"@auth/core": "^0.34.2",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@microlink/mql": "^0.13.12",
|
||||
"@next/third-parties": "^15.0.3",
|
||||
"@openpanel/nextjs": "^1.0.5",
|
||||
"@openrouter/ai-sdk-provider": "^0.4.6",
|
||||
"@opentelemetry/api": "1.8.0",
|
||||
"@radix-ui/react-accessible-icon": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.1",
|
||||
"@radix-ui/react-context-menu": "^2.2.2",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-hover-card": "^1.1.2",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-menubar": "^1.1.2",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.1",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.2.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@react-email/components": "0.0.25",
|
||||
"@sanity/block-content-to-markdown": "^0.0.5",
|
||||
"@sanity/client": "^6.21.3",
|
||||
"@sanity/code-input": "^4.1.4",
|
||||
"@sanity/dashboard": "^4.0.0",
|
||||
"@sanity/icons": "^3.4.0",
|
||||
"@sanity/image-url": "^1.0.2",
|
||||
"@sanity/preview-url-secret": "^1.6.20",
|
||||
"@sanity/uuid": "^3.0.2",
|
||||
"@sanity/vision": "^3.57.2",
|
||||
"@sanity/visual-editing": "^2.1.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"ai": "^4.0.33",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"easymde": "^2.18.0",
|
||||
"glob": "^11.0.0",
|
||||
"groq": "^3.55.0",
|
||||
"lucide-react": "^0.414.0",
|
||||
"mdast-util-toc": "^7.1.0",
|
||||
"next": "14.2.35",
|
||||
"next-auth": "5.0.0-beta.18",
|
||||
"next-mdx-remote-client": "^1.0.3",
|
||||
"next-sanity": "^9.4.5",
|
||||
"next-themes": "^0.3.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^9.0.8",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-iframe": "^1.8.5",
|
||||
"react-simplemde-editor": "^5.2.0",
|
||||
"resend": "^3.5.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"sanity": "^3.57.2",
|
||||
"sanity-plugin-asset-source-unsplash": "^3.0.1",
|
||||
"sanity-plugin-iframe-pane": "^3.1.6",
|
||||
"sanity-plugin-internationalized-array": "^2.1.0",
|
||||
"sanity-plugin-markdown": "^5.0.0",
|
||||
"sanity-plugin-media": "^2.3.2",
|
||||
"sonner": "^1.5.0",
|
||||
"stripe": "^15.12.0",
|
||||
"styled-components": "^6",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"transliteration": "^2.3.5",
|
||||
"use-debounce": "^10.0.3",
|
||||
"vaul": "^0.9.9",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.3",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"dotenv": "^16.4.7",
|
||||
"html-loader": "^5.1.0",
|
||||
"postcss": "^8.4.41",
|
||||
"react-email": "3.0.1",
|
||||
"rehype": "^13.0.1",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-pretty-code": "^0.13.2",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 749 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 240 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"path": "src/sanity/lib/queries.ts",
|
||||
"generates": "sanity.types.ts",
|
||||
"schema": "schema.json",
|
||||
"overloadClientMethods": true
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { dataset, projectId } from "@/sanity/lib/api";
|
||||
import { defineCliConfig } from "sanity/cli";
|
||||
|
||||
/**
|
||||
* https://www.sanity.io/docs/cli
|
||||
* Build, deploy, init plugin boilerplate, run scripts,
|
||||
* and wrangle datasets and webhooks, all from the command line
|
||||
*/
|
||||
export default defineCliConfig({
|
||||
api: {
|
||||
projectId,
|
||||
dataset
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { CustomMarkdownInput } from '@/sanity/components/custom-markdown-input';
|
||||
import documentNode from '@/sanity/document-node';
|
||||
import { apiVersion, dataset, projectId, studioUrl } from '@/sanity/lib/api';
|
||||
import { singletonPlugin } from '@/sanity/plugins/singleton';
|
||||
import { schemaTypes } from '@/sanity/schemas';
|
||||
import settings from '@/sanity/schemas/documents/settings';
|
||||
import { structure } from '@/sanity/structure';
|
||||
import { codeInput } from '@sanity/code-input';
|
||||
import {
|
||||
dashboardTool,
|
||||
projectInfoWidget,
|
||||
projectUsersWidget,
|
||||
sanityTutorialsWidget
|
||||
} from "@sanity/dashboard";
|
||||
import { visionTool } from '@sanity/vision';
|
||||
import { defineConfig } from 'sanity';
|
||||
import { unsplashImageAsset } from "sanity-plugin-asset-source-unsplash";
|
||||
import { markdownSchema } from "sanity-plugin-markdown";
|
||||
import { media } from 'sanity-plugin-media';
|
||||
import { presentationTool } from 'sanity/presentation';
|
||||
import { structureTool } from 'sanity/structure';
|
||||
import { customDocumentActionsPlugin } from '@/sanity/plugins/customDocumentActions';
|
||||
|
||||
export default defineConfig({
|
||||
name: 'Studio',
|
||||
basePath: studioUrl,
|
||||
projectId,
|
||||
dataset,
|
||||
|
||||
schema: {
|
||||
types: schemaTypes,
|
||||
},
|
||||
|
||||
plugins: [
|
||||
// https://www.sanity.io/docs/structure-tool-api
|
||||
// The Structure Tool is a top-level view within Sanity Studio
|
||||
// where editors can drill down to specific documents to edit them.
|
||||
structureTool({
|
||||
defaultDocumentNode: documentNode,
|
||||
structure: structure(),
|
||||
}),
|
||||
|
||||
// https://www.sanity.io/docs/the-vision-plugin
|
||||
// Vision is a plugin that lets you quickly test your GROQ queries right from the Studio.
|
||||
visionTool({
|
||||
title: 'Query',
|
||||
defaultApiVersion: apiVersion,
|
||||
defaultDataset: dataset,
|
||||
}),
|
||||
|
||||
// https://www.sanity.io/docs/configuring-the-presentation-tool
|
||||
// https://www.sanity.io/docs/draft-mode
|
||||
// The Presentation tool enables Visual Editing for interactive live previews.
|
||||
// demo: https://github.com/javayhu/sanity-press/blob/main/sanity/src/presentation.ts#L4
|
||||
// demo: https://github.com/javayhu/sanity-press/blob/main/sanity/sanity.config.ts#L32
|
||||
presentationTool({
|
||||
previewUrl: { previewMode: { enable: "/api/draft" } },
|
||||
}),
|
||||
|
||||
// https://www.sanity.io/plugins/sanity-plugin-media
|
||||
// A convenient way to browse, manage and select all your Sanity assets.
|
||||
media(),
|
||||
|
||||
// https://www.sanity.io/plugins/sanity-plugin-markdown
|
||||
markdownSchema({ input: CustomMarkdownInput }),
|
||||
|
||||
// https://www.sanity.io/docs/dashboard
|
||||
// Dashboard is a Sanity Studio tool that allows you to add widgets that display information
|
||||
// about your content, project details, or anything else you'd want to put there.
|
||||
dashboardTool({
|
||||
widgets: [
|
||||
projectInfoWidget(),
|
||||
projectUsersWidget(
|
||||
{ layout: { width: 'large' } }
|
||||
),
|
||||
sanityTutorialsWidget()
|
||||
],
|
||||
}),
|
||||
|
||||
// Configures the global "new document" button, and document actions,
|
||||
// hide the "duplicate" action on the Singletons (such as Settings)
|
||||
singletonPlugin([settings.name]),
|
||||
|
||||
// https://www.sanity.io/plugins/code-input
|
||||
// Syntax highlighted editor for code.
|
||||
// if you need codeInput, you need to install @sanity/code-input
|
||||
codeInput(),
|
||||
|
||||
// https://www.sanity.io/plugins/sanity-plugin-asset-source-unsplash
|
||||
// Search photos on Unsplash and insert them directly inside of your Sanity Studio.
|
||||
unsplashImageAsset(),
|
||||
|
||||
// https://www.sanity.io/docs/document-actions
|
||||
customDocumentActionsPlugin()
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
importCategories,
|
||||
removeCategories,
|
||||
updateCategories,
|
||||
} from "./batch-category";
|
||||
import { importGroups, removeGroups, updateGroups } from "./batch-group";
|
||||
import { importItems, removeItems, updateItems } from "./batch-item";
|
||||
import {
|
||||
importItemsMicrolink,
|
||||
removeItemsMicrolink,
|
||||
} from "./batch-item-microlink";
|
||||
import { importTags, removeTags, updateTags } from "./batch-tag";
|
||||
|
||||
/**
|
||||
* Demo: Batch Operations Manager
|
||||
*
|
||||
* A centralized script to manage all batch operations across different entities
|
||||
* in the Sanity database.
|
||||
*
|
||||
* Features:
|
||||
* - Unified data cleanup
|
||||
* - Coordinated imports
|
||||
* - Bulk updates
|
||||
* - Sequential processing
|
||||
*
|
||||
* Available Operations:
|
||||
* 1. removeAll: Remove all data
|
||||
* 2. importAll: Import all data
|
||||
* 3. updateAll: Update all data
|
||||
*
|
||||
* Usage Examples:
|
||||
* ```typescript
|
||||
* // Clean everything
|
||||
* await removeAll();
|
||||
*
|
||||
* // Fresh import
|
||||
* await importAll();
|
||||
*
|
||||
* // Update everything
|
||||
* await updateAll();
|
||||
*
|
||||
* // Custom sequence
|
||||
* await removeAll();
|
||||
* await importGroups();
|
||||
* await importCategories();
|
||||
* await importTags();
|
||||
* await importItems();
|
||||
* ```
|
||||
*/
|
||||
|
||||
export const removeAll = async () => {
|
||||
console.log("Removing all data...");
|
||||
|
||||
try {
|
||||
// Remove in reverse order of dependencies
|
||||
await removeItems();
|
||||
await removeTags();
|
||||
await removeCategories();
|
||||
await removeGroups();
|
||||
|
||||
console.log("All data removed successfully");
|
||||
} catch (error) {
|
||||
console.error("Error removing data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const importAll = async () => {
|
||||
console.log("Importing all data...");
|
||||
|
||||
try {
|
||||
// Import in order of dependencies
|
||||
await importGroups();
|
||||
await importCategories();
|
||||
await importTags();
|
||||
await importItems();
|
||||
|
||||
console.log("All data imported successfully");
|
||||
} catch (error) {
|
||||
console.error("Error importing data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateAll = async () => {
|
||||
console.log("Updating all data...");
|
||||
|
||||
try {
|
||||
await updateGroups();
|
||||
await updateCategories();
|
||||
await updateTags();
|
||||
await updateItems();
|
||||
|
||||
console.log("All data updated successfully");
|
||||
} catch (error) {
|
||||
console.error("Error updating data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// get operation from command line
|
||||
const operation = process.argv[2];
|
||||
|
||||
// run operation based on command line argument
|
||||
const runOperation = async () => {
|
||||
switch (operation) {
|
||||
case "remove":
|
||||
await removeAll();
|
||||
break;
|
||||
case "import":
|
||||
await importAll();
|
||||
break;
|
||||
case "update":
|
||||
await updateAll();
|
||||
break;
|
||||
default:
|
||||
console.log(`
|
||||
Available commands:
|
||||
- remove: Remove all data
|
||||
- import: Import all data
|
||||
- update: Update all data
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
// run operation
|
||||
runOperation().catch(console.error);
|
||||
@@ -0,0 +1,252 @@
|
||||
import { slugify } from "@/lib/utils";
|
||||
import type { Group } from "@/sanity.types";
|
||||
import { createClient } from "@sanity/client";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
// make sure you have set the environment variables in .env file
|
||||
const client = createClient({
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
|
||||
apiVersion: "2024-08-01",
|
||||
useCdn: false,
|
||||
perspective: "published",
|
||||
token: process.env.SANITY_API_TOKEN,
|
||||
});
|
||||
|
||||
/**
|
||||
* Demo: Category Management
|
||||
*
|
||||
* This script manages categories in the Sanity database, including their
|
||||
* relationships with category groups.
|
||||
*
|
||||
* Operations:
|
||||
* 1. Remove all existing categories
|
||||
* 2. Import predefined categories with group associations
|
||||
* 3. (Optional) Update existing categories
|
||||
*
|
||||
* Features:
|
||||
* - Hierarchical category organization
|
||||
* - Priority-based ordering
|
||||
* - Group relationship management
|
||||
* - Automatic slug generation
|
||||
*
|
||||
* Data Structure:
|
||||
* - name: Category display name
|
||||
* - slug: URL-friendly identifier
|
||||
* - description: Detailed category explanation
|
||||
* - priority: The priority of the category, the higher the number, the higher the priority
|
||||
* - group: The group that the category belongs to
|
||||
*
|
||||
* Requirements:
|
||||
* 1. Each category must have a unique priority value
|
||||
* 2. When using groups, group names must exist in the database
|
||||
* 3. Environment variables of Sanity are required in .env file
|
||||
*
|
||||
* Example Usage:
|
||||
* ```
|
||||
* // Remove all categories and import new ones
|
||||
* removeCategories();
|
||||
* importCategories();
|
||||
*
|
||||
* // Update existing categories
|
||||
* updateCategories();
|
||||
* ```
|
||||
*
|
||||
* Note: This script requires proper environment variables in .env
|
||||
*/
|
||||
const data = [
|
||||
// fullstack
|
||||
{
|
||||
priority: 200,
|
||||
name: "NextJS Boilerplates",
|
||||
group: "Fullstack",
|
||||
description: "The nextjs boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 199,
|
||||
name: "Nuxt Boilerplates",
|
||||
group: "Fullstack",
|
||||
description: "The nuxt boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 198,
|
||||
name: "SvelteKit Boilerplates",
|
||||
group: "Fullstack",
|
||||
description: "The sveltekit boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 197,
|
||||
name: "Django Boilerplates",
|
||||
group: "Fullstack",
|
||||
description: "The django boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 196,
|
||||
name: "Express Boilerplates",
|
||||
group: "Fullstack",
|
||||
description: "The express boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 195,
|
||||
name: "NodeJS Boilerplates",
|
||||
group: "Fullstack",
|
||||
description: "The nodejs boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 194,
|
||||
name: "PHP Boilerplates",
|
||||
group: "Fullstack",
|
||||
description: "The php boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 193,
|
||||
name: "Ruby on Rails Boilerplates",
|
||||
group: "Fullstack",
|
||||
description: "The ruby on rails boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 192,
|
||||
name: "Laravel Boilerplates",
|
||||
group: "Fullstack",
|
||||
description: "The laravel boilerplates",
|
||||
},
|
||||
|
||||
// frontend
|
||||
{
|
||||
priority: 150,
|
||||
name: "React Boilerplates",
|
||||
group: "Frontend",
|
||||
description: "The react boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 149,
|
||||
name: "Vue Boilerplates",
|
||||
group: "Frontend",
|
||||
description: "The vue boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 148,
|
||||
name: "Svelte Boilerplates",
|
||||
group: "Frontend",
|
||||
description: "The svelte boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 147,
|
||||
name: "Astro Boilerplates",
|
||||
group: "Frontend",
|
||||
description: "The astro boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 146,
|
||||
name: "Typescript Boilerplates",
|
||||
group: "Frontend",
|
||||
description: "The typescript boilerplates",
|
||||
},
|
||||
|
||||
// mobile
|
||||
{
|
||||
priority: 100,
|
||||
name: "React Native Boilerplates",
|
||||
group: "Mobile",
|
||||
description: "The react native boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 99,
|
||||
name: "Flutter Boilerplates",
|
||||
group: "Mobile",
|
||||
description: "The flutter boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 98,
|
||||
name: "Expo Boilerplates",
|
||||
group: "Mobile",
|
||||
description: "The expo boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 97,
|
||||
name: "Swift Boilerplates",
|
||||
group: "Mobile",
|
||||
description: "The swift boilerplates",
|
||||
},
|
||||
{
|
||||
priority: 96,
|
||||
name: "Kotlin Boilerplates",
|
||||
group: "Mobile",
|
||||
description: "The kotlin boilerplates",
|
||||
},
|
||||
];
|
||||
|
||||
export const removeCategories = async () => {
|
||||
const data = await client.delete({
|
||||
query: "*[_type == 'category']",
|
||||
});
|
||||
console.log("removeCategories:", data);
|
||||
};
|
||||
|
||||
export const importCategories = async () => {
|
||||
try {
|
||||
console.log("importCategories start");
|
||||
|
||||
// get all category groups
|
||||
const groups = await client.fetch<Group[]>(`*[_type == "group"]`);
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const item = data[i];
|
||||
console.log("index: ", i, "item:", item.name);
|
||||
const group = findGroup(groups, item.group);
|
||||
// accept category without group
|
||||
// if (!group) {
|
||||
// console.error(`Group ${item.group} not found`);
|
||||
// continue;
|
||||
// }
|
||||
await client.create({
|
||||
// _id: item._id,
|
||||
// _createdAt: item._createdAt,
|
||||
// _updatedAt: item._updatedAt,
|
||||
_type: "category",
|
||||
name: item.name,
|
||||
slug: {
|
||||
_type: "slug",
|
||||
// current: item.slug,
|
||||
current: slugify(item.name),
|
||||
},
|
||||
description: item.description,
|
||||
priority: item.priority,
|
||||
group: group
|
||||
? {
|
||||
_type: "reference",
|
||||
_ref: group._id,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("importCategories success");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const findGroup = (groups: Group[], name: string) => {
|
||||
return groups.find((group: Group) => group.name === name);
|
||||
};
|
||||
|
||||
export const updateCategories = async () => {
|
||||
const categories = await client.fetch(`*[_type == "category"]`);
|
||||
|
||||
for (const category of categories) {
|
||||
const result = await client
|
||||
.patch(category._id)
|
||||
.set({
|
||||
// do what you want to update here
|
||||
})
|
||||
.commit();
|
||||
|
||||
console.log(`Updated category ${category.name}:`, result);
|
||||
}
|
||||
|
||||
console.log(`Updated ${categories.length} categories`);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { slugify } from "@/lib/utils";
|
||||
import { createClient } from "@sanity/client";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
// make sure you have set the environment variables in .env file
|
||||
const client = createClient({
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
|
||||
apiVersion: "2024-08-01",
|
||||
useCdn: false,
|
||||
perspective: "published",
|
||||
token: process.env.SANITY_API_TOKEN,
|
||||
});
|
||||
|
||||
/**
|
||||
* Demo: Category Group Management
|
||||
*
|
||||
* This script manages category groups in the Sanity.io database.
|
||||
*
|
||||
* Operations:
|
||||
* 1. Remove all existing category groups
|
||||
* 2. Import predefined category groups
|
||||
* 3. (Optional) Update existing groups
|
||||
*
|
||||
* Features:
|
||||
* - Handles basic group metadata (name, slug, description)
|
||||
* - Maintains priority ordering
|
||||
* - Ensures consistent slug generation
|
||||
*
|
||||
* Data Structure:
|
||||
* - name: Group display name
|
||||
* - slug: URL-friendly identifier
|
||||
* - description: Detailed group explanation
|
||||
* - priority: The priority of the group, the higher the number, the higher the priority
|
||||
*
|
||||
* Use Cases:
|
||||
* - Initial system setup
|
||||
* - Category Groups restructuring
|
||||
* - Batch updates to group metadata
|
||||
*
|
||||
* Note: This script requires proper environment variables in .env
|
||||
*/
|
||||
const data = [
|
||||
{ priority: 3, name: "Fullstack", description: "The fullstack boilerplates" },
|
||||
{ priority: 2, name: "Frontend", description: "The frontend boilerplates" },
|
||||
{ priority: 1, name: "Mobile", description: "The mobile boilerplates" },
|
||||
];
|
||||
|
||||
export const removeGroups = async () => {
|
||||
const data = await client.delete({
|
||||
query: "*[_type == 'group']",
|
||||
});
|
||||
console.log("removeGroups:", data);
|
||||
};
|
||||
|
||||
export const importGroups = async () => {
|
||||
try {
|
||||
console.log("importGroups start");
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const item = data[i];
|
||||
console.log("index: ", i, "item:", item.name);
|
||||
await client.create({
|
||||
// _id: item._id,
|
||||
// _createdAt: item._createdAt,
|
||||
// _updatedAt: item._updatedAt,
|
||||
_type: "group",
|
||||
name: item.name,
|
||||
slug: {
|
||||
_type: "slug",
|
||||
// current: item.slug,
|
||||
current: slugify(item.name),
|
||||
},
|
||||
description: item.description,
|
||||
priority: item.priority,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("importGroups success");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateGroups = async () => {
|
||||
const groups = await client.fetch(`*[_type == "group"]`);
|
||||
|
||||
for (const group of groups) {
|
||||
const result = await client
|
||||
.patch(group._id)
|
||||
.set({
|
||||
// do what you want to update here
|
||||
})
|
||||
.commit();
|
||||
|
||||
console.log(`Updated group ${group.name}:`, result);
|
||||
}
|
||||
|
||||
console.log(`Updated ${groups.length} groups`);
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
import { slugify } from "@/lib/utils";
|
||||
import type { Category, Tag } from "@/sanity.types";
|
||||
import { createClient } from "@sanity/client";
|
||||
import dotenv from "dotenv";
|
||||
import fetch from "node-fetch";
|
||||
dotenv.config();
|
||||
|
||||
// make sure you have set the environment variables in .env file
|
||||
const client = createClient({
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
|
||||
apiVersion: "2024-08-01",
|
||||
useCdn: false,
|
||||
perspective: "published",
|
||||
token: process.env.SANITY_API_TOKEN,
|
||||
});
|
||||
|
||||
/**
|
||||
* Demo: Item Management
|
||||
*
|
||||
* A comprehensive script for managing items in the Sanity database
|
||||
* with manual data entry approach.
|
||||
*
|
||||
* Operations:
|
||||
* 1. Remove existing items (cleanup)
|
||||
* 2. Import predefined items
|
||||
* 3. (Optional) Update existing items
|
||||
*
|
||||
* Features:
|
||||
* - Manual data curation
|
||||
* - Asset management (icons, images)
|
||||
* - Category and tag relationships
|
||||
* - Automatic slug generation
|
||||
* - Metadata handling
|
||||
*
|
||||
* Data Structure:
|
||||
* - name: Item display name
|
||||
* - slug: URL-friendly identifier
|
||||
* - description: Detailed item explanation
|
||||
* - link: The link of the item
|
||||
* - categories: The categories that the item belongs to
|
||||
* - tags: The tags that the item belongs to
|
||||
* - image: The image of the item
|
||||
* - icon: The icon of the item
|
||||
*
|
||||
* Advantages:
|
||||
* 1. High data accuracy and quality control
|
||||
* 2. Precise metadata management
|
||||
* 3. Controlled categorization
|
||||
*
|
||||
* Limitations:
|
||||
* 1. Time-intensive manual data entry
|
||||
* 2. Requires regular maintenance
|
||||
* 3. Limited scalability for large datasets
|
||||
* 4. Content of the items are missing
|
||||
*
|
||||
* Use Cases:
|
||||
* 1. Initial platform seeding
|
||||
* 2. Featured content management
|
||||
* 3. Quality-focused collections
|
||||
* 4. Custom showcase items
|
||||
*
|
||||
* Note: This script requires proper environment variables in .env
|
||||
*/
|
||||
const data = [
|
||||
{
|
||||
name: "Mkdirs",
|
||||
description: "The best directory boilerplate",
|
||||
link: "https://mkdirs.com",
|
||||
categories: [
|
||||
"NextJS Boilerplates",
|
||||
"Typescript Boilerplates",
|
||||
"React Boilerplates",
|
||||
],
|
||||
tags: [
|
||||
"NextJS",
|
||||
"Directory",
|
||||
"Tailwind",
|
||||
"React",
|
||||
"TypeScript",
|
||||
"Shadcn",
|
||||
"Resend",
|
||||
"Stripe",
|
||||
"Auth",
|
||||
"Blog",
|
||||
],
|
||||
image: "https://mkdirs.com/og.png",
|
||||
// icon: "https://mkdirs.com/logo.png",
|
||||
icon: "https://s2.googleusercontent.com/s2/favicons?domain=mkdirs.com&sz=128",
|
||||
},
|
||||
{
|
||||
name: "Achromatic",
|
||||
description: "An advanced Next 15 SaaS starter kit",
|
||||
link: "https://achromatic.dev",
|
||||
categories: [
|
||||
"NextJS Boilerplates",
|
||||
"Typescript Boilerplates",
|
||||
"React Boilerplates",
|
||||
],
|
||||
tags: [
|
||||
"NextJS",
|
||||
"Tailwind",
|
||||
"React",
|
||||
"TypeScript",
|
||||
"Shadcn",
|
||||
"Resend",
|
||||
"Stripe",
|
||||
"Auth",
|
||||
"Blog",
|
||||
],
|
||||
image: "https://achromatic.dev/og.jpg",
|
||||
// icon: "https://achromatic.dev/favicon.ico",
|
||||
icon: "https://s2.googleusercontent.com/s2/favicons?domain=achromatic.dev&sz=128",
|
||||
},
|
||||
];
|
||||
|
||||
export const removeItems = async () => {
|
||||
const data = await client.delete({
|
||||
query: "*[_type == 'item']",
|
||||
});
|
||||
console.log("removeItems:", data);
|
||||
};
|
||||
|
||||
export const importItems = async () => {
|
||||
try {
|
||||
console.log("importItems start");
|
||||
|
||||
// get all categories and tags
|
||||
const categories = await client.fetch<Category[]>(`*[_type == "category"]`);
|
||||
const tags = await client.fetch<Tag[]>(`*[_type == "tag"]`);
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const item = data[i];
|
||||
console.log("index: ", i, "item:", item.name);
|
||||
const itemCategories = findCategory(categories, item.categories);
|
||||
const itemTags = findTag(tags, item.tags);
|
||||
|
||||
// Fetch icon and image
|
||||
const [iconResponse, imageResponse] = await Promise.all([
|
||||
fetch(item.icon),
|
||||
fetch(item.image),
|
||||
]);
|
||||
const [iconArrayBuffer, imageArrayBuffer] = await Promise.all([
|
||||
iconResponse.arrayBuffer(),
|
||||
imageResponse.arrayBuffer(),
|
||||
]);
|
||||
|
||||
// Convert ArrayBuffer to Buffer for Sanity upload
|
||||
const [iconBuffer, imageBuffer] = [
|
||||
Buffer.from(iconArrayBuffer),
|
||||
Buffer.from(imageArrayBuffer),
|
||||
];
|
||||
|
||||
// Upload icon and image to sanity
|
||||
const [iconAsset, imageAsset] = await Promise.all([
|
||||
client.assets.upload("image", iconBuffer, {
|
||||
filename: `${slugify(item.name)}_logo.png`,
|
||||
}),
|
||||
client.assets.upload("image", imageBuffer, {
|
||||
filename: `${slugify(item.name)}_image.png`,
|
||||
}),
|
||||
]);
|
||||
|
||||
await client.create({
|
||||
// _id: item._id,
|
||||
// _createdAt: item._createdAt,
|
||||
// _updatedAt: item._updatedAt,
|
||||
_type: "item",
|
||||
name: item.name,
|
||||
slug: {
|
||||
_type: "slug",
|
||||
// current: item.slug,
|
||||
current: slugify(item.name),
|
||||
},
|
||||
link: item.link,
|
||||
description: item.description,
|
||||
publishDate: new Date(),
|
||||
pricePlan: "free",
|
||||
freePlanStatus: "approved",
|
||||
|
||||
// set submitter if you need, change the _ref to your user id
|
||||
// submitter: {
|
||||
// _type: "reference",
|
||||
// _ref: "",
|
||||
// },
|
||||
|
||||
// set categories and tags
|
||||
categories: itemCategories.map((category, index) => ({
|
||||
_type: "reference",
|
||||
_ref: category._id,
|
||||
_key: index.toString(),
|
||||
})),
|
||||
tags: itemTags.map((tag, index) => ({
|
||||
_type: "reference",
|
||||
_ref: tag._id,
|
||||
_key: index.toString(),
|
||||
})),
|
||||
|
||||
// set icon and image
|
||||
icon: {
|
||||
_type: "image",
|
||||
asset: {
|
||||
_type: "reference",
|
||||
_ref: iconAsset._id,
|
||||
},
|
||||
alt: `Logo of ${item.name}`,
|
||||
},
|
||||
image: {
|
||||
_type: "image",
|
||||
asset: {
|
||||
_type: "reference",
|
||||
_ref: imageAsset._id,
|
||||
},
|
||||
alt: `Screenshot of ${item.name}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("importItems success");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const findCategory = (categories: Category[], names: string[]) => {
|
||||
return categories.filter((category: Category) =>
|
||||
names.includes(category.name),
|
||||
);
|
||||
};
|
||||
|
||||
const findTag = (tags: Tag[], names: string[]) => {
|
||||
return tags.filter((tag: Tag) => names.includes(tag.name));
|
||||
};
|
||||
|
||||
export const updateItems = async () => {
|
||||
const items = await client.fetch(`*[_type == "item"]`);
|
||||
|
||||
for (const item of items) {
|
||||
const result = await client
|
||||
.patch(item._id)
|
||||
.set({
|
||||
// do what you want to update here
|
||||
})
|
||||
.commit();
|
||||
|
||||
console.log(`Updated item ${item.name}:`, result);
|
||||
}
|
||||
|
||||
console.log(`Updated ${items.length} items`);
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
import { slugify } from "@/lib/utils";
|
||||
import type { Category, Tag } from "@/sanity.types";
|
||||
import mql from "@microlink/mql";
|
||||
import { createClient } from "@sanity/client";
|
||||
import dotenv from "dotenv";
|
||||
import fetch from "node-fetch";
|
||||
dotenv.config();
|
||||
|
||||
// make sure you have set the environment variables in .env file
|
||||
const client = createClient({
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
|
||||
apiVersion: "2024-08-01",
|
||||
useCdn: false,
|
||||
perspective: "published",
|
||||
token: process.env.SANITY_API_TOKEN,
|
||||
});
|
||||
|
||||
/**
|
||||
* Demo: Automated Item Import Script (with Microlink API)
|
||||
*
|
||||
* A script that leverages Microlink API to automatically fetch and import
|
||||
* items into Sanity database.
|
||||
*
|
||||
* Operations:
|
||||
* 1. Remove existing items (cleanup)
|
||||
* 2. Fetch metadata via Microlink API
|
||||
* 3. Process and import items
|
||||
*
|
||||
* Features:
|
||||
* - Automated metadata extraction
|
||||
* - Bulk URL processing
|
||||
* - Asset auto-discovery
|
||||
* - Sequential processing for reliability
|
||||
*
|
||||
* Data Structure:
|
||||
* - name: Item display name
|
||||
* - slug: URL-friendly identifier
|
||||
* - description: Detailed item explanation
|
||||
* - link: The link of the item
|
||||
* - categories: The categories that the item belongs to
|
||||
* - tags: The tags that the item belongs to
|
||||
* - image: The image of the item
|
||||
* - icon: The icon of the item
|
||||
*
|
||||
* Data Fetched via Microlink:
|
||||
* - title → name
|
||||
* - description
|
||||
* - logo → icon
|
||||
* - image
|
||||
* - url → link
|
||||
*
|
||||
* Advantages:
|
||||
* 1. Automated metadata extraction
|
||||
* 2. Faster data entry process
|
||||
* 3. Consistent data structure
|
||||
* 4. Scalable for large datasets
|
||||
*
|
||||
* Limitations:
|
||||
* 1. Manual category/tag assignment still needed
|
||||
* 2. Variable asset quality (logos/images)
|
||||
* 3. Dependent on Microlink API availability
|
||||
* 4. Content of the items are missing
|
||||
*
|
||||
* Use Cases:
|
||||
* 1. Bulk item imports
|
||||
* 2. Quick prototyping
|
||||
* 3. Initial database population
|
||||
* 4. Regular content updates
|
||||
*
|
||||
* Note: This script requires proper environment variables in .env
|
||||
*/
|
||||
const links = [
|
||||
"https://mkdirs.com",
|
||||
"https://achromatic.dev",
|
||||
// Add more links here...
|
||||
];
|
||||
|
||||
const fetchItem = async (url: string) => {
|
||||
try {
|
||||
const { data } = await mql(url, {
|
||||
data: {
|
||||
title: { selector: "title" },
|
||||
description: { selector: 'meta[name="description"]' },
|
||||
logo: { selector: 'link[rel="icon"]' },
|
||||
image: { selector: 'meta[property="og:image"]' },
|
||||
url: { selector: 'link[rel="canonical"]' },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
name: data.title || new URL(url).hostname,
|
||||
description: data.description || `A great boilerplate from ${url}`,
|
||||
link: url,
|
||||
categories: [
|
||||
"NextJS Boilerplates",
|
||||
"Typescript Boilerplates",
|
||||
"React Boilerplates",
|
||||
],
|
||||
tags: ["NextJS", "Tailwind", "React", "TypeScript", "Shadcn"],
|
||||
image:
|
||||
data.image?.url ||
|
||||
`https://image.thum.io/get/width/1200/crop/800/${url}`,
|
||||
icon:
|
||||
data.logo?.url ||
|
||||
`https://s2.googleusercontent.com/s2/favicons?domain=${new URL(url).hostname}&sz=128`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data for ${url}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeItemsMicrolink = async () => {
|
||||
const data = await client.delete({
|
||||
query: "*[_type == 'item']",
|
||||
});
|
||||
// console.log("removeItems:", data);
|
||||
console.log("removeItems success, length:", data.length);
|
||||
};
|
||||
|
||||
export const importItemsMicrolink = async () => {
|
||||
try {
|
||||
// get all categories and tags first (only need to fetch once)
|
||||
const categories = await client.fetch<Category[]>(`*[_type == "category"]`);
|
||||
const tags = await client.fetch<Tag[]>(`*[_type == "tag"]`);
|
||||
|
||||
console.log("Processing links one by one...");
|
||||
for (const link of links) {
|
||||
try {
|
||||
console.log(`Processing link: ${link}`);
|
||||
const item = await fetchItem(link);
|
||||
|
||||
if (!item) {
|
||||
console.log(`Skipping ${link} due to fetch error`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Creating item for: ${item.name}`);
|
||||
const itemCategories = findCategory(categories, item.categories);
|
||||
const itemTags = findTag(tags, item.tags);
|
||||
|
||||
// Fetch icon and image
|
||||
const [iconResponse, imageResponse] = await Promise.all([
|
||||
fetch(item.icon),
|
||||
fetch(item.image),
|
||||
]);
|
||||
const [iconArrayBuffer, imageArrayBuffer] = await Promise.all([
|
||||
iconResponse.arrayBuffer(),
|
||||
imageResponse.arrayBuffer(),
|
||||
]);
|
||||
|
||||
// Convert ArrayBuffer to Buffer for Sanity upload
|
||||
const [iconBuffer, imageBuffer] = [
|
||||
Buffer.from(iconArrayBuffer),
|
||||
Buffer.from(imageArrayBuffer),
|
||||
];
|
||||
|
||||
// Upload icon and image to sanity
|
||||
const [iconAsset, imageAsset] = await Promise.all([
|
||||
client.assets.upload("image", iconBuffer, {
|
||||
filename: `${slugify(item.name)}_logo.png`,
|
||||
}),
|
||||
client.assets.upload("image", imageBuffer, {
|
||||
filename: `${slugify(item.name)}_image.png`,
|
||||
}),
|
||||
]);
|
||||
|
||||
await client.create({
|
||||
// _id: item._id,
|
||||
// _createdAt: item._createdAt,
|
||||
// _updatedAt: item._updatedAt,
|
||||
_type: "item",
|
||||
name: item.name,
|
||||
slug: {
|
||||
_type: "slug",
|
||||
// current: item.slug,
|
||||
current: slugify(item.name),
|
||||
},
|
||||
link: item.link,
|
||||
description: item.description,
|
||||
publishDate: new Date(),
|
||||
pricePlan: "free",
|
||||
freePlanStatus: "submitting",
|
||||
|
||||
// set submitter if you need, change the _ref to your user id
|
||||
// submitter: {
|
||||
// _type: "reference",
|
||||
// _ref: "",
|
||||
// },
|
||||
|
||||
// set categories and tags
|
||||
categories: itemCategories.map((category, index) => ({
|
||||
_type: "reference",
|
||||
_ref: category._id,
|
||||
_key: index.toString(),
|
||||
})),
|
||||
tags: itemTags.map((tag, index) => ({
|
||||
_type: "reference",
|
||||
_ref: tag._id,
|
||||
_key: index.toString(),
|
||||
})),
|
||||
|
||||
// set icon and image
|
||||
icon: {
|
||||
_type: "image",
|
||||
asset: {
|
||||
_type: "reference",
|
||||
_ref: iconAsset._id,
|
||||
},
|
||||
alt: `Logo of ${item.name}`,
|
||||
},
|
||||
image: {
|
||||
_type: "image",
|
||||
asset: {
|
||||
_type: "reference",
|
||||
_ref: imageAsset._id,
|
||||
},
|
||||
alt: `Screenshot of ${item.name}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error processing link: ${link}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("importItems success");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const findCategory = (categories: Category[], names: string[]) => {
|
||||
return categories.filter((category: Category) =>
|
||||
names.includes(category.name),
|
||||
);
|
||||
};
|
||||
|
||||
const findTag = (tags: Tag[], names: string[]) => {
|
||||
return tags.filter((tag: Tag) => names.includes(tag.name));
|
||||
};
|
||||
@@ -0,0 +1,375 @@
|
||||
import { slugify } from "@/lib/utils";
|
||||
import type { Category, Tag } from "@/sanity.types";
|
||||
import { google } from "@ai-sdk/google";
|
||||
import mql from "@microlink/mql";
|
||||
import { createClient } from "@sanity/client";
|
||||
import { generateObject } from "ai";
|
||||
import dotenv from "dotenv";
|
||||
import fetch from "node-fetch";
|
||||
import { z } from "zod";
|
||||
dotenv.config();
|
||||
|
||||
// make sure you have set the environment variables in .env file
|
||||
const client = createClient({
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
|
||||
apiVersion: "2024-08-01",
|
||||
useCdn: false,
|
||||
perspective: "published",
|
||||
token: process.env.SANITY_API_TOKEN,
|
||||
});
|
||||
|
||||
/**
|
||||
* AI-Powered Item Management System
|
||||
*
|
||||
* An automated script for managing content items in Sanity CMS using AI SDK
|
||||
* and Microlink for data enrichment.
|
||||
*
|
||||
* Core Functions:
|
||||
* 1. Content Scraping: Extract data from URLs using Microlink
|
||||
* 2. AI Analysis: Process content using Google's Gemini AI
|
||||
* 3. Asset Management: Handle images and icons
|
||||
* 4. Database Operations: CRUD operations in Sanity
|
||||
*
|
||||
* Data Processing Flow:
|
||||
* 1. URL → Microlink (metadata) + AI SDK (content analysis)
|
||||
* 2. Data Enrichment → Categories & Tags mapping
|
||||
* 3. Asset Processing → Icon & Image handling
|
||||
* 4. Sanity Import → Structured content creation
|
||||
*
|
||||
* Content Structure:
|
||||
* - name: Title of the item
|
||||
* - slug: Auto-generated URL-friendly identifier
|
||||
* - description: AI-generated short description (160 chars)
|
||||
* - introduction: AI-generated detailed markdown content
|
||||
* - link: Source URL
|
||||
* - categories: Auto-mapped category references
|
||||
* - tags: Auto-mapped tag references
|
||||
* - image: Screenshot or main image
|
||||
* - icon: Favicon or logo
|
||||
*
|
||||
* Key Features:
|
||||
* - AI-powered content analysis
|
||||
* - Automated metadata extraction
|
||||
* - Smart category/tag mapping
|
||||
* - Automated asset management
|
||||
* - Bulk processing support
|
||||
*
|
||||
* Requirements:
|
||||
* - Sanity CMS credentials
|
||||
* - Google AI SDK access
|
||||
* - Microlink API access
|
||||
* - Environment variables configured
|
||||
*
|
||||
* Usage:
|
||||
* 1. pnpm run item remove
|
||||
* remove all items
|
||||
* 2. pnpm run item import
|
||||
* import all items defined in links array
|
||||
* 3. pnpm run item update
|
||||
* update all items
|
||||
* 4. pnpm run item fetch <url>
|
||||
* fetch item info for the specified url
|
||||
*/
|
||||
const links = [
|
||||
"https://mkdirs.com",
|
||||
"https://achromatic.dev",
|
||||
];
|
||||
|
||||
export const removeItems = async () => {
|
||||
const data = await client.delete({
|
||||
query: "*[_type == 'item']",
|
||||
});
|
||||
console.log("removeItems:", data);
|
||||
};
|
||||
|
||||
/**
|
||||
* fetch item info for the specified url with Microlink and AI SDK
|
||||
*/
|
||||
export const fetchItem = async (url: string) => {
|
||||
// step 1: fetch item info with Microlink
|
||||
const microlinkData = await fetchItemWithMicrolink(url);
|
||||
console.log("fetchItem, url:", url, "microlinkData:", microlinkData);
|
||||
|
||||
// step 2: fetch item info with AI SDK
|
||||
const aisdkData = await fetchItemWithAISdk(url);
|
||||
console.log("fetchItem, url:", url, "aisdkData:", aisdkData);
|
||||
|
||||
// step 3: merge the data
|
||||
const mergedData = {
|
||||
link: url,
|
||||
name: aisdkData.object.title,
|
||||
description: aisdkData.object.description,
|
||||
introduction: aisdkData.object.introduction,
|
||||
categories: aisdkData.object.categories,
|
||||
tags: aisdkData.object.tags,
|
||||
image: microlinkData?.image?.url, // or get a new screenshot
|
||||
icon: `https://s2.googleusercontent.com/s2/favicons?domain=${url}&sz=128`, // or microlinkData?.logo?.url
|
||||
};
|
||||
console.log("fetchItem, url:", url, "mergedData:", mergedData);
|
||||
return mergedData;
|
||||
};
|
||||
|
||||
/**
|
||||
* fetch item info for the specified url with Microlink
|
||||
*/
|
||||
export const fetchItemWithMicrolink = async (url: string) => {
|
||||
try {
|
||||
const { data } = await mql(url, {
|
||||
// information included:
|
||||
// - title: page title
|
||||
// - description: page description
|
||||
// - lang: page language
|
||||
// - author: author information
|
||||
// - publisher: publisher information
|
||||
// - image: main image
|
||||
// - logo: website logo
|
||||
// - url: normalized URL
|
||||
});
|
||||
// console.log("fetchItemWithMicrolink, url:", url, "data:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`fetchItemWithMicrolink, Error processing url for ${url}:`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* fetch item info for the specified url with AI SDK
|
||||
*/
|
||||
export const fetchItemWithAISdk = async (url: string) => {
|
||||
try {
|
||||
// get all categories and tags
|
||||
const categories = await client.fetch<Category[]>(`*[_type == "category"]`);
|
||||
const tags = await client.fetch<Tag[]>(`*[_type == "tag"]`);
|
||||
const availableCategories = categories.map((cat) => cat.name);
|
||||
const availableTags = tags.map((tag) => tag.name);
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().describe("A short, concise name without description"),
|
||||
description: z
|
||||
.string()
|
||||
.max(160)
|
||||
.describe("One sentence summary, max 160 characters"),
|
||||
introduction: z
|
||||
.string()
|
||||
.describe("Detailed introduction in markdown format"),
|
||||
categories: z
|
||||
.array(z.string())
|
||||
.describe("Array of category names that best match the content"),
|
||||
tags: z
|
||||
.array(z.string())
|
||||
.describe("Array of tag names that best match the content")
|
||||
});
|
||||
|
||||
const response = await fetch(url);
|
||||
const htmlContent = (await response.text())
|
||||
.replace(/class="[^"]*"/g, '')
|
||||
.replace(/<svg[^>]*>.*?<\/svg>/g, '')
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
const result = await generateObject({
|
||||
model: google("gemini-2.0-flash-exp", {
|
||||
structuredOutputs: true,
|
||||
}),
|
||||
schema,
|
||||
prompt: `Analyze the following webpage content and provide structured information:
|
||||
|
||||
Content to analyze:
|
||||
${htmlContent}
|
||||
|
||||
Available Categories:
|
||||
${availableCategories.join(", ")}
|
||||
|
||||
Available Tags:
|
||||
${availableTags.join(", ")}
|
||||
|
||||
Please analyze the content and provide:
|
||||
1. A concise title (just the name, no description)
|
||||
2. A brief description (one sentence, max 160 characters)
|
||||
3. A detailed introduction in markdown format (include key features and use cases)
|
||||
4. Select appropriate categories from the available categories list (return array of names)
|
||||
5. Select relevant tags from the available tags list (return array of names)
|
||||
|
||||
Focus on technical aspects and practical applications. If the content is a tool or service,
|
||||
emphasize its main features, target users, and unique selling points.`,
|
||||
});
|
||||
|
||||
// console.log("fetchItemWithAISdk, url:", url, "result:", result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`fetchItemWithAISdk, Error processing url for ${url}:`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const importItems = async () => {
|
||||
try {
|
||||
console.log("importItems start");
|
||||
|
||||
// get all categories and tags
|
||||
const categories = await client.fetch<Category[]>(`*[_type == "category"]`);
|
||||
const tags = await client.fetch<Tag[]>(`*[_type == "tag"]`);
|
||||
|
||||
for (let i = 0; i < links.length; i++) {
|
||||
const item = await fetchItem(links[i]);
|
||||
console.log("index: ", i, ", url: ", links[i], ", item:", item.name);
|
||||
|
||||
const itemCategories = findCategory(categories, item.categories);
|
||||
const itemTags = findTag(tags, item.tags);
|
||||
|
||||
// Fetch icon and image
|
||||
const [iconResponse, imageResponse] = await Promise.all([
|
||||
fetch(item.icon),
|
||||
fetch(item.image),
|
||||
]);
|
||||
const [iconArrayBuffer, imageArrayBuffer] = await Promise.all([
|
||||
iconResponse.arrayBuffer(),
|
||||
imageResponse.arrayBuffer(),
|
||||
]);
|
||||
|
||||
// Convert ArrayBuffer to Buffer for Sanity upload
|
||||
const [iconBuffer, imageBuffer] = [
|
||||
Buffer.from(iconArrayBuffer),
|
||||
Buffer.from(imageArrayBuffer),
|
||||
];
|
||||
|
||||
// Upload icon and image to sanity
|
||||
const [iconAsset, imageAsset] = await Promise.all([
|
||||
client.assets.upload("image", iconBuffer, {
|
||||
filename: `${slugify(item.name)}_logo.png`,
|
||||
}),
|
||||
client.assets.upload("image", imageBuffer, {
|
||||
filename: `${slugify(item.name)}_image.png`,
|
||||
}),
|
||||
]);
|
||||
|
||||
await client.create({
|
||||
// _id: item._id,
|
||||
// _createdAt: item._createdAt,
|
||||
// _updatedAt: item._updatedAt,
|
||||
_type: "item",
|
||||
name: item.name,
|
||||
slug: {
|
||||
_type: "slug",
|
||||
current: slugify(item.name),
|
||||
},
|
||||
link: item.link,
|
||||
description: item.description,
|
||||
publishDate: new Date(),
|
||||
pricePlan: "free",
|
||||
freePlanStatus: "approved",
|
||||
|
||||
introduction: item.introduction,
|
||||
|
||||
// set submitter if you need, change the _ref to your user id
|
||||
// submitter: {
|
||||
// _type: "reference",
|
||||
// _ref: "",
|
||||
// },
|
||||
|
||||
// set categories and tags
|
||||
categories: itemCategories.map((category, index) => ({
|
||||
_type: "reference",
|
||||
_ref: category._id,
|
||||
_key: index.toString(),
|
||||
})),
|
||||
tags: itemTags.map((tag, index) => ({
|
||||
_type: "reference",
|
||||
_ref: tag._id,
|
||||
_key: index.toString(),
|
||||
})),
|
||||
|
||||
// set icon and image
|
||||
icon: {
|
||||
_type: "image",
|
||||
asset: {
|
||||
_type: "reference",
|
||||
_ref: iconAsset._id,
|
||||
},
|
||||
alt: `Logo of ${item.name}`,
|
||||
},
|
||||
image: {
|
||||
_type: "image",
|
||||
asset: {
|
||||
_type: "reference",
|
||||
_ref: imageAsset._id,
|
||||
},
|
||||
alt: `Screenshot of ${item.name}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("importItems success");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const findCategory = (categories: Category[], names: string[]) => {
|
||||
return categories.filter((category: Category) =>
|
||||
names.includes(category.name),
|
||||
);
|
||||
};
|
||||
|
||||
const findTag = (tags: Tag[], names: string[]) => {
|
||||
return tags.filter((tag: Tag) => names.includes(tag.name));
|
||||
};
|
||||
|
||||
export const updateItems = async () => {
|
||||
const items = await client.fetch(`*[_type == "item"]`);
|
||||
|
||||
for (const item of items) {
|
||||
const result = await client
|
||||
.patch(item._id)
|
||||
.set({
|
||||
// do what you want to update here
|
||||
})
|
||||
.commit();
|
||||
|
||||
console.log(`Updated item ${item.name}:`, result);
|
||||
}
|
||||
|
||||
console.log(`Updated ${items.length} items`);
|
||||
};
|
||||
|
||||
// get operation from command line
|
||||
const operation = process.argv[2];
|
||||
// get url from command line
|
||||
const url = process.argv[3];
|
||||
|
||||
// run operation based on command line argument
|
||||
const runOperation = async () => {
|
||||
switch (operation) {
|
||||
case "remove":
|
||||
await removeItems();
|
||||
break;
|
||||
case "import":
|
||||
await importItems();
|
||||
break;
|
||||
case "update":
|
||||
await updateItems();
|
||||
break;
|
||||
case "fetch":
|
||||
await fetchItem(url);
|
||||
break;
|
||||
default:
|
||||
console.log(`
|
||||
Available commands:
|
||||
- remove: Remove all items
|
||||
- import: Import all items
|
||||
- update: Update all items
|
||||
- fetch<url>: Fetch item info for the specified url with AI SDK and Microlink and return structured data
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
// run operation
|
||||
runOperation().catch(console.error);
|
||||
@@ -0,0 +1,345 @@
|
||||
import { slugify } from "@/lib/utils";
|
||||
import { createClient } from "@sanity/client";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
// make sure you have set the environment variables in .env file
|
||||
const client = createClient({
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
|
||||
apiVersion: "2024-08-01",
|
||||
useCdn: false,
|
||||
perspective: "published",
|
||||
token: process.env.SANITY_API_TOKEN,
|
||||
});
|
||||
|
||||
/**
|
||||
* Demo: Tag Management
|
||||
*
|
||||
* This script manages the tag system in Sanity database.
|
||||
*
|
||||
* Operations:
|
||||
* 1. Remove all existing tags
|
||||
* 2. Import predefined tags
|
||||
* 3. (Optional) Update existing tags
|
||||
*
|
||||
* Features:
|
||||
* - Automatic slug generation from tag names
|
||||
* - Descriptive metadata for each tag
|
||||
* - Batch processing capabilities
|
||||
*
|
||||
* Data Structure:
|
||||
* - name: Tag display name
|
||||
* - slug: URL-friendly identifier
|
||||
* - description: Detailed tag explanation
|
||||
*
|
||||
* Example Usage:
|
||||
* ```
|
||||
* // Clean and import
|
||||
* removeTags();
|
||||
* importTags();
|
||||
*
|
||||
* // Update existing tags
|
||||
* updateTags();
|
||||
* ```
|
||||
*
|
||||
* Note: This script requires proper environment variables in .env
|
||||
*/
|
||||
const data = [
|
||||
// You can use Cursor to help you generate the data
|
||||
{
|
||||
name: "Open Source",
|
||||
description: "The boilerplates which are open source",
|
||||
},
|
||||
{
|
||||
name: "Auth",
|
||||
description: "The boilerplates with authentication functionality",
|
||||
},
|
||||
{ name: "AI", description: "The boilerplates with AI integration" },
|
||||
{
|
||||
name: "Admin Panel",
|
||||
description: "The boilerplates with admin panel functionality",
|
||||
},
|
||||
{
|
||||
name: "Stripe",
|
||||
description: "The boilerplates with Stripe payment integration",
|
||||
},
|
||||
{
|
||||
name: "Database",
|
||||
description: "The boilerplates with database integration",
|
||||
},
|
||||
{
|
||||
name: "Directory",
|
||||
description: "The boilerplates with directory structure",
|
||||
},
|
||||
{ name: "Blog", description: "The boilerplates with blogging functionality" },
|
||||
{
|
||||
name: "Analytics",
|
||||
description: "The boilerplates with analytics integration",
|
||||
},
|
||||
{
|
||||
name: "CMS",
|
||||
description: "The boilerplates with content management system",
|
||||
},
|
||||
{
|
||||
name: "Billing",
|
||||
description: "The boilerplates with billing functionality",
|
||||
},
|
||||
{ name: "Themes", description: "The boilerplates with theming support" },
|
||||
{
|
||||
name: "Subscription",
|
||||
description: "The boilerplates with subscription functionality",
|
||||
},
|
||||
{
|
||||
name: "Microservices",
|
||||
description: "The boilerplates with microservices architecture",
|
||||
},
|
||||
{ name: "Payments", description: "The boilerplates with payment processing" },
|
||||
{ name: "Email", description: "The boilerplates with email functionality" },
|
||||
{
|
||||
name: "Deployment",
|
||||
description: "The boilerplates with deployment configurations",
|
||||
},
|
||||
{
|
||||
name: "Animations",
|
||||
description: "The boilerplates with animation features",
|
||||
},
|
||||
{
|
||||
name: "Checkout",
|
||||
description: "The boilerplates with checkout functionality",
|
||||
},
|
||||
{ name: "Ads", description: "The boilerplates with advertising integration" },
|
||||
{
|
||||
name: "Components",
|
||||
description: "The boilerplates with reusable components",
|
||||
},
|
||||
{ name: "SEO", description: "The boilerplates with SEO optimization" },
|
||||
{ name: "Storage", description: "The boilerplates with storage solutions" },
|
||||
{
|
||||
name: "Onboarding",
|
||||
description: "The boilerplates with user onboarding flow",
|
||||
},
|
||||
{
|
||||
name: "Dashboard",
|
||||
description: "The boilerplates with dashboard interface",
|
||||
},
|
||||
{ name: "Login", description: "The boilerplates with login functionality" },
|
||||
{
|
||||
name: "Documentation",
|
||||
description: "The boilerplates with documentation setup",
|
||||
},
|
||||
{
|
||||
name: "Lemon Squeezy",
|
||||
description: "The boilerplates with Lemon Squeezy integration",
|
||||
},
|
||||
{
|
||||
name: "Landing Page",
|
||||
description: "The boilerplates with landing page templates",
|
||||
},
|
||||
{ name: "CI/CD", description: "The boilerplates with CI/CD pipeline setup" },
|
||||
{
|
||||
name: "Navigation",
|
||||
description: "The boilerplates with navigation components",
|
||||
},
|
||||
{ name: "UI/UX", description: "The boilerplates with UI/UX design system" },
|
||||
{
|
||||
name: "Multi-tenancy",
|
||||
description: "The boilerplates with multi-tenant architecture",
|
||||
},
|
||||
{ name: "Tables", description: "The boilerplates with table components" },
|
||||
{ name: "Support", description: "The boilerplates with support system" },
|
||||
{
|
||||
name: "Paywalls",
|
||||
description: "The boilerplates with paywall functionality",
|
||||
},
|
||||
{
|
||||
name: "Customizable",
|
||||
description: "The boilerplates with customization options",
|
||||
},
|
||||
{
|
||||
name: "Referral Tracking",
|
||||
description: "The boilerplates with referral tracking system",
|
||||
},
|
||||
{
|
||||
name: "Responsive",
|
||||
description: "The boilerplates with responsive design",
|
||||
},
|
||||
{ name: "Plugins", description: "The boilerplates with plugin system" },
|
||||
{ name: "Style", description: "The boilerplates with styling system" },
|
||||
{
|
||||
name: "Notifications",
|
||||
description: "The boilerplates with notification system",
|
||||
},
|
||||
{
|
||||
name: "Paddle",
|
||||
description: "The boilerplates with Paddle payment integration",
|
||||
},
|
||||
{
|
||||
name: "Waitlist",
|
||||
description: "The boilerplates with waitlist functionality",
|
||||
},
|
||||
{
|
||||
name: "Community",
|
||||
description: "The boilerplates with community features",
|
||||
},
|
||||
{ name: "Updates", description: "The boilerplates with update system" },
|
||||
{ name: "Dark Mode", description: "The boilerplates with dark mode support" },
|
||||
{
|
||||
name: "Lambda Architecture",
|
||||
description: "The boilerplates with Lambda architecture",
|
||||
},
|
||||
{
|
||||
name: "Integrations",
|
||||
description: "The boilerplates with third-party integrations",
|
||||
},
|
||||
{ name: "Resend", description: "The boilerplates with Resend email service" },
|
||||
{
|
||||
name: "Settings",
|
||||
description: "The boilerplates with settings management",
|
||||
},
|
||||
{
|
||||
name: "2FA",
|
||||
description: "The boilerplates with two-factor authentication",
|
||||
},
|
||||
{
|
||||
name: "Revenue Cat",
|
||||
description: "The boilerplates with Revenue Cat integration",
|
||||
},
|
||||
{
|
||||
name: "In-app purchases",
|
||||
description: "The boilerplates with in-app purchase functionality",
|
||||
},
|
||||
{
|
||||
name: "NextJS",
|
||||
description: "The boilerplates built with Next.js framework",
|
||||
},
|
||||
{ name: "React", description: "The boilerplates built with React library" },
|
||||
{
|
||||
name: "Tailwind",
|
||||
description: "The boilerplates using Tailwind CSS framework",
|
||||
},
|
||||
{
|
||||
name: "Django",
|
||||
description: "The boilerplates built with Django framework",
|
||||
},
|
||||
{
|
||||
name: "Laravel",
|
||||
description: "The boilerplates built with Laravel framework",
|
||||
},
|
||||
{
|
||||
name: "Supabase",
|
||||
description: "The boilerplates integrated with Supabase",
|
||||
},
|
||||
{ name: "TypeScript", description: "The boilerplates using TypeScript" },
|
||||
{
|
||||
name: "Shadcn",
|
||||
description: "The boilerplates using Shadcn UI components",
|
||||
},
|
||||
{ name: "NodeJS", description: "The boilerplates built with Node.js" },
|
||||
{ name: "PHP", description: "The boilerplates using PHP" },
|
||||
{
|
||||
name: "React Native",
|
||||
description: "The boilerplates built with React Native",
|
||||
},
|
||||
{ name: "Python", description: "The boilerplates using Python" },
|
||||
{ name: "HTMX", description: "The boilerplates using HTMX" },
|
||||
{ name: "Flutter", description: "The boilerplates built with Flutter" },
|
||||
{ name: "Vue", description: "The boilerplates built with Vue.js" },
|
||||
{
|
||||
name: "Ruby on Rails",
|
||||
description: "The boilerplates built with Ruby on Rails",
|
||||
},
|
||||
{ name: "Astro", description: "The boilerplates built with Astro framework" },
|
||||
{
|
||||
name: "Nuxt",
|
||||
description: "The boilerplates built with Nuxt.js framework",
|
||||
},
|
||||
{ name: "Kotlin", description: "The boilerplates using Kotlin" },
|
||||
{ name: "Figma", description: "The boilerplates with Figma design assets" },
|
||||
{ name: "Express", description: "The boilerplates built with Express.js" },
|
||||
{
|
||||
name: "PostgreSQL",
|
||||
description: "The boilerplates using PostgreSQL database",
|
||||
},
|
||||
{ name: "Mailgun", description: "The boilerplates integrated with Mailgun" },
|
||||
{ name: "Remix", description: "The boilerplates built with Remix framework" },
|
||||
{ name: "Expo", description: "The boilerplates built with Expo" },
|
||||
{ name: "SwiftUI", description: "The boilerplates built with SwiftUI" },
|
||||
{ name: "SvelteKit", description: "The boilerplates built with SvelteKit" },
|
||||
{ name: "CSS", description: "The boilerplates with CSS styling" },
|
||||
{ name: "HTML", description: "The boilerplates using HTML" },
|
||||
{ name: "DaisyUI", description: "The boilerplates using DaisyUI components" },
|
||||
{
|
||||
name: "Strapi",
|
||||
description: "The boilerplates integrated with Strapi CMS",
|
||||
},
|
||||
{ name: "Svelte", description: "The boilerplates built with Svelte" },
|
||||
{ name: "Javascript", description: "The boilerplates using JavaScript" },
|
||||
{ name: "Chakra", description: "The boilerplates using Chakra UI" },
|
||||
{
|
||||
name: "Firebase",
|
||||
description: "The boilerplates integrated with Firebase",
|
||||
},
|
||||
{ name: "Livewire", description: "The boilerplates using Laravel Livewire" },
|
||||
{ name: "Prisma", description: "The boilerplates using Prisma ORM" },
|
||||
{
|
||||
name: "Pocketbase",
|
||||
description: "The boilerplates integrated with Pocketbase",
|
||||
},
|
||||
{ name: "MongoDB", description: "The boilerplates using MongoDB database" },
|
||||
{ name: "Fly.io", description: "The boilerplates deployed on Fly.io" },
|
||||
];
|
||||
|
||||
export const removeTags = async () => {
|
||||
const data = await client.delete({
|
||||
query: "*[_type == 'tag']",
|
||||
});
|
||||
console.log("removeTags:", data);
|
||||
};
|
||||
|
||||
export const importTags = async () => {
|
||||
try {
|
||||
console.log("importTags start");
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const item = data[i];
|
||||
console.log("index: ", i, "item:", item.name);
|
||||
await client.create({
|
||||
// _id: item._id,
|
||||
// _createdAt: item._createdAt,
|
||||
// _updatedAt: item._updatedAt,
|
||||
_type: "tag",
|
||||
name: item.name,
|
||||
slug: {
|
||||
_type: "slug",
|
||||
// current: item.slug,
|
||||
current: slugify(item.name),
|
||||
},
|
||||
description: item.description,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("importTags success");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTags = async () => {
|
||||
const tags = await client.fetch(`*[_type == "tag"]`);
|
||||
|
||||
for (const tag of tags) {
|
||||
const result = await client
|
||||
.patch(tag._id)
|
||||
.set({
|
||||
// do what you want to update here
|
||||
})
|
||||
.commit();
|
||||
|
||||
console.log(`Updated tag ${tag.name}:`, result);
|
||||
}
|
||||
|
||||
console.log(`Updated ${tags.length} tags`);
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { Category, Tag } from "@/sanity.types";
|
||||
import { google } from "@ai-sdk/google";
|
||||
import { createClient } from "@sanity/client";
|
||||
import { generateObject, generateText } from "ai";
|
||||
import dotenv from "dotenv";
|
||||
import { z } from "zod";
|
||||
dotenv.config();
|
||||
|
||||
// make sure you have set the environment variables in .env file
|
||||
const client = createClient({
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
|
||||
apiVersion: "2024-08-01",
|
||||
useCdn: false,
|
||||
perspective: "published",
|
||||
token: process.env.SANITY_API_TOKEN,
|
||||
});
|
||||
|
||||
/**
|
||||
* AI SDK API Integration (using Google Gemini)
|
||||
*
|
||||
* This module provides functions to interact with the AI SDK,
|
||||
* allowing you to fetch metadata from a given URL.
|
||||
*
|
||||
* Available functions:
|
||||
* - aisdkFetch: Fetch data from AI SDK for the specified message
|
||||
*/
|
||||
|
||||
// https://sdk.vercel.ai/docs/foundations/overview
|
||||
// https://sdk.vercel.ai/docs/getting-started/nodejs
|
||||
// test successfully: pnpm run aisdk:fetch "https://mkdirs.com"
|
||||
export const aisdkFetch = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const htmlContent = (await response.text())
|
||||
.replace(/class="[^"]*"/g, '')
|
||||
.replace(/<svg[^>]*>.*?<\/svg>/g, '')
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
|
||||
const prompt = `Analyze the following webpage content and URL: ${url}
|
||||
|
||||
Webpage content:
|
||||
${htmlContent}
|
||||
|
||||
Based on the above content, provide the following information in a structured format:
|
||||
1. Title of the page (extract from content if possible, just return the short name, no description)
|
||||
2. Brief description (1 sentence summarizing the main content, no more than 160 characters)
|
||||
3. Detailed introduction (in markdown format, include the key features and prices of the product)
|
||||
|
||||
Format your response as follows:
|
||||
{
|
||||
"title": "...",
|
||||
"description": "...",
|
||||
"introduction": "... (in markdown)"
|
||||
}`;
|
||||
|
||||
const { text } = await generateText({
|
||||
model: google("gemini-2.0-flash-exp"),
|
||||
prompt: prompt,
|
||||
});
|
||||
|
||||
console.log("aisdkFetch, url:", url, "response:", text);
|
||||
return text;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data for URL ${url}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// https://sdk.vercel.ai/providers/ai-sdk-providers/google-generative-ai#schema-limitations
|
||||
// test successfully: pnpm run aisdk:structure "https://mkdirs.com"
|
||||
export const aisdkStructure = async (url: string) => {
|
||||
try {
|
||||
// 获取实际的分类和标签数据
|
||||
const categories = await client.fetch<Category[]>(`*[_type == "category"]`);
|
||||
const tags = await client.fetch<Tag[]>(`*[_type == "tag"]`);
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().describe("A short, concise name without description"),
|
||||
description: z
|
||||
.string()
|
||||
.max(160)
|
||||
.describe("One sentence summary, max 160 characters"),
|
||||
introduction: z
|
||||
.string()
|
||||
.describe("Detailed introduction in markdown format"),
|
||||
});
|
||||
|
||||
const response = await fetch(url);
|
||||
const htmlContent = (await response.text())
|
||||
.replace(/class="[^"]*"/g, '')
|
||||
.replace(/<svg[^>]*>.*?<\/svg>/g, '')
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
|
||||
const result = await generateObject({
|
||||
model: google("gemini-2.0-flash-exp", {
|
||||
structuredOutputs: true,
|
||||
}),
|
||||
schema,
|
||||
prompt: `Analyze the following content and provide structured information:
|
||||
|
||||
Content to analyze:
|
||||
${htmlContent}
|
||||
|
||||
Please provide a concise title, brief description, and detailed introduction.
|
||||
The introduction should be in markdown format, include the key features and prices of the product.`,
|
||||
});
|
||||
|
||||
console.log(
|
||||
"aisdkStructure, url:",
|
||||
url,
|
||||
"structured response:",
|
||||
result,
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error processing url for ${url}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// get operation from command line
|
||||
const operation = process.argv[2];
|
||||
// get message from command line (new)
|
||||
const url = process.argv[3];
|
||||
|
||||
// run operation based on command line argument
|
||||
const runOperation = async () => {
|
||||
switch (operation) {
|
||||
case "fetch": {
|
||||
if (!url) {
|
||||
console.error("Please provide a url as the second argument");
|
||||
return;
|
||||
}
|
||||
const result = await aisdkFetch(url);
|
||||
console.log("aisdkFetch, url:", url, "result:", result);
|
||||
break;
|
||||
}
|
||||
|
||||
case "structure": {
|
||||
if (!url) {
|
||||
console.error("Please provide a url as the second argument");
|
||||
return;
|
||||
}
|
||||
const result = await aisdkStructure(url);
|
||||
console.log("aisdkStructure, url:", url, "result:", result);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log(`
|
||||
Available commands:
|
||||
- fetch <url>: Fetch data for the specified url
|
||||
- structure <url>: Fetch data for the specified url and return structured data
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
// run operation
|
||||
runOperation().catch(console.error);
|
||||
@@ -0,0 +1,138 @@
|
||||
import mql from "@microlink/mql";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
/**
|
||||
* Microlink API Integration
|
||||
*
|
||||
* This module provides functions to interact with the Microlink API,
|
||||
* allowing you to fetch metadata and screenshots from a given URL.
|
||||
*
|
||||
* Available functions:
|
||||
* - microlinkFetch: Fetches metadata such as title, description, logo, etc.
|
||||
* - microlinkInsights: Fetches insights such as technologies, languages, etc.
|
||||
* - microlinkScreenshot: Captures a screenshot of the webpage.
|
||||
* - microlinkFullpage: Captures a fullpage screenshot of the webpage.
|
||||
*/
|
||||
|
||||
// https://microlink.io/docs/api/getting-started/overview
|
||||
// test successfully: pnpm run microlink:fetch https://mkdirs.com
|
||||
export const microlinkFetch = async (url: string) => {
|
||||
try {
|
||||
const { data } = await mql(url, {
|
||||
// information included:
|
||||
// - title: page title
|
||||
// - description: page description
|
||||
// - lang: page language
|
||||
// - author: author information
|
||||
// - publisher: publisher information
|
||||
// - image: main image
|
||||
// - logo: website logo
|
||||
// - url: normalized URL
|
||||
});
|
||||
console.log("microlinkFetch, url:", url, "data:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data for ${url}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// https://microlink.io/docs/api/parameters/insights/technologies
|
||||
// test successfully: pnpm run microlink:insights https://mkdirs.com
|
||||
export const microlinkInsights = async (url: string) => {
|
||||
try {
|
||||
const { data } = await mql(url, {
|
||||
insights: {
|
||||
technologies: true,
|
||||
},
|
||||
});
|
||||
console.log("microlinkInsights, url:", url, "data:", JSON.stringify(data, null, 2));
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data for ${url}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// https://microlink.io/docs/api/parameters/screenshot
|
||||
// test successfully: pnpm run microlink:screenshot https://mkdirs.com
|
||||
export const microlinkScreenshot = async (url: string) => {
|
||||
try {
|
||||
const { data } = await mql(url, {
|
||||
screenshot: true,
|
||||
});
|
||||
console.log("microlinkScreenshot, url:", url, "data:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data for ${url}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// https://microlink.io/docs/api/parameters/screenshot
|
||||
// test failed: pnpm run microlink:fullpage https://mkdirs.com
|
||||
export const microlinkFullpage = async (url: string) => {
|
||||
try {
|
||||
const { data } = await mql(url, {
|
||||
screenshot: {
|
||||
fullPage: true,
|
||||
},
|
||||
});
|
||||
console.log("microlinkFullpage, url:", url, "data:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data for ${url}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// get operation from command line
|
||||
const operation = process.argv[2];
|
||||
// get url from command line (new)
|
||||
const url = process.argv[3];
|
||||
|
||||
// run operation based on command line argument
|
||||
const runOperation = async () => {
|
||||
switch (operation) {
|
||||
case "fetch":
|
||||
if (!url) {
|
||||
console.error("Please provide a URL as the second argument");
|
||||
return;
|
||||
}
|
||||
await microlinkFetch(url);
|
||||
break;
|
||||
case "insights":
|
||||
if (!url) {
|
||||
console.error("Please provide a URL as the second argument");
|
||||
return;
|
||||
}
|
||||
await microlinkInsights(url);
|
||||
break;
|
||||
case "screenshot":
|
||||
if (!url) {
|
||||
console.error("Please provide a URL as the second argument");
|
||||
return;
|
||||
}
|
||||
await microlinkScreenshot(url);
|
||||
break;
|
||||
case "fullpage":
|
||||
if (!url) {
|
||||
console.error("Please provide a URL as the second argument");
|
||||
return;
|
||||
}
|
||||
await microlinkFullpage(url);
|
||||
break;
|
||||
default:
|
||||
console.log(`
|
||||
Available commands:
|
||||
- fetch <url>: Fetch data from Microlink API for the specified URL
|
||||
- insights <url>: Fetch insights from Microlink API for the specified URL
|
||||
- screenshot <url>: Fetch screenshot from Microlink API for the specified URL
|
||||
- fullpage <url>: Fetch fullpage screenshot from Microlink API for the specified URL
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
// run operation
|
||||
runOperation().catch(console.error);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createClient } from '@sanity/client';
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
const client = createClient({
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
|
||||
apiVersion: '2024-08-01',
|
||||
useCdn: false,
|
||||
perspective: 'published',
|
||||
token: process.env.SANITY_API_TOKEN,
|
||||
});
|
||||
|
||||
async function exportUserEmails() {
|
||||
try {
|
||||
const query = `*[_type == "user" && defined(email)].email`;
|
||||
const emails: string[] = await client.fetch(query);
|
||||
const emailString = emails.filter(Boolean).join(', ');
|
||||
console.log('User emails:');
|
||||
console.log(emailString);
|
||||
console.log(`\nTotal: ${emails.length} email addresses`);
|
||||
} catch (error) {
|
||||
console.error('Error fetching emails:', error);
|
||||
}
|
||||
}
|
||||
|
||||
exportUserEmails();
|
||||
@@ -0,0 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { currentRole } from "@/lib/auth";
|
||||
import { UserRole } from "@/types/user-role";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* demostrate how to use currentRole to check user's role,
|
||||
* and return different responses according to different roles.
|
||||
*/
|
||||
export async function admin(): Promise<ServerActionResponse> {
|
||||
const role = await currentRole();
|
||||
|
||||
if (role === UserRole.ADMIN) {
|
||||
return { status: "success", message: "Allowed Server Action!" };
|
||||
}
|
||||
|
||||
return { status: "error", message: "Forbidden Server Action!" };
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use server";
|
||||
|
||||
import { getUserById } from "@/data/user";
|
||||
import { currentUser } from "@/lib/auth";
|
||||
import { stripe } from "@/lib/stripe";
|
||||
import { absoluteUrl } from "@/lib/utils";
|
||||
import { sanityClient } from "@/sanity/lib/client";
|
||||
import { sanityFetch } from "@/sanity/lib/fetch";
|
||||
import { itemByIdQuery } from "@/sanity/lib/queries";
|
||||
import type { ItemInfo } from "@/types";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
stripeUrl?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* https://github.com/javayhu/lms-studio-antonio/blob/main/app/api/courses/%5BcourseId%5D/checkout/route.ts
|
||||
*/
|
||||
export async function createCheckoutSession(
|
||||
itemId: string,
|
||||
priceId: string,
|
||||
pricePlan: string,
|
||||
): Promise<ServerActionResponse> {
|
||||
let redirectUrl = "";
|
||||
|
||||
try {
|
||||
const user = await currentUser();
|
||||
if (!user || !user.email || !user.id) {
|
||||
return { status: "error", message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const item = await sanityFetch<ItemInfo>({
|
||||
query: itemByIdQuery,
|
||||
params: { id: itemId },
|
||||
});
|
||||
if (!item) {
|
||||
return { status: "error", message: "Item not found!" };
|
||||
}
|
||||
|
||||
// 1. get user's stripeCustomerId
|
||||
const sanityUser = await getUserById(user.id);
|
||||
if (item.submitter._id !== user.id) {
|
||||
return { status: "error", message: "You are not allowed to do this!" };
|
||||
}
|
||||
let stripeCustomerId = sanityUser?.stripeCustomerId;
|
||||
// console.log('stripeCustomerId:', stripeCustomerId);
|
||||
|
||||
// 2. if the item is paid and the submitter is the user, then redirect to the billing portal
|
||||
if (stripeCustomerId && item.paid) {
|
||||
console.log("item is paid, redirect to billing portal");
|
||||
const billingUrl = absoluteUrl("/dashboard");
|
||||
const stripeSession = await stripe.billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
return_url: billingUrl,
|
||||
});
|
||||
redirectUrl = stripeSession.url as string;
|
||||
// console.log('stripe billing portal session created, url:', redirectUrl);
|
||||
} else {
|
||||
// 3. make sure the user has a stripeCustomerId
|
||||
console.log("item is not paid, redirect to stripe checkout session");
|
||||
if (!stripeCustomerId) {
|
||||
console.log("creating customer in Stripe and Sanity");
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
});
|
||||
if (!customer) {
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to create customer in Stripe",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await sanityClient
|
||||
.patch(user.id)
|
||||
.set({
|
||||
stripeCustomerId: customer.id,
|
||||
})
|
||||
.commit();
|
||||
if (!result) {
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to save customer in Sanity",
|
||||
};
|
||||
}
|
||||
stripeCustomerId = customer.id;
|
||||
}
|
||||
|
||||
// 4. create stripe checkout session
|
||||
console.log(
|
||||
"Creating Stripe checkout session:",
|
||||
{
|
||||
customerId: stripeCustomerId,
|
||||
priceId,
|
||||
userId: user.id,
|
||||
itemId,
|
||||
}
|
||||
);
|
||||
// TODO: optimize the success and cancel urls with sessionId!!!
|
||||
const successUrl = absoluteUrl(`/publish/${itemId}?pay=success`);
|
||||
const cancelUrl = absoluteUrl(`/payment/${itemId}?pay=failed`);
|
||||
const stripeSession = await stripe.checkout.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
mode: "payment",
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
itemId: itemId,
|
||||
priceId: priceId,
|
||||
pricePlan: pricePlan,
|
||||
},
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
// do not limit to card, allow other payment methods
|
||||
// payment_method_types: ["card"],
|
||||
billing_address_collection: "auto",
|
||||
// allow promotion codes if you need
|
||||
allow_promotion_codes: true,
|
||||
});
|
||||
|
||||
redirectUrl = stripeSession.url as string;
|
||||
console.log("stripe checkout session created, url:", redirectUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to generate stripe checkout session",
|
||||
};
|
||||
}
|
||||
|
||||
// 5. redirect to new url, no revalidatePath because redirect
|
||||
redirect(redirectUrl);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use server";
|
||||
|
||||
import { getItemById } from "@/data/item";
|
||||
import { currentUser } from "@/lib/auth";
|
||||
import type { SUPPORT_ITEM_ICON } from "@/lib/constants";
|
||||
import { sendNotifySubmissionEmail } from "@/lib/mail";
|
||||
import { EditSchema } from "@/lib/schemas";
|
||||
import { FreePlanStatus, PricePlans } from "@/lib/submission";
|
||||
import {
|
||||
getItemLinkInStudio,
|
||||
getItemStatusLinkInWebsite,
|
||||
slugify,
|
||||
} from "@/lib/utils";
|
||||
import { sanityClient } from "@/sanity/lib/client";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
// biome-ignore format: conditional type
|
||||
// biome-ignore lint/complexity/noBannedTypes: support item icon
|
||||
// type IconField = typeof SUPPORT_ITEM_ICON extends true ? { iconId: string } : {};
|
||||
|
||||
// export type EditFormData = {
|
||||
// id: string;
|
||||
// name: string;
|
||||
// link: string;
|
||||
// description: string;
|
||||
// introduction: string;
|
||||
// tags: string[];
|
||||
// categories: string[];
|
||||
// imageId: string;
|
||||
// pricePlan: string;
|
||||
// planStatus: string;
|
||||
// } & IconField;
|
||||
|
||||
type BaseEditFormData = {
|
||||
id: string;
|
||||
name: string;
|
||||
link: string;
|
||||
description: string;
|
||||
introduction: string;
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
imageId: string;
|
||||
pricePlan: string;
|
||||
planStatus: string;
|
||||
};
|
||||
|
||||
export type EditFormData = typeof SUPPORT_ITEM_ICON extends true
|
||||
? BaseEditFormData & { iconId: string }
|
||||
: BaseEditFormData;
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* https://nextjs.org/learn/dashboard-app/mutating-data
|
||||
*/
|
||||
export async function edit(
|
||||
formData: EditFormData,
|
||||
): Promise<ServerActionResponse> {
|
||||
try {
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
return { status: "error", message: "Unauthorized" };
|
||||
}
|
||||
console.log("edit, user:", user);
|
||||
|
||||
// console.log("edit, data:", formData);
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
link,
|
||||
description,
|
||||
introduction,
|
||||
imageId,
|
||||
tags,
|
||||
categories,
|
||||
pricePlan,
|
||||
planStatus,
|
||||
...rest
|
||||
} = EditSchema.parse(formData);
|
||||
const iconId = "iconId" in rest ? rest.iconId : undefined;
|
||||
console.log("edit, name:", name, "link:", link);
|
||||
|
||||
// check if the user is the submitter of the item
|
||||
const item = await getItemById(id);
|
||||
if (!item) {
|
||||
return { status: "error", message: "Item not found!" };
|
||||
}
|
||||
if (item.submitter._ref !== user.id) {
|
||||
return { status: "error", message: "You are not allowed to do this!" };
|
||||
}
|
||||
|
||||
const slug = slugify(name);
|
||||
const data = {
|
||||
_id: id,
|
||||
_type: "item",
|
||||
name,
|
||||
slug: {
|
||||
_type: "slug",
|
||||
current: slug,
|
||||
},
|
||||
link,
|
||||
description,
|
||||
introduction,
|
||||
|
||||
// Free plan: update item leads to be unpublished and reviewed again
|
||||
// remain submitted if the plan status is submitted, otherwise set to pending
|
||||
...(pricePlan === PricePlans.FREE && {
|
||||
publishDate: null,
|
||||
freePlanStatus:
|
||||
planStatus === FreePlanStatus.SUBMITTING
|
||||
? FreePlanStatus.SUBMITTING
|
||||
: FreePlanStatus.PENDING,
|
||||
}),
|
||||
|
||||
// The _key only needs to be unique within the array itself, use index as the _key
|
||||
tags: tags.map((tag, index) => ({
|
||||
_type: "reference",
|
||||
_ref: tag,
|
||||
_key: index.toString(),
|
||||
})),
|
||||
categories: categories.map((category, index) => ({
|
||||
_type: "reference",
|
||||
_ref: category,
|
||||
_key: index.toString(),
|
||||
})),
|
||||
image: {
|
||||
_type: "image",
|
||||
alt: `image of ${name}`,
|
||||
asset: {
|
||||
_type: "reference",
|
||||
_ref: imageId,
|
||||
},
|
||||
},
|
||||
icon: iconId
|
||||
? {
|
||||
_type: "image",
|
||||
alt: `icon of ${name}`,
|
||||
asset: {
|
||||
_type: "reference",
|
||||
_ref: iconId,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// console.log("edit, data:", data);
|
||||
const res = await sanityClient.patch(id).set(data).commit();
|
||||
if (!res) {
|
||||
console.log("edit, fail");
|
||||
return { status: "error", message: "Failed to update item" };
|
||||
}
|
||||
// console.log("edit, success, res:", res);
|
||||
|
||||
// send notify email to admin and user
|
||||
if (pricePlan === PricePlans.FREE) {
|
||||
const statusLink = getItemStatusLinkInWebsite(id);
|
||||
const reviewLink = getItemLinkInStudio(id);
|
||||
sendNotifySubmissionEmail(
|
||||
user.name,
|
||||
user.email,
|
||||
name,
|
||||
statusLink,
|
||||
reviewLink,
|
||||
);
|
||||
}
|
||||
|
||||
// Next.js has a Client-side Router Cache that stores the route segments in the user's browser for a time.
|
||||
// Along with prefetching, this cache ensures that users can quickly navigate between routes
|
||||
// while reducing the number of requests made to the server.
|
||||
// Since you're updating the data displayed in the invoices route, you want to clear this cache and trigger a new request to the server.
|
||||
// You can do this with the revalidatePath function from Next.js.
|
||||
|
||||
// redirect to the updated item, but not working, still showing the old item
|
||||
revalidatePath(`/edit/${id}`);
|
||||
revalidatePath(`/item/${slug}`);
|
||||
return { status: "success", message: "Successfully updated item" };
|
||||
} catch (error) {
|
||||
console.log("edit, error", error);
|
||||
return { status: "error", message: "Failed to update item" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
"use server";
|
||||
|
||||
import { slugify } from "@/lib/utils";
|
||||
import type { Category, Tag } from "@/sanity.types";
|
||||
import { sanityClient } from "@/sanity/lib/client";
|
||||
import { deepseek } from "@ai-sdk/deepseek";
|
||||
import { google } from "@ai-sdk/google";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { xai } from "@ai-sdk/xai";
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Website info schema
|
||||
*
|
||||
* 1. when AI generates the image and icon, it will return the image URL and icon URL
|
||||
* 2. when the image and icon are uploaded to Sanity, the image reference Id and icon reference Id will be set to the image and icon
|
||||
* 3. when this server action is called, it will return the image url and icon url, and the image reference Id and icon reference Id
|
||||
*/
|
||||
const WebsiteInfoSchema = z.object({
|
||||
name: z.string().describe("A short, concise name without description"),
|
||||
description: z
|
||||
.string()
|
||||
.max(160)
|
||||
.describe("One sentence summary, max 160 characters"),
|
||||
introduction: z.string().describe("Detailed introduction in markdown format"),
|
||||
categories: z
|
||||
.array(z.string())
|
||||
.describe("Array of category names that best match the content"),
|
||||
tags: z
|
||||
.array(z.string())
|
||||
.describe("Array of tag names that best match the content"),
|
||||
image: z.string().describe("Website screenshot image URL"),
|
||||
icon: z.string().describe("Website logo image URL"),
|
||||
imageId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Website screenshot image reference Id"),
|
||||
iconId: z.string().optional().describe("Website logo image reference Id"),
|
||||
});
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
data?: z.infer<typeof WebsiteInfoSchema>;
|
||||
};
|
||||
|
||||
/**
|
||||
* fetch info for the specified url
|
||||
*/
|
||||
export async function fetchWebsite(url: string): Promise<ServerActionResponse> {
|
||||
try {
|
||||
if (!process.env.DEFAULT_AI_PROVIDER) {
|
||||
return {
|
||||
status: "error",
|
||||
message: "AI submit is not supported",
|
||||
};
|
||||
}
|
||||
|
||||
const data = await fetchWebsiteInfo(url);
|
||||
if (data === null) {
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to fetch website info",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "success",
|
||||
message: "Successfully fetched website info",
|
||||
data,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("fetchWebsite, fetch website:", url, "error:", error);
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to fetch website info",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch website info for the specified url with Microlink and AI SDK
|
||||
*/
|
||||
export const fetchWebsiteInfo = async (url: string) => {
|
||||
const fetchedData = await fetchWebsiteInfoWithAI(url);
|
||||
if (fetchedData === null) {
|
||||
console.error("fetchWebsiteInfo, url:", url, "fetchedData is null");
|
||||
return null;
|
||||
}
|
||||
console.log("fetchWebsiteInfo, url:", url, "fetchedData:", fetchedData);
|
||||
|
||||
const websiteInfo = WebsiteInfoSchema.parse({
|
||||
name: fetchedData.object.name,
|
||||
description: fetchedData.object.description,
|
||||
introduction: fetchedData.object.introduction,
|
||||
categories: fetchedData.object.categories,
|
||||
tags: fetchedData.object.tags,
|
||||
// image: microlinkData?.image?.url, // maybe we can use Microlink to get the screenshot as fallback
|
||||
image: fetchedData.object.image,
|
||||
// icon: microlinkData?.logo?.url, // maybe we can use Microlink to get the logo as fallback
|
||||
// TODO: we don't use favicon URL, instead, we use Google API to get the logo
|
||||
icon: `https://s2.googleusercontent.com/s2/favicons?domain=${url}&sz=128`,
|
||||
});
|
||||
console.log("fetchWebsiteInfo, url:", url, "websiteInfo:", websiteInfo);
|
||||
|
||||
if (websiteInfo.image && websiteInfo.icon) {
|
||||
// Fetch icon and image
|
||||
const [iconResponse, imageResponse] = await Promise.all([
|
||||
fetch(websiteInfo.icon),
|
||||
fetch(websiteInfo.image),
|
||||
]);
|
||||
|
||||
console.log("fetchWebsiteInfo, start fetching icon and image");
|
||||
const [iconArrayBuffer, imageArrayBuffer] = await Promise.all([
|
||||
iconResponse.arrayBuffer(),
|
||||
imageResponse.arrayBuffer(),
|
||||
]);
|
||||
|
||||
// Convert ArrayBuffer to Buffer for Sanity upload
|
||||
const [iconBuffer, imageBuffer] = [
|
||||
Buffer.from(iconArrayBuffer),
|
||||
Buffer.from(imageArrayBuffer),
|
||||
];
|
||||
|
||||
console.log("fetchWebsiteInfo, start uploading icon and image to sanity");
|
||||
// Upload icon and image to sanity
|
||||
const [iconAsset, imageAsset] = await Promise.all([
|
||||
sanityClient.assets.upload("image", iconBuffer, {
|
||||
filename: `${slugify(websiteInfo.name)}_logo.png`,
|
||||
}),
|
||||
sanityClient.assets.upload("image", imageBuffer, {
|
||||
filename: `${slugify(websiteInfo.name)}_image.png`,
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log("fetchWebsiteInfo, url:", url, "iconAsset:", iconAsset);
|
||||
console.log("fetchWebsiteInfo, url:", url, "imageAsset:", imageAsset);
|
||||
|
||||
websiteInfo.icon = iconAsset.url;
|
||||
websiteInfo.image = imageAsset.url;
|
||||
websiteInfo.iconId = iconAsset._id;
|
||||
websiteInfo.imageId = imageAsset._id;
|
||||
console.log("fetchWebsiteInfo, url:", url, "websiteInfo:", websiteInfo);
|
||||
}
|
||||
|
||||
return websiteInfo;
|
||||
};
|
||||
|
||||
/**
|
||||
* fetch website info for the specified url with AI SDK
|
||||
*/
|
||||
export const fetchWebsiteInfoWithAI = async (url: string) => {
|
||||
try {
|
||||
// get all categories and tags
|
||||
const categories = await sanityClient.fetch<Category[]>(
|
||||
`*[_type == "category"]`,
|
||||
);
|
||||
const tags = await sanityClient.fetch<Tag[]>(`*[_type == "tag"]`);
|
||||
const availableCategories = categories.map((cat) => cat.name);
|
||||
const availableTags = tags.map((tag) => tag.name);
|
||||
|
||||
const response = await fetch(url);
|
||||
// TODO: we need to convert htmlContent to simple content for AI to analyze (save time and cost)
|
||||
// TODO: if the content is too long, error will be thrown, so sometimes AI submit will fail
|
||||
// Google Gemini model support more tokens than DeepSeek model, so we prefer Google Gemini model
|
||||
// Thanks to Justin3go for the original implementation.
|
||||
const htmlContent = (await response.text())
|
||||
.replace(/class="[^"]*"/g, "")
|
||||
.replace(/<svg[^>]*>.*?<\/svg>/g, "")
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "");
|
||||
|
||||
let aiModel = null;
|
||||
if (
|
||||
process.env.DEFAULT_AI_PROVIDER === "google" &&
|
||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY !== undefined
|
||||
) {
|
||||
// https://ai-sdk.dev/providers/ai-sdk-providers/google-generative-ai
|
||||
aiModel = google("gemini-2.0-flash-exp", {
|
||||
structuredOutputs: true,
|
||||
});
|
||||
} else if (
|
||||
process.env.DEFAULT_AI_PROVIDER === "deepseek" &&
|
||||
process.env.DEEPSEEK_API_KEY !== undefined
|
||||
) {
|
||||
// https://ai-sdk.dev/providers/ai-sdk-providers/deepseek
|
||||
aiModel = deepseek("deepseek-chat", {
|
||||
// structuredOutputs: true,
|
||||
});
|
||||
} else if (
|
||||
process.env.DEFAULT_AI_PROVIDER === "openai" &&
|
||||
process.env.OPENAI_API_KEY !== undefined
|
||||
) {
|
||||
// https://ai-sdk.dev/providers/ai-sdk-providers/openai
|
||||
aiModel = openai("gpt-4o-mini", {
|
||||
structuredOutputs: true,
|
||||
});
|
||||
} else if (
|
||||
process.env.DEFAULT_AI_PROVIDER === "xai" &&
|
||||
process.env.XAI_API_KEY !== undefined
|
||||
) {
|
||||
// https://ai-sdk.dev/providers/ai-sdk-providers/xai
|
||||
aiModel = xai("grok-3", {
|
||||
// structuredOutputs: true,
|
||||
});
|
||||
} else if (
|
||||
process.env.DEFAULT_AI_PROVIDER === "openrouter" &&
|
||||
process.env.OPENROUTER_API_KEY !== undefined &&
|
||||
process.env.OPENROUTER_MODEL !== undefined
|
||||
) {
|
||||
// https://ai-sdk.dev/providers/community-providers/openrouter
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey: process.env.OPENROUTER_API_KEY,
|
||||
});
|
||||
aiModel = openrouter(process.env.OPENROUTER_MODEL);
|
||||
}
|
||||
|
||||
if (aiModel === null) {
|
||||
return null;
|
||||
}
|
||||
console.log("fetchWebsiteInfoWithAI, aiModel:", aiModel);
|
||||
|
||||
const result = await generateObject({
|
||||
model: aiModel,
|
||||
schema: WebsiteInfoSchema,
|
||||
prompt: `Analyze the following webpage content and provide structured information:
|
||||
|
||||
Content to analyze:
|
||||
${htmlContent}
|
||||
|
||||
Available Categories:
|
||||
${availableCategories.join(", ")}
|
||||
|
||||
Available Tags:
|
||||
${availableTags.join(", ")}
|
||||
|
||||
Please analyze the content and provide:
|
||||
1. A concise title (just the name, no description)
|
||||
2. A brief description (one sentence, max 160 characters)
|
||||
3. A detailed introduction in markdown format (include key features and use cases)
|
||||
4. Select appropriate categories from the available categories list (return array of names)
|
||||
5. Select relevant tags from the available tags list (return array of names)
|
||||
6. Provide a screenshot image full URL, choose open graph image URL if possible
|
||||
7. Provide a website logo full URL, choose website favicon URL if possible
|
||||
|
||||
Focus on technical aspects and practical applications. If the content is a tool or service,
|
||||
emphasize its main features, target users, and unique selling points.`,
|
||||
});
|
||||
|
||||
// console.log("fetchWebsiteInfoWithAI, url:", url, "result:", result);
|
||||
|
||||
// filter AI generated categories and tags to make sure they are in the available categories and tags
|
||||
const filteredCategories = result.object.categories.filter((category) =>
|
||||
availableCategories.includes(category),
|
||||
);
|
||||
const filteredTags = result.object.tags.filter((tag) =>
|
||||
availableTags.includes(tag),
|
||||
);
|
||||
result.object.categories = filteredCategories;
|
||||
result.object.tags = filteredTags;
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`fetchWebsiteInfoWithAI, error processing url for ${url}:`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
"use server";
|
||||
|
||||
import { getItemTargetLinkInWebsite } from "@/lib/utils";
|
||||
import type { SponsorItemListQueryResult } from "@/sanity.types";
|
||||
import { sanityFetch } from "@/sanity/lib/fetch";
|
||||
import { sponsorItemListQuery } from "@/sanity/lib/queries";
|
||||
|
||||
export type BannerAdData = {
|
||||
content: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
data?: BannerAdData;
|
||||
};
|
||||
|
||||
export async function getBannerAd(): Promise<ServerActionResponse> {
|
||||
try {
|
||||
const result = await sanityFetch<SponsorItemListQueryResult>({
|
||||
query: sponsorItemListQuery,
|
||||
});
|
||||
|
||||
// console.log("getBannerAd, result", result);
|
||||
|
||||
// we only show the first sponsor item as banner ad
|
||||
if (result && result.length > 0) {
|
||||
return {
|
||||
status: "success",
|
||||
message: "Banner ad fetched successfully!",
|
||||
data: {
|
||||
content: `🎉 ${result[0].name}: ${result[0].description}`,
|
||||
url: getItemTargetLinkInWebsite(result[0]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
message: "no banner ad!",
|
||||
data: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log("getBannerAd, error", error);
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to fetch banner ad!",
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use server";
|
||||
|
||||
import { signIn } from "@/auth";
|
||||
import { getUserByEmail } from "@/data/user";
|
||||
import { sendVerificationEmail } from "@/lib/mail";
|
||||
import { LoginSchema } from "@/lib/schemas";
|
||||
import { generateVerificationToken } from "@/lib/tokens";
|
||||
import { DEFAULT_LOGIN_REDIRECT } from "@/routes";
|
||||
import { AuthError } from "next-auth";
|
||||
import type * as z from "zod";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
redirectUrl?: string;
|
||||
};
|
||||
|
||||
export async function login(
|
||||
values: z.infer<typeof LoginSchema>,
|
||||
callbackUrl?: string | null,
|
||||
): Promise<ServerActionResponse> {
|
||||
const validatedFields = LoginSchema.safeParse(values);
|
||||
if (!validatedFields.success) {
|
||||
return { status: "error", message: "Invalid fields!" };
|
||||
}
|
||||
|
||||
const { email, password } = validatedFields.data;
|
||||
const existingUser = await getUserByEmail(email);
|
||||
if (!existingUser || !existingUser.email || !existingUser.password) {
|
||||
return { status: "error", message: "User does not exist!" };
|
||||
}
|
||||
|
||||
if (!existingUser.emailVerified) {
|
||||
const verificationToken = await generateVerificationToken(
|
||||
existingUser.email,
|
||||
);
|
||||
await sendVerificationEmail(
|
||||
verificationToken.identifier,
|
||||
verificationToken.token,
|
||||
);
|
||||
return {
|
||||
status: "success",
|
||||
message: "Please check your email for verification",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("login, start signIn");
|
||||
// https://youtu.be/1MTyCvS05V4?t=9828
|
||||
await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
redirectTo: callbackUrl || DEFAULT_LOGIN_REDIRECT,
|
||||
});
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
message: "Login success",
|
||||
redirectUrl: callbackUrl || DEFAULT_LOGIN_REDIRECT,
|
||||
};
|
||||
} catch (error) {
|
||||
// console.error("login, error:", error);
|
||||
if (error instanceof AuthError) {
|
||||
switch (error.type) {
|
||||
case "CredentialsSignin":
|
||||
return { status: "error", message: "Invalid credentials!" };
|
||||
default:
|
||||
return { status: "error", message: "Something went wrong!" };
|
||||
}
|
||||
}
|
||||
return { status: "error", message: "Something went wrong!" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { signOut } from "@/auth";
|
||||
|
||||
export const logout = async () => {
|
||||
await signOut();
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
"use server";
|
||||
|
||||
import { getPasswordResetTokenByToken } from "@/data/password-reset-token";
|
||||
import { getUserByEmail } from "@/data/user";
|
||||
import { NewPasswordSchema } from "@/lib/schemas";
|
||||
import { sanityClient } from "@/sanity/lib/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
import type * as z from "zod";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function newPassword(
|
||||
values: z.infer<typeof NewPasswordSchema>,
|
||||
token?: string | null,
|
||||
): Promise<ServerActionResponse> {
|
||||
// console.log('newPassword, token:', token);
|
||||
if (!token) {
|
||||
return { status: "error", message: "Missing token!" };
|
||||
}
|
||||
|
||||
const validatedFields = NewPasswordSchema.safeParse(values);
|
||||
if (!validatedFields.success) {
|
||||
return { status: "error", message: "Invalid fields!" };
|
||||
}
|
||||
|
||||
const { password } = validatedFields.data;
|
||||
|
||||
const existingToken = await getPasswordResetTokenByToken(token);
|
||||
if (!existingToken) {
|
||||
return { status: "error", message: "Invalid token!" };
|
||||
}
|
||||
|
||||
const hasExpired = new Date(existingToken.expires) < new Date();
|
||||
if (hasExpired) {
|
||||
return { status: "error", message: "Token has expired!" };
|
||||
}
|
||||
|
||||
const existingUser = await getUserByEmail(existingToken.identifier);
|
||||
if (!existingUser) {
|
||||
return { status: "error", message: "Email does not exist!" };
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
await sanityClient
|
||||
.patch(existingUser._id)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
})
|
||||
.commit();
|
||||
|
||||
await sanityClient.delete(existingToken._id);
|
||||
return { status: "success", message: "Password updated!" };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use server";
|
||||
|
||||
import { getUserByEmail } from "@/data/user";
|
||||
import { getVerificationTokenByToken } from "@/data/verification-token";
|
||||
import { sanityClient } from "@/sanity/lib/client";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function newVerification(
|
||||
token: string,
|
||||
): Promise<ServerActionResponse> {
|
||||
const existingToken = await getVerificationTokenByToken(token);
|
||||
if (!existingToken) {
|
||||
return { status: "error", message: "Token does not exist!" };
|
||||
}
|
||||
|
||||
const hasExpired = new Date(existingToken.expires) < new Date();
|
||||
if (hasExpired) {
|
||||
return { status: "error", message: "Token has expired!" };
|
||||
}
|
||||
|
||||
const existingUser = await getUserByEmail(existingToken.identifier);
|
||||
if (!existingUser) {
|
||||
return { status: "error", message: "Email does not exist!" };
|
||||
}
|
||||
|
||||
await sanityClient
|
||||
.patch(existingUser._id)
|
||||
.set({
|
||||
emailVerified: new Date().toISOString(),
|
||||
email: existingToken.identifier,
|
||||
})
|
||||
.commit();
|
||||
console.log("Email verified:", existingUser.email);
|
||||
|
||||
await sanityClient.delete(existingToken._id);
|
||||
return { status: "success", message: "Email verified!" };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import { currentUser } from "@/lib/auth";
|
||||
import { stripe } from "@/lib/stripe";
|
||||
import { absoluteUrl } from "@/lib/utils";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
stripeUrl?: string;
|
||||
};
|
||||
|
||||
const billingUrl = absoluteUrl("/dashboard");
|
||||
|
||||
/**
|
||||
* NOTICE: not used in the app yet
|
||||
*/
|
||||
export async function openCustomerPortal(
|
||||
stripeCustomerId: string,
|
||||
): Promise<ServerActionResponse> {
|
||||
let redirectUrl = "";
|
||||
|
||||
try {
|
||||
const user = await currentUser();
|
||||
if (!user || !user.email) {
|
||||
return { status: "error", message: "Unauthorized" };
|
||||
}
|
||||
|
||||
if (stripeCustomerId) {
|
||||
const stripeSession = await stripe.billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
return_url: billingUrl,
|
||||
});
|
||||
|
||||
redirectUrl = stripeSession.url as string;
|
||||
}
|
||||
} catch (error) {
|
||||
return { status: "error", message: "Failed to open customer portal" };
|
||||
}
|
||||
|
||||
redirect(redirectUrl);
|
||||
// return { status: "success", stripeUrl: redirectUrl };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use server";
|
||||
|
||||
import { getItemById } from "@/data/item";
|
||||
import { currentUser } from "@/lib/auth";
|
||||
import { sanityClient } from "@/sanity/lib/client";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function publish(itemId: string): Promise<ServerActionResponse> {
|
||||
console.log("publish, itemId:", itemId);
|
||||
try {
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
return { status: "error", message: "Unauthorized" };
|
||||
}
|
||||
// console.log("publish, user:", user);
|
||||
|
||||
const item = await getItemById(itemId);
|
||||
if (!item) {
|
||||
return { status: "error", message: "Item not found!" };
|
||||
}
|
||||
if (item.submitter._ref !== user.id) {
|
||||
return { status: "error", message: "You are not allowed to do this!" };
|
||||
}
|
||||
|
||||
const result = await sanityClient
|
||||
.patch(itemId)
|
||||
.set({
|
||||
publishDate: new Date().toISOString(),
|
||||
})
|
||||
.commit();
|
||||
// console.log('publish, result:', result);
|
||||
|
||||
if (!result) {
|
||||
return { status: "error", message: "Failed to publish item!" };
|
||||
}
|
||||
return { status: "success", message: "Successfully published!" };
|
||||
} catch (error) {
|
||||
console.log("publish, error", error);
|
||||
return { status: "error", message: "Failed to publish item!" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use server";
|
||||
|
||||
import { getUserByEmail } from "@/data/user";
|
||||
import { sendVerificationEmail } from "@/lib/mail";
|
||||
import { RegisterSchema } from "@/lib/schemas";
|
||||
import { generateVerificationToken } from "@/lib/tokens";
|
||||
import { sanityClient } from "@/sanity/lib/client";
|
||||
import { UserRole } from "@/types/user-role";
|
||||
import { uuid } from "@sanity/uuid";
|
||||
import bcrypt from "bcryptjs";
|
||||
import type * as z from "zod";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function register(
|
||||
values: z.infer<typeof RegisterSchema>,
|
||||
): Promise<ServerActionResponse> {
|
||||
const validatedFields = RegisterSchema.safeParse(values);
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return { status: "error", message: "Invalid Fields!" };
|
||||
}
|
||||
|
||||
const { email, password, name } = validatedFields.data;
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const existingUser = await getUserByEmail(email);
|
||||
if (existingUser) {
|
||||
return { status: "error", message: "Email already being used" };
|
||||
}
|
||||
|
||||
await sanityClient.create({
|
||||
_type: "user",
|
||||
_id: `user.${uuid()}`,
|
||||
name,
|
||||
email,
|
||||
role: UserRole.USER,
|
||||
password: hashedPassword,
|
||||
});
|
||||
|
||||
const verificationToken = await generateVerificationToken(email);
|
||||
await sendVerificationEmail(
|
||||
verificationToken.identifier,
|
||||
verificationToken.token,
|
||||
);
|
||||
return {
|
||||
status: "success",
|
||||
message: "Please check your email for verification",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use server";
|
||||
|
||||
import { getUserByEmail } from "@/data/user";
|
||||
import { sendPasswordResetEmail } from "@/lib/mail";
|
||||
import { ResetSchema } from "@/lib/schemas";
|
||||
import { generatePasswordResetToken } from "@/lib/tokens";
|
||||
import type * as z from "zod";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function reset(
|
||||
values: z.infer<typeof ResetSchema>,
|
||||
): Promise<ServerActionResponse> {
|
||||
const validatedFields = ResetSchema.safeParse(values);
|
||||
if (!validatedFields.success) {
|
||||
return { status: "error", message: "Invalid email!" };
|
||||
}
|
||||
|
||||
const { email } = validatedFields.data;
|
||||
|
||||
const existingUser = await getUserByEmail(email);
|
||||
if (!existingUser) {
|
||||
return { status: "error", message: "Email not found!" };
|
||||
}
|
||||
|
||||
const passwordResetToken = await generatePasswordResetToken(email);
|
||||
await sendPasswordResetEmail(
|
||||
existingUser.name,
|
||||
passwordResetToken.identifier,
|
||||
passwordResetToken.token,
|
||||
);
|
||||
|
||||
return { status: "success", message: "Password reset email sent" };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use server";
|
||||
|
||||
import { unstable_update } from "@/auth";
|
||||
import { getUserById } from "@/data/user";
|
||||
import { currentUser } from "@/lib/auth";
|
||||
import type { SettingsSchema } from "@/lib/schemas";
|
||||
import { sanityClient } from "@/sanity/lib/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type * as z from "zod";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function settings(
|
||||
values: z.infer<typeof SettingsSchema>,
|
||||
): Promise<ServerActionResponse> {
|
||||
try {
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
return { status: "error", message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const dbUser = await getUserById(user.id);
|
||||
if (!dbUser) {
|
||||
return { status: "error", message: "User not found" };
|
||||
}
|
||||
|
||||
// console.log('settings, values:', values);
|
||||
if (user.isOAuth) {
|
||||
values.password = undefined;
|
||||
values.newPassword = undefined;
|
||||
}
|
||||
|
||||
// password change needs verification
|
||||
if (values.password && values.newPassword && dbUser.password) {
|
||||
const passwordsMatch = await bcrypt.compare(
|
||||
values.password,
|
||||
dbUser.password,
|
||||
);
|
||||
|
||||
if (!passwordsMatch) {
|
||||
return { status: "error", message: "Incorrect password!" };
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(values.newPassword, 10);
|
||||
values.password = hashedPassword;
|
||||
values.newPassword = undefined;
|
||||
}
|
||||
|
||||
const updatedUser = await sanityClient
|
||||
.patch(dbUser._id)
|
||||
.set({
|
||||
...values,
|
||||
})
|
||||
.commit();
|
||||
console.log("settings, updatedUser:", updatedUser);
|
||||
|
||||
// unstable update in Beta version
|
||||
unstable_update({
|
||||
user: {
|
||||
name: updatedUser.name,
|
||||
link: updatedUser.link,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/settings");
|
||||
return { status: "success", message: "Account information updated!" };
|
||||
} catch (error) {
|
||||
console.log("settings, error", error);
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to update account information!",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use server";
|
||||
|
||||
import { getItemById } from "@/data/item";
|
||||
import { currentUser } from "@/lib/auth";
|
||||
import { sendNotifySubmissionEmail } from "@/lib/mail";
|
||||
import { FreePlanStatus, PricePlans } from "@/lib/submission";
|
||||
import { getItemLinkInStudio, getItemStatusLinkInWebsite } from "@/lib/utils";
|
||||
import { sanityClient } from "@/sanity/lib/client";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export const submitToReview = async (
|
||||
itemId: string,
|
||||
): Promise<ServerActionResponse> => {
|
||||
console.log("submitToReview, itemId:", itemId);
|
||||
try {
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
return { status: "error", message: "Unauthorized" };
|
||||
}
|
||||
// console.log("submitToReview, user:", user);
|
||||
|
||||
const item = await getItemById(itemId);
|
||||
if (!item) {
|
||||
return { status: "error", message: "Item not found!" };
|
||||
}
|
||||
if (item.submitter._ref !== user.id) {
|
||||
return { status: "error", message: "You are not allowed to do this!" };
|
||||
}
|
||||
if (
|
||||
item.pricePlan !== PricePlans.FREE ||
|
||||
item.freePlanStatus !== FreePlanStatus.SUBMITTING
|
||||
) {
|
||||
return { status: "error", message: "Item is not in right plan status!" };
|
||||
}
|
||||
|
||||
const result = await sanityClient
|
||||
.patch(itemId)
|
||||
.set({
|
||||
pricePlan: PricePlans.FREE,
|
||||
freePlanStatus: FreePlanStatus.PENDING,
|
||||
})
|
||||
.commit();
|
||||
// console.log('submitToReview, result:', result);
|
||||
if (!result) {
|
||||
return { status: "error", message: "Failed to submit item to review!" };
|
||||
}
|
||||
|
||||
const statusLink = getItemStatusLinkInWebsite(itemId);
|
||||
const reviewLink = getItemLinkInStudio(itemId);
|
||||
sendNotifySubmissionEmail(
|
||||
user.name,
|
||||
user.email,
|
||||
result.name,
|
||||
statusLink,
|
||||
reviewLink,
|
||||
);
|
||||
|
||||
return { status: "success", message: "Item submitted to review!" };
|
||||
} catch (error) {
|
||||
console.log("submitToReview, error", error);
|
||||
return { status: "error", message: "Failed to submit item to review!" };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
"use server";
|
||||
|
||||
import { currentUser } from "@/lib/auth";
|
||||
import type { SUPPORT_ITEM_ICON } from "@/lib/constants";
|
||||
import { SubmitSchema } from "@/lib/schemas";
|
||||
import { FreePlanStatus, PricePlans } from "@/lib/submission";
|
||||
import { slugify } from "@/lib/utils";
|
||||
import { sanityClient } from "@/sanity/lib/client";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
type BaseSubmitFormData = {
|
||||
name: string;
|
||||
link: string;
|
||||
description: string;
|
||||
introduction: string;
|
||||
imageId: string;
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
};
|
||||
|
||||
export type SubmitFormData = typeof SUPPORT_ITEM_ICON extends true
|
||||
? BaseSubmitFormData & { iconId: string }
|
||||
: BaseSubmitFormData;
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* https://nextjs.org/learn/dashboard-app/mutating-data
|
||||
*/
|
||||
export async function submit(
|
||||
formData: SubmitFormData,
|
||||
): Promise<ServerActionResponse> {
|
||||
try {
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
return { status: "error", message: "Unauthorized" };
|
||||
}
|
||||
|
||||
// console.log("submit, data:", formData);
|
||||
const {
|
||||
name,
|
||||
link,
|
||||
description,
|
||||
introduction,
|
||||
imageId,
|
||||
tags,
|
||||
categories,
|
||||
...rest
|
||||
} = SubmitSchema.parse(formData);
|
||||
const iconId = "iconId" in rest ? rest.iconId : undefined;
|
||||
console.log("submit, name:", name, "link:", link);
|
||||
|
||||
let slug = slugify(name);
|
||||
// check if the slug already exists
|
||||
const existingItem = await sanityClient.fetch(
|
||||
`*[_type == "item" && slug.current == $slug][0]`,
|
||||
{ slug },
|
||||
);
|
||||
if (existingItem) {
|
||||
const slugLink = slugify(link.replace(/^https?:\/\//, ""));
|
||||
slug = `${slug}-${slugLink}`;
|
||||
}
|
||||
|
||||
const data = {
|
||||
_type: "item",
|
||||
name,
|
||||
slug: {
|
||||
_type: "slug",
|
||||
current: slug,
|
||||
},
|
||||
link,
|
||||
description,
|
||||
introduction,
|
||||
publishDate: null,
|
||||
|
||||
paid: false,
|
||||
pricePlan: PricePlans.FREE,
|
||||
freePlanStatus: FreePlanStatus.SUBMITTING,
|
||||
submitter: {
|
||||
_type: "reference",
|
||||
_ref: user.id,
|
||||
},
|
||||
|
||||
// The _key only needs to be unique within the array itself
|
||||
tags: tags.map((tag, index) => ({
|
||||
_type: "reference",
|
||||
_ref: tag,
|
||||
_key: index.toString(),
|
||||
})),
|
||||
categories: categories.map((category, index) => ({
|
||||
_type: "reference",
|
||||
_ref: category,
|
||||
_key: index.toString(),
|
||||
})),
|
||||
image: {
|
||||
_type: "image",
|
||||
alt: `image of ${name}`,
|
||||
asset: {
|
||||
_type: "reference",
|
||||
_ref: imageId,
|
||||
},
|
||||
},
|
||||
icon: iconId
|
||||
? {
|
||||
_type: "image",
|
||||
alt: `icon of ${name}`,
|
||||
asset: {
|
||||
_type: "reference",
|
||||
_ref: iconId,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// console.log("submit, data:", data);
|
||||
|
||||
const res = await sanityClient.create(data);
|
||||
if (!res) {
|
||||
console.log("submit, fail");
|
||||
return { status: "error", message: "Failed to submit" };
|
||||
}
|
||||
|
||||
// console.log("submit, success, res:", res);
|
||||
|
||||
// Next.js has a Client-side Router Cache that stores the route segments in the user's browser for a time.
|
||||
// Along with prefetching, this cache ensures that users can quickly navigate between routes
|
||||
// while reducing the number of requests made to the server.
|
||||
// Since you're updating the data displayed in the invoices route, you want to clear this cache and trigger a new request to the server.
|
||||
// You can do this with the revalidatePath function from Next.js.
|
||||
revalidatePath("/submit");
|
||||
|
||||
return { status: "success", message: "Successfully created", id: res._id };
|
||||
} catch (error) {
|
||||
console.log("submit, error", error);
|
||||
return { status: "error", message: "Failed to submit" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use server";
|
||||
|
||||
import { NewsletterWelcomeEmail } from "@/emails/newsletter-welcome";
|
||||
import { resend } from "@/lib/mail";
|
||||
import { type NewsletterFormData, NewsletterFormSchema } from "@/lib/schemas";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function subscribeToNewsletter(
|
||||
formdata: NewsletterFormData,
|
||||
): Promise<ServerActionResponse> {
|
||||
try {
|
||||
const validatedInput = NewsletterFormSchema.safeParse(formdata);
|
||||
if (!validatedInput.success) {
|
||||
return { status: "error", message: "Invalid input" };
|
||||
}
|
||||
|
||||
const subscribedResult = await resend.contacts.create({
|
||||
email: validatedInput.data.email,
|
||||
unsubscribed: false,
|
||||
audienceId: process.env.RESEND_AUDIENCE_ID,
|
||||
});
|
||||
console.log("subscribeToNewsletter, subscribedResult", subscribedResult);
|
||||
const subscribed = !subscribedResult.error;
|
||||
|
||||
if (subscribed) {
|
||||
const emailSentResult = await resend.emails.send({
|
||||
from: process.env.RESEND_EMAIL_FROM,
|
||||
to: validatedInput.data.email,
|
||||
subject: "Welcome to our newsletter!",
|
||||
react: NewsletterWelcomeEmail({ email: validatedInput.data.email }),
|
||||
});
|
||||
console.log("subscribeToNewsletter, emailSentResult", emailSentResult);
|
||||
const emailSent = !emailSentResult.error;
|
||||
if (emailSent) {
|
||||
return { status: "success", message: "Subscribed to the newsletter" };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to subscribe to the newsletter",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("subscribeToNewsletter, error:", error);
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to subscribe to the newsletter",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use server";
|
||||
|
||||
import { getItemById } from "@/data/item";
|
||||
import { currentUser } from "@/lib/auth";
|
||||
import { sanityClient } from "@/sanity/lib/client";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function unpublish(itemId: string): Promise<ServerActionResponse> {
|
||||
console.log("unpublish, itemId:", itemId);
|
||||
try {
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
return { status: "error", message: "Unauthorized" };
|
||||
}
|
||||
// console.log("publish, user:", user);
|
||||
|
||||
const item = await getItemById(itemId);
|
||||
if (!item) {
|
||||
return { status: "error", message: "Item not found!" };
|
||||
}
|
||||
if (item.submitter._ref !== user.id) {
|
||||
return { status: "error", message: "You are not allowed to do this!" };
|
||||
}
|
||||
|
||||
const result = await sanityClient
|
||||
.patch(itemId)
|
||||
.set({
|
||||
publishDate: null,
|
||||
})
|
||||
.commit();
|
||||
// console.log('unpublish, result:', result);
|
||||
|
||||
if (!result) {
|
||||
return { status: "error", message: "Failed to unpublish item!" };
|
||||
}
|
||||
|
||||
return { status: "success", message: "Item unpublished!" };
|
||||
} catch (error) {
|
||||
console.log("unpublish, error", error);
|
||||
return { status: "error", message: "Failed to unpublish item!" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use server";
|
||||
|
||||
import { resend } from "@/lib/mail";
|
||||
import { type NewsletterFormData, NewsletterFormSchema } from "@/lib/schemas";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function unsubscribeToNewsletter(
|
||||
formdata: NewsletterFormData,
|
||||
): Promise<ServerActionResponse> {
|
||||
try {
|
||||
const validatedInput = NewsletterFormSchema.safeParse(formdata);
|
||||
if (!validatedInput.success) {
|
||||
return { status: "error", message: "Invalid input" };
|
||||
}
|
||||
|
||||
const unsubscribedResult = await resend.contacts.remove({
|
||||
email: validatedInput.data.email,
|
||||
audienceId: process.env.RESEND_AUDIENCE_ID,
|
||||
});
|
||||
console.log(
|
||||
"unsubscribeToNewsletter, unsubscribedResult",
|
||||
unsubscribedResult,
|
||||
);
|
||||
const unsubscribed = !unsubscribedResult.error;
|
||||
if (unsubscribed) {
|
||||
return {
|
||||
status: "success",
|
||||
message: "You have been unsubscribed from the newsletter",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to unsubscribe to the newsletter",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("unsubscribeToNewsletter, error", error);
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to unsubscribe to the newsletter",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use server";
|
||||
|
||||
import { unstable_update } from "@/auth";
|
||||
import { getUserById } from "@/data/user";
|
||||
import { currentUser } from "@/lib/auth";
|
||||
import { type UserLinkData, UserLinkSchema } from "@/lib/schemas";
|
||||
import { sanityClient } from "@/sanity/lib/client";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function updateUserLink(
|
||||
values: UserLinkData,
|
||||
): Promise<ServerActionResponse> {
|
||||
try {
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
return { status: "error", message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const dbUser = await getUserById(user.id);
|
||||
if (!dbUser) {
|
||||
return { status: "error", message: "User not found" };
|
||||
}
|
||||
|
||||
// console.log('updateUserLink, values:', values);
|
||||
const { link } = UserLinkSchema.parse(values);
|
||||
|
||||
const updatedUser = await sanityClient
|
||||
.patch(dbUser._id)
|
||||
.set({
|
||||
link: link,
|
||||
})
|
||||
.commit();
|
||||
// console.log("updateUserLink, user:", updatedUser);
|
||||
|
||||
// unstable update in Beta version
|
||||
unstable_update({
|
||||
user: {
|
||||
link: updatedUser.link,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/settings");
|
||||
return { status: "success", message: "User link updated!" };
|
||||
} catch (error) {
|
||||
console.log("updateUserLink, error", error);
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to update user link!",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use server";
|
||||
|
||||
import { unstable_update } from "@/auth";
|
||||
import { getUserById } from "@/data/user";
|
||||
import { currentUser } from "@/lib/auth";
|
||||
import { type UserNameData, UserNameSchema } from "@/lib/schemas";
|
||||
import { sanityClient } from "@/sanity/lib/client";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function updateUserName(
|
||||
values: UserNameData,
|
||||
): Promise<ServerActionResponse> {
|
||||
try {
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
return { status: "error", message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const dbUser = await getUserById(user.id);
|
||||
if (!dbUser) {
|
||||
return { status: "error", message: "User not found" };
|
||||
}
|
||||
|
||||
// console.log('updateUserName, values:', values);
|
||||
const { name } = UserNameSchema.parse(values);
|
||||
|
||||
const updatedUser = await sanityClient
|
||||
.patch(dbUser._id)
|
||||
.set({
|
||||
name: name,
|
||||
})
|
||||
.commit();
|
||||
// console.log("updateUserName, user:", updatedUser);
|
||||
|
||||
// unstable update in Beta version
|
||||
unstable_update({
|
||||
user: {
|
||||
name: updatedUser.name,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/settings");
|
||||
return { status: "success", message: "User name updated!" };
|
||||
} catch (error) {
|
||||
console.log("updateUserName, error", error);
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to update user name!",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use server";
|
||||
|
||||
import { getUserById } from "@/data/user";
|
||||
import { currentUser } from "@/lib/auth";
|
||||
import type { UserPasswordData } from "@/lib/schemas";
|
||||
import { sanityClient } from "@/sanity/lib/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export type ServerActionResponse = {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function updateUserPassword(
|
||||
values: UserPasswordData,
|
||||
): Promise<ServerActionResponse> {
|
||||
try {
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
return { status: "error", message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const dbUser = await getUserById(user.id);
|
||||
if (!dbUser) {
|
||||
return { status: "error", message: "User not found" };
|
||||
}
|
||||
|
||||
// password change needs verification
|
||||
if (values.password && values.newPassword && dbUser.password) {
|
||||
const passwordsMatch = await bcrypt.compare(
|
||||
values.password,
|
||||
dbUser.password,
|
||||
);
|
||||
|
||||
if (!passwordsMatch) {
|
||||
return { status: "error", message: "Incorrect password!" };
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(values.newPassword, 10);
|
||||
const updatedUser = await sanityClient
|
||||
.patch(dbUser._id)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
})
|
||||
.commit();
|
||||
console.log("updateUserPassword, user:", updatedUser);
|
||||
|
||||
revalidatePath("/settings");
|
||||
return { status: "success", message: "User password updated!" };
|
||||
}
|
||||
return { status: "error", message: "No password provided" };
|
||||
} catch (error) {
|
||||
console.log("updateUserPassword, error", error);
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to update user password!",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import "@/styles/globals.css";
|
||||
|
||||
export default function StudioLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import config from "@/sanity.config";
|
||||
import { NextStudio } from "next-sanity/studio";
|
||||
|
||||
/**
|
||||
* https://www.sanity.io/plugins/next-sanity#studio-route-with-app-router
|
||||
*/
|
||||
export default function Studio() {
|
||||
return <NextStudio config={config} />;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { unsubscribeToNewsletter } from "@/actions/unsubscribe-to-newsletter";
|
||||
import { Logo } from "@/components/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function UnsubscribePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const [message, setMessage] = useState("Processing your request...");
|
||||
|
||||
useEffect(() => {
|
||||
const email = searchParams.get("email");
|
||||
if (email) {
|
||||
unsubscribeToNewsletter({ email })
|
||||
.then((response) => {
|
||||
console.log("unsubscribePage, response", response);
|
||||
if (response.status === "success") {
|
||||
setMessage(response.message || "You have been unsubscribed.");
|
||||
} else {
|
||||
setMessage(
|
||||
response.message || "An error occurred. Please try again later.",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.log("unsubscribePage, error");
|
||||
setMessage("An error occurred. Please try again later.");
|
||||
});
|
||||
} else {
|
||||
console.log("unsubscribePage, no email provided");
|
||||
setMessage("Invalid request. No email provided.");
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-8">
|
||||
<Logo className="size-12" />
|
||||
|
||||
<h1 className="text-4xl font-bold">Unsubscribe from the newsletter</h1>
|
||||
|
||||
<p className="text-balance text-center text-xl font-medium px-4">
|
||||
{message}
|
||||
</p>
|
||||
|
||||
<Button asChild size="lg" variant="default">
|
||||
<Link href="/">Back to home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { DashboardSubmitHeader } from "@/components/dashboard/dashboard-submit-header";
|
||||
import { SubmitStepper } from "@/components/submit/submit-stepper";
|
||||
|
||||
export default async function PlanLayout({
|
||||
children,
|
||||
}: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<DashboardSubmitHeader
|
||||
title="Submit"
|
||||
subtitle="Choose pricing plan"
|
||||
label="2 / 3"
|
||||
>
|
||||
<SubmitStepper initialStep={2} />
|
||||
</DashboardSubmitHeader>
|
||||
|
||||
<div className="mt-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { PricingPlansSkeleton } from "@/components/dashboard/pricing-plans";
|
||||
import { SubmissionCardInPlanPageSkeleton } from "@/components/payment/submission-card-in-plan-page";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<Card className="flex flex-col items-center">
|
||||
<div className="w-full p-4">
|
||||
<SubmissionCardInPlanPageSkeleton />
|
||||
</div>
|
||||
|
||||
<Separator className="w-full" />
|
||||
|
||||
<div className="w-full p-4 my-4">
|
||||
<PricingPlansSkeleton />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { PricingPlans } from "@/components/dashboard/pricing-plans";
|
||||
import SubmissionCardInPlanPage from "@/components/payment/submission-card-in-plan-page";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { currentUser } from "@/lib/auth";
|
||||
import { constructMetadata } from "@/lib/metadata";
|
||||
import { sanityFetch } from "@/sanity/lib/fetch";
|
||||
import { itemByIdQuery } from "@/sanity/lib/queries";
|
||||
import type { ItemInfo } from "@/types";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}): Promise<Metadata | undefined> {
|
||||
return constructMetadata({
|
||||
title: "Submit your product (2/3)",
|
||||
description: "Submit your product (2/3) Choose pricing plan",
|
||||
canonicalUrl: `${siteConfig.url}/payment/${params.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function PlanPage({ params }: { params: { id: string } }) {
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
console.error("PlanPage, user not found");
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
console.log("PlanPage, itemId:", id);
|
||||
const item = await sanityFetch<ItemInfo>({
|
||||
query: itemByIdQuery,
|
||||
params: { id: id },
|
||||
disableCache: true,
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
console.error("PlanPage, item not found");
|
||||
return notFound();
|
||||
}
|
||||
// console.log('PlanPage, item:', item);
|
||||
|
||||
// redirect to dashboard if the item is not submitted by the user
|
||||
if (item.submitter._id !== user.id) {
|
||||
console.error("PlanPage, user not match");
|
||||
return redirect("/dashboard");
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col items-center">
|
||||
<div className="w-full p-4">
|
||||
<SubmissionCardInPlanPage item={item} />
|
||||
</div>
|
||||
|
||||
<Separator className="w-full" />
|
||||
|
||||
<div className="w-full p-4 my-4">
|
||||
<PricingPlans item={item} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { DashboardSubmitHeader } from "@/components/dashboard/dashboard-submit-header";
|
||||
import { SubmitStepper } from "@/components/submit/submit-stepper";
|
||||
|
||||
export default async function PublishLayout({
|
||||
children,
|
||||
}: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<DashboardSubmitHeader
|
||||
title="Submit"
|
||||
subtitle="Review and publish product"
|
||||
label="3 / 3"
|
||||
>
|
||||
<SubmitStepper initialStep={3} />
|
||||
</DashboardSubmitHeader>
|
||||
|
||||
<div className="mt-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SubmissionCardInPublishPageSkeleton } from "@/components/publish/submission-card-in-publish-page";
|
||||
|
||||
export default function Loading() {
|
||||
return <SubmissionCardInPublishPageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import SubmissionCardInPublishPage from "@/components/publish/submission-card-in-publish-page";
|
||||
import ConfettiEffect from "@/components/shared/confetti-effect";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { currentUser } from "@/lib/auth";
|
||||
import { constructMetadata } from "@/lib/metadata";
|
||||
import { FreePlanStatus, PricePlans, ProPlanStatus } from "@/lib/submission";
|
||||
import { sanityFetch } from "@/sanity/lib/fetch";
|
||||
import { itemByIdQuery } from "@/sanity/lib/queries";
|
||||
import type { ItemInfo } from "@/types";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}): Promise<Metadata | undefined> {
|
||||
return constructMetadata({
|
||||
title: "Submit your product (3/3)",
|
||||
description: "Submit your product (3/3) Review and publish product",
|
||||
canonicalUrl: `${siteConfig.url}/publish/${params.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function PublishPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { id: string };
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
console.error("PublishPage, user not found");
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
const { pay } = searchParams as { [key: string]: string };
|
||||
const showConfetti = pay === "success";
|
||||
// console.log('PublishPage, itemId:', id);
|
||||
const item = await sanityFetch<ItemInfo>({
|
||||
query: itemByIdQuery,
|
||||
params: { id: id },
|
||||
disableCache: true,
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
console.error("PublishPage, item not found");
|
||||
return notFound();
|
||||
}
|
||||
// console.log("PublishPage, item:", item);
|
||||
|
||||
// redirect to dashboard if the item is not submitted by the user
|
||||
if (item.submitter._id !== user.id) {
|
||||
console.error("PublishPage, user not match");
|
||||
return redirect("/dashboard");
|
||||
}
|
||||
|
||||
// check status, redirect to the corresponding page if the status is not right
|
||||
if (
|
||||
item.pricePlan === PricePlans.FREE &&
|
||||
item.freePlanStatus !== FreePlanStatus.APPROVED
|
||||
) {
|
||||
return redirect("/dashboard");
|
||||
}
|
||||
if (
|
||||
item.pricePlan === PricePlans.PRO &&
|
||||
item.proPlanStatus !== ProPlanStatus.SUCCESS
|
||||
) {
|
||||
return redirect("/dashboard");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* show confetti if the payment is successful */}
|
||||
{showConfetti && <ConfettiEffect />}
|
||||
|
||||
<SubmissionCardInPublishPage item={item} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { DashboardSubmitHeader } from "@/components/dashboard/dashboard-submit-header";
|
||||
import { SubmitStepper } from "@/components/submit/submit-stepper";
|
||||
|
||||
export default async function SubmitLayout({
|
||||
children,
|
||||
}: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<DashboardSubmitHeader
|
||||
title="Submit"
|
||||
subtitle="Enter product details"
|
||||
label="1 / 3"
|
||||
>
|
||||
<SubmitStepper initialStep={1} />
|
||||
</DashboardSubmitHeader>
|
||||
|
||||
<div className="mt-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SubmitFormSkeleton } from "@/components/submit/submit-form";
|
||||
|
||||
export default function Loading() {
|
||||
return <SubmitFormSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SubmitForm } from "@/components/submit/submit-form";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { constructMetadata } from "@/lib/metadata";
|
||||
import type {
|
||||
CategoryListQueryResult,
|
||||
TagListQueryResult,
|
||||
} from "@/sanity.types";
|
||||
import { sanityFetch } from "@/sanity/lib/fetch";
|
||||
import { categoryListQuery, tagListQuery } from "@/sanity/lib/queries";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Submit your product (1/3)",
|
||||
description: "Submit your product (1/3) – Enter product details",
|
||||
canonicalUrl: `${siteConfig.url}/submit`,
|
||||
});
|
||||
|
||||
export default async function SubmitPage() {
|
||||
const [categoryList, tagList] = await Promise.all([
|
||||
sanityFetch<CategoryListQueryResult>({
|
||||
query: categoryListQuery,
|
||||
}),
|
||||
sanityFetch<TagListQueryResult>({
|
||||
query: tagListQuery,
|
||||
}),
|
||||
]);
|
||||
|
||||
return <SubmitForm tagList={tagList} categoryList={categoryList} />;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { DashboardButton } from "@/components/dashboard/dashboard-button";
|
||||
import { DashboardHeader } from "@/components/dashboard/dashboard-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UploadIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<DashboardHeader title="Dashboard" subtitle="Overview of submissions">
|
||||
<div className="flex items-center space-x-4">
|
||||
<DashboardButton />
|
||||
|
||||
<Button asChild disabled className="group whitespace-nowrap">
|
||||
<Link
|
||||
href="/submit"
|
||||
prefetch={false}
|
||||
className="flex items-center justify-center space-x-2"
|
||||
>
|
||||
<UploadIcon className="w-4 h-4" />
|
||||
<span>Submit</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
|
||||
<div className="mt-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SubmissionListSkeleton } from "@/components/dashboard/submission-list";
|
||||
|
||||
export default function Loading() {
|
||||
return <SubmissionListSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import EmptySubmission from "@/components/dashboard/submission-empty";
|
||||
import SubmissionList from "@/components/dashboard/submission-list";
|
||||
import CustomPagination from "@/components/shared/pagination";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { getSubmissions } from "@/data/submission";
|
||||
import { currentUser } from "@/lib/auth";
|
||||
import { SUBMISSIONS_PER_PAGE } from "@/lib/constants";
|
||||
import { constructMetadata } from "@/lib/metadata";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Dashboard",
|
||||
description: "Overview of submissions",
|
||||
canonicalUrl: `${siteConfig.url}/dashboard`,
|
||||
});
|
||||
|
||||
export default async function DashboardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const user = await currentUser();
|
||||
const userId = user?.id;
|
||||
// console.log('DashboardPage, user:', user);
|
||||
|
||||
console.log("DashboardPage, searchParams", searchParams);
|
||||
const { page } = searchParams as { [key: string]: string };
|
||||
const currentPage = page ? Number(page) : 1;
|
||||
const { submissions, totalCount } = await getSubmissions({
|
||||
userId,
|
||||
currentPage,
|
||||
});
|
||||
const totalPages = Math.ceil(totalCount / SUBMISSIONS_PER_PAGE);
|
||||
console.log(
|
||||
"DashboardPage, totalCount",
|
||||
totalCount,
|
||||
", totalPages",
|
||||
totalPages,
|
||||
);
|
||||
// console.log('DashboardPage, submissions:', submissions);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* when no submissions are found */}
|
||||
{submissions?.length === 0 && <EmptySubmission />}
|
||||
|
||||
{/* when submissions are found */}
|
||||
{submissions && submissions.length > 0 && (
|
||||
<section className="">
|
||||
<SubmissionList items={submissions} />
|
||||
|
||||
<div className="mt-8 flex items-center justify-center">
|
||||
<CustomPagination
|
||||
routePrefix="/dashboard"
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { DashboardHeader } from "@/components/dashboard/dashboard-header";
|
||||
|
||||
export default async function EditLayout({
|
||||
children,
|
||||
}: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<DashboardHeader
|
||||
title="Edit"
|
||||
subtitle="Update product info"
|
||||
showBackButton={true}
|
||||
/>
|
||||
|
||||
<div className="mt-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||