feat: open source Mkdirs

This commit is contained in:
open-fox
2026-08-09 23:23:29 +08:00
commit 640e47a305
422 changed files with 53384 additions and 0 deletions
+33
View File
@@ -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
+43
View File
@@ -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
+50
View File
@@ -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
+63
View File
@@ -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
+33
View File
@@ -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
+53
View File
@@ -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
+73
View File
@@ -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
+114
View File
@@ -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=
+38
View File
@@ -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.
+11
View File
@@ -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.
+20
View File
@@ -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.
+41
View File
@@ -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/
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"biomejs.biome",
"bradlc.vscode-tailwindcss",
"eamodio.gitlens"
]
}
+22
View File
@@ -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
}
}
+79
View File
@@ -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.
+79
View File
@@ -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.
+61
View File
@@ -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"]
+201
View File
@@ -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.
+52
View File
@@ -0,0 +1,52 @@
# Mkdirs
Launch AI-powered directory websites in minutes
[![GitHub stars](https://img.shields.io/github/stars/MkdirsHQ/mkdirs)](https://github.com/MkdirsHQ/mkdirs/stargazers)
[![License](https://img.shields.io/github/license/MkdirsHQ/mkdirs)](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)
![Mkdirs directory website template](public/og.png)
## 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.
+36
View File
@@ -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
}
}
+17
View File
@@ -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"
}
}
+119
View File
@@ -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}>
&copy; {new Date().getFullYear()}
&nbsp;&nbsp; All Rights Reserved.
</span>
<span style={footerRight}>
<Link style={anchor} href={siteConfig.links.twitter}>
Twitter
</Link>
&nbsp;&nbsp;&nbsp;&nbsp;
<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;
+62
View File
@@ -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,
};
+98
View File
@@ -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}>
&copy; {new Date().getFullYear()}
&nbsp; All Rights Reserved.
</span>
<span style={footerRight}>
<Link style={anchor} href={siteConfig.links.twitter}>
Twitter
</Link>
&nbsp;&nbsp;&nbsp;&nbsp;
<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",
};
+99
View File
@@ -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}>
&copy; {new Date().getFullYear()}
&nbsp;&nbsp; All Rights Reserved.
</span>
<span style={footerRight}>
<Link style={anchor} href={siteConfig.links.twitter}>
Twitter
</Link>
&nbsp;&nbsp;&nbsp;&nbsp;
<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;
+119
View File
@@ -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}>
&copy; {new Date().getFullYear()}
&nbsp;&nbsp; All Rights Reserved.
</span>
<span style={footerRight}>
<Link style={anchor} href={siteConfig.links.twitter}>
Twitter
</Link>
&nbsp;&nbsp;&nbsp;&nbsp;
<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;
+123
View File
@@ -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}>
&copy; {new Date().getFullYear()}
&nbsp;&nbsp; All Rights Reserved.
</span>
<span style={footerRight}>
<Link style={anchor} href={siteConfig.links.twitter}>
Twitter
</Link>
&nbsp;&nbsp;&nbsp;&nbsp;
<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;
+107
View File
@@ -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}>
&copy; {new Date().getFullYear()}
&nbsp;&nbsp; All Rights Reserved.
</span>
<span style={footerRight}>
<Link style={anchor} href={siteConfig.links.twitter}>
Twitter
</Link>
&nbsp;&nbsp;&nbsp;&nbsp;
<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;
+109
View File
@@ -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&apos;t want to change your password or didn&apos;t
request this, just ignore and delete this message.
</Text>
<Text style={paragraph}>
To keep your account secure, please don&apos;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}>
&copy; {new Date().getFullYear()}
&nbsp;&nbsp; All Rights Reserved.
</span>
<span style={footerRight}>
<Link style={anchor} href={siteConfig.links.twitter}>
Twitter
</Link>
&nbsp;&nbsp;&nbsp;&nbsp;
<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;
+101
View File
@@ -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&apos;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}>
&copy; {new Date().getFullYear()}
&nbsp;&nbsp; All Rights Reserved.
</span>
<span style={footerRight}>
<Link style={anchor} href={siteConfig.links.twitter}>
Twitter
</Link>
&nbsp;&nbsp;&nbsp;&nbsp;
<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;
+52
View File
@@ -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;
+51
View File
@@ -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;
+159
View File
@@ -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"
}
}
+16791
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

+1
View File
@@ -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

+1
View File
@@ -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"}
+6
View File
@@ -0,0 +1,6 @@
{
"path": "src/sanity/lib/queries.ts",
"generates": "sanity.types.ts",
"schema": "schema.json",
"overloadClientMethods": true
}
+14
View File
@@ -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
}
});
+96
View File
@@ -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()
],
})
+2461
View File
File diff suppressed because it is too large Load Diff
+3022
View File
File diff suppressed because it is too large Load Diff
+124
View File
@@ -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);
+252
View File
@@ -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`);
};
+103
View File
@@ -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`);
};
+252
View File
@@ -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`);
};
+243
View File
@@ -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));
};
+375
View File
@@ -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);
+345
View File
@@ -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`);
};
+162
View File
@@ -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);
+138
View File
@@ -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);
+29
View File
@@ -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();
+23
View File
@@ -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!" };
}
+140
View File
@@ -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);
}
+184
View File
@@ -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" };
}
}
+270
View File
@@ -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;
}
};
+52
View File
@@ -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,
};
}
}
+74
View File
@@ -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!" };
}
}
+7
View File
@@ -0,0 +1,7 @@
"use server";
import { signOut } from "@/auth";
export const logout = async () => {
await signOut();
};
+56
View File
@@ -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!" };
}
+41
View File
@@ -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!" };
}
+44
View File
@@ -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 };
}
+45
View File
@@ -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!" };
}
}
+53
View File
@@ -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",
};
}
+37
View File
@@ -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" };
}
+78
View File
@@ -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!",
};
}
}
+67
View File
@@ -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!" };
}
};
+141
View File
@@ -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" };
}
}
+54
View File
@@ -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",
};
}
}
+46
View File
@@ -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!" };
}
}
+47
View File
@@ -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",
};
}
}
+56
View File
@@ -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!",
};
}
}
+56
View File
@@ -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!",
};
}
}
+60
View File
@@ -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!",
};
}
}
+13
View File
@@ -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>
);
}

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