From 66a9ee29504035827e80af5113fc7864ba025207 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 1 May 2026 08:44:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=85=B3=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API_REFERENCE.md | 452 ++++++++++ EXPLORATION_MANIFEST.md | 280 ++++++ EXPLORATION_REPORT.md | 871 ++++++++++++++++++ EXPLORATION_SUMMARY.txt | 364 ++++++++ PROJECT_ANALYSIS.md | 944 ++++++++++++++++++++ QUICK_REFERENCE.md | 240 +++++ app/(dashboard)/levels/page.tsx | 64 +- app/api/levels/reorder/route.ts | 44 - app/api/levels/route.ts | 112 ++- app/api/wx-users/[id]/route.ts | 29 +- components/levels/batch-import-dialog.tsx | 563 ++++++++++++ components/levels/delete-confirm-dialog.tsx | 156 ++++ components/levels/image-uploader.tsx | 50 +- components/levels/level-columns.tsx | 156 ---- components/levels/level-dialog.tsx | 109 ++- components/levels/level-row.tsx | 119 +++ components/levels/level-table.tsx | 210 ++--- lib/cos-client.ts | 81 ++ lib/sort-key.ts | 144 +++ package.json | 5 +- pnpm-lock.yaml | 113 +-- prisma/backfill-sort-keys.ts | 34 + prisma/rebalance-sort-keys.ts | 26 + prisma/schema.prisma | 2 + types/index.ts | 7 +- โœ…_EXPLORATION_COMPLETE.txt | 366 ++++++++ ๐Ÿ“š_DOCUMENTATION_INDEX.md | 236 +++++ 27 files changed, 5262 insertions(+), 515 deletions(-) create mode 100644 API_REFERENCE.md create mode 100644 EXPLORATION_MANIFEST.md create mode 100644 EXPLORATION_REPORT.md create mode 100644 EXPLORATION_SUMMARY.txt create mode 100644 PROJECT_ANALYSIS.md create mode 100644 QUICK_REFERENCE.md delete mode 100644 app/api/levels/reorder/route.ts create mode 100644 components/levels/batch-import-dialog.tsx create mode 100644 components/levels/delete-confirm-dialog.tsx delete mode 100644 components/levels/level-columns.tsx create mode 100644 components/levels/level-row.tsx create mode 100644 lib/cos-client.ts create mode 100644 lib/sort-key.ts create mode 100644 prisma/backfill-sort-keys.ts create mode 100644 prisma/rebalance-sort-keys.ts create mode 100644 โœ…_EXPLORATION_COMPLETE.txt create mode 100644 ๐Ÿ“š_DOCUMENTATION_INDEX.md diff --git a/API_REFERENCE.md b/API_REFERENCE.md new file mode 100644 index 0000000..f23224b --- /dev/null +++ b/API_REFERENCE.md @@ -0,0 +1,452 @@ +# MemeStudio API Reference + +## Base URL +- Development: `http://localhost:3001/studio` +- Production: `https://119.91.211.52/studio` + +## Authentication +All endpoints except `/api/auth/*` require a valid session cookie: +- `better-auth.session_token` (HTTP) +- `__Secure-better-auth.session_token` (HTTPS) + +Error response if unauthorized: `{ error: 'Unauthorized' }` (401) + +--- + +## Authentication Endpoints + +### POST /api/auth/sign-in/email +Sign in with email and password. + +**Request:** +```json +{ + "email": "admin@example.com", + "password": "password123" +} +``` + +**Response:** +```json +{ + "user": { + "id": "uuid", + "email": "admin@example.com", + "name": "Admin", + "emailVerified": true + }, + "session": { + "id": "session_id", + "expiresAt": "2024-04-14T10:00:00Z", + "token": "session_token" + } +} +``` + +--- + +### POST /api/auth/sign-out +Sign out and invalidate session. + +**Response:** +```json +{ "success": true } +``` + +--- + +## Levels API + +### GET /api/levels +Get all levels, sorted by sortOrder. + +**Response:** +```json +[ + { + "id": "uuid", + "imageUrl": "https://bucket.myqcloud.com/mini_game/images/...", + "answer": "็ญ”ๆกˆๆ–‡ๆœฌ", + "hint1": "ๆ็คบ1", + "hint2": "ๆ็คบ2", + "hint3": "ๆ็คบ3", + "sortOrder": 1, + "createdAt": "2024-04-01T10:00:00Z", + "updatedAt": "2024-04-01T10:00:00Z" + } +] +``` + +--- + +### POST /api/levels +Create a new level. sortOrder is auto-calculated as max+1. + +**Request:** +```json +{ + "imageUrl": "https://bucket.myqcloud.com/mini_game/images/...", + "answer": "็ญ”ๆกˆ", + "hint1": "ๆ็คบ1", + "hint2": "ๆ็คบ2", + "hint3": "ๆ็คบ3" +} +``` + +**Response:** Level object (201 Created) + +**Validation:** +- `imageUrl` and `answer` are required +- Returns 400 if missing required fields + +--- + +### PUT /api/levels +Update a level. + +**Request:** +```json +{ + "id": "uuid", + "imageUrl": "...", + "answer": "ๆ–ฐ็ญ”ๆกˆ", + "hint1": "...", + "hint2": "...", + "hint3": "..." +} +``` + +**Response:** Updated Level object + +**Validation:** +- `id` is required +- Other fields are optional (only provided fields are updated) + +--- + +### DELETE /api/levels?id= +Delete a level. + +**Response:** +```json +{ "success": true } +``` + +--- + +## Levels Reorder API + +### PUT /api/levels/reorder +Batch update sort order for multiple levels (atomic transaction). + +**Request:** +```json +{ + "orders": [ + { "id": "uuid1", "sortOrder": 1 }, + { "id": "uuid2", "sortOrder": 2 }, + { "id": "uuid3", "sortOrder": 3 } + ] +} +``` + +**Response:** +```json +{ "success": true } +``` + +**Validation:** +- `orders` must be an array of objects with `id` and `sortOrder` + +--- + +## Admin Users API + +### GET /api/users +Get all admin users, sorted by createdAt DESC. + +**Response:** +```json +[ + { + "id": "uuid", + "email": "admin@example.com", + "emailVerified": true, + "name": "Admin Name", + "image": null, + "createdAt": "2024-04-01T10:00:00Z", + "updatedAt": "2024-04-01T10:00:00Z" + } +] +``` + +--- + +### POST /api/users +Create a new admin user. + +**Request:** +```json +{ + "email": "newadmin@example.com", + "password": "securepassword123", + "name": "New Admin" +} +``` + +**Response:** User object (201 Created) + +**Validation:** +- `email` and `password` are required +- `email` must be unique (returns 400 if already exists: "่ฏฅ้‚ฎ็ฎฑๅทฒ่ขซๆณจๅ†Œ") +- Password is automatically hashed using Better Auth's hashPassword + +--- + +### PUT /api/users +Update a user. + +**Request:** +```json +{ + "id": "uuid", + "email": "newemail@example.com", + "password": "newpassword123", + "name": "Updated Name" +} +``` + +**Response:** Updated User object + +**Validation:** +- `id` is required +- Email must be unique across other users (returns 400 if taken: "่ฏฅ้‚ฎ็ฎฑๅทฒ่ขซๅ…ถไป–็”จๆˆทไฝฟ็”จ") +- If `password` is provided, it's hashed and Account record is updated + +--- + +### DELETE /api/users?id= +Delete a user. + +**Response:** +```json +{ "success": true } +``` + +**Validation:** +- `id` is required +- Cannot delete yourself (returns 400: "ไธ่ƒฝๅˆ ้™ค่‡ชๅทฑ็š„่ดฆๆˆท") +- Cascading delete removes all sessions and accounts + +--- + +## WeChat Users API + +### GET /api/wx-users?search=&page=<1>&limit=<20> +Get WeChat users with pagination and search. + +**Query Parameters:** +- `search` (optional): Search in nickname or openid (contains search) +- `page` (optional, default 1): Page number +- `limit` (optional, default 20): Items per page + +**Response:** +```json +{ + "users": [ + { + "id": "uuid", + "openid": "oABCDEF123456", + "nickname": "็”จๆˆทๆ˜ต็งฐ", + "avatarUrl": "https://...", + "points": 100, + "createdAt": "2024-04-01T10:00:00Z", + "updatedAt": "2024-04-01T10:00:00Z" + } + ], + "meta": { + "total": 150, + "page": 1, + "limit": 20, + "totalPages": 8 + } +} +``` + +--- + +### GET /api/wx-users/ +Get a specific WeChat user with their level progress. + +**Response:** +```json +{ + "id": "uuid", + "openid": "oABCDEF123456", + "nickname": "็”จๆˆทๆ˜ต็งฐ", + "avatarUrl": "https://...", + "points": 100, + "sessionKey": "...", + "createdAt": "2024-04-01T10:00:00Z", + "updatedAt": "2024-04-01T10:00:00Z", + "levelProgress": [ + { + "id": "progress_uuid", + "userId": "uuid", + "levelId": "uuid", + "completedAt": "2024-04-02T15:30:00Z", + "level": { + "id": "uuid", + "answer": "็ญ”ๆกˆ" + } + } + ] +} +``` + +--- + +### DELETE /api/wx-users/level-progress +Delete multiple level progress records (batch). + +**Request:** +```json +{ + "ids": [ + "progress_uuid1", + "progress_uuid2", + "progress_uuid3" + ] +} +``` + +**Response:** +```json +{ "deleted": 3 } +``` + +**Validation:** +- `ids` must be a non-empty array of strings +- Returns 400 if validation fails + +--- + +## Tencent COS API + +### GET /api/cos/temp-key +Get temporary credentials for uploading images to COS. + +**Response:** +```json +{ + "credentials": { + "tmpSecretId": "AKIA...", + "tmpSecretKey": "...", + "sessionToken": "..." + }, + "startTime": 1712000000, + "expiredTime": 1712001800, + "bucket": "mybucket-1234567890", + "region": "ap-guangzhou" +} +``` + +**Details:** +- Credentials are valid for 30 minutes (1800 seconds) +- Limited to upload to `mini_game/images/*` directory +- Use returned `bucket` and `region` for upload configuration +- Browser should upload directly to COS using these credentials (S3-compatible API) + +--- + +## Error Handling + +### Standard Error Response +```json +{ + "error": "Error message" +} +``` + +### HTTP Status Codes +- `200`: Success +- `201`: Created +- `400`: Bad Request (validation error) +- `401`: Unauthorized (no session) +- `404`: Not Found (resource doesn't exist) +- `500`: Server Error + +--- + +## Implementation Notes + +### Session Validation Pattern +```typescript +const session = await auth.api.getSession({ headers: request.headers }) +if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +} +``` + +### Pagination Pattern +- `skip = (page - 1) * limit` +- `totalPages = Math.ceil(total / limit)` + +### Transaction Pattern (Multi-step operations) +```typescript +await prisma.$transaction(async (tx) => { + // Multiple database operations in atomic transaction +}) +``` + +### Error Handling Pattern +All endpoints log errors to console and return appropriate status codes. + +--- + +## Testing with cURL + +### Sign In +```bash +curl -X POST http://localhost:3001/studio/api/auth/sign-in/email \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@example.com","password":"admin123456"}' \ + -c cookies.txt +``` + +### Get Levels (with session cookie) +```bash +curl http://localhost:3001/studio/api/levels \ + -b cookies.txt +``` + +### Create Level +```bash +curl -X POST http://localhost:3001/studio/api/levels \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "imageUrl":"https://bucket.myqcloud.com/mini_game/images/test.jpg", + "answer":"็ญ”ๆกˆ", + "hint1":"ๆ็คบ1", + "hint2":"ๆ็คบ2", + "hint3":"ๆ็คบ3" + }' +``` + +--- + +## Environment Variables Required for API + +``` +DATABASE_URL=mysql://user:pass@host:port/dbname +BETTER_AUTH_SECRET=<32+ chars> +BETTER_AUTH_URL=http://localhost:3001 +NEXT_PUBLIC_APP_URL=http://localhost:3001 +NEXT_PUBLIC_BASE_PATH=/studio +COS_SECRET_ID= +COS_SECRET_KEY= +COS_BUCKET=bucket-name +COS_REGION=ap-guangzhou +COS_APPID= +``` diff --git a/EXPLORATION_MANIFEST.md b/EXPLORATION_MANIFEST.md new file mode 100644 index 0000000..400873c --- /dev/null +++ b/EXPLORATION_MANIFEST.md @@ -0,0 +1,280 @@ +# MemeStudio - Complete Exploration Manifest + +## Files Analyzed + +### ๐Ÿ“„ Core Configuration Files +- โœ… `CLAUDE.md` - Project guidance (4KB) +- โœ… `.env.production` - Environment example (500B) +- โœ… `package.json` - Dependencies (1.6KB) +- โœ… `next.config.js` - Next.js config (330B) +- โœ… `middleware.ts` - Auth middleware (1KB) +- โœ… `tsconfig.json` - TypeScript config (230B) + +### ๐Ÿ—„๏ธ Database +- โœ… `prisma/schema.prisma` - 7 data models (2.5KB) + +### ๐Ÿ” Authentication & Libraries +- โœ… `lib/auth.ts` - Better Auth config (430B) +- โœ… `lib/auth-client.ts` - Client auth hooks (300B) +- โœ… `lib/prisma.ts` - Prisma singleton (280B) +- โœ… `lib/api.ts` - apiFetch wrapper (200B) +- โœ… `lib/cos.ts` - Tencent COS utilities (2.5KB) +- โœ… `lib/utils.ts` - Tailwind utilities (140B) + +### ๐Ÿ“ Type Definitions +- โœ… `types/index.ts` - All TypeScript interfaces (1.5KB) + +### ๐Ÿ›ฃ๏ธ API Routes (11 files) +- โœ… `app/api/auth/[...all]/route.ts` - Better Auth handler (80B) +- โœ… `app/api/levels/route.ts` - Levels CRUD (4.5KB) +- โœ… `app/api/levels/reorder/route.ts` - Batch reorder (1.2KB) +- โœ… `app/api/users/route.ts` - User management (5.5KB) +- โœ… `app/api/cos/temp-key/route.ts` - COS credentials (900B) +- โœ… `app/api/wx-users/route.ts` - WeChat users list (2KB) +- โœ… `app/api/wx-users/[id]/route.ts` - User details (1.8KB) +- โœ… `app/api/wx-users/level-progress/route.ts` - Progress delete (1KB) + +### ๐Ÿ“„ Pages (3 main pages) +- โœ… `app/(dashboard)/levels/page.tsx` - Levels management UI (5KB) +- โœ… `app/(dashboard)/users/page.tsx` - User management UI (7.5KB) +- โœ… `app/(dashboard)/wx-users/page.tsx` - WeChat users UI (6KB) +- โœ… `app/layout.tsx` - Root layout (500B) +- โœ… `app/page.tsx` - Home redirect (120B) + +### ๐ŸŽจ Components (15+ files) +- โœ… `components/layout/header.tsx` - Header component (600B) +- โœ… `components/levels/level-dialog.tsx` - Create/edit dialog (4.5KB) +- โœ… `components/layout/sidebar.tsx` - Navigation +- โœ… `components/levels/level-list.tsx` - Drag-and-drop list +- โœ… `components/levels/level-card.tsx` - Level card +- โœ… `components/levels/image-uploader.tsx` - Image upload +- โœ… `components/users/user-dialog.tsx` - User form +- โœ… `components/wx-users/wx-user-detail-dialog.tsx` - User details +- โœ… `components/ui/*` - shadcn/ui components (buttons, inputs, dialogs, etc.) + +--- + +## Analysis Summary + +### Total Files Reviewed: 30+ + +### Key Statistics +- **Total API Routes**: 14 endpoints across 8 route files +- **Database Models**: 7 (User, Session, Account, Verification, Level, WxUser, WxUserLevelProgress) +- **Protected Pages**: 3 (levels, users, wx-users) +- **Public Pages**: 1 (login) +- **UI Components**: 15+ +- **Utility Libraries**: 6 key files +- **Dependencies**: 30+ npm packages + +### Code Structure Quality +- โœ… Well-organized folder structure +- โœ… Consistent naming conventions +- โœ… Type safety throughout (TypeScript) +- โœ… Error handling in all endpoints +- โœ… Session validation patterns consistent +- โœ… React Query for state management +- โœ… Component composition is clean + +--- + +## Key Findings + +### 1. Architecture +- **Framework**: Next.js 14 with App Router +- **Deployment Model**: Standalone (single binary) +- **Reverse Proxy**: Behind `/studio` basePath +- **Auth System**: Better Auth with MySQL Prisma adapter +- **Session Duration**: 7 days with 1-day update age + +### 2. Database Design +- **Provider**: MySQL +- **ORM**: Prisma v6.5.0 +- **Models**: 7 total (Better Auth + custom models) +- **Relationships**: One-to-many (WxUser โ†’ WxUserLevelProgress โ†’ Level) +- **Cascade Delete**: Implemented for Sessions/Accounts + +### 3. API Patterns +- **Auth Check**: All routes validate session before proceeding +- **Error Handling**: Consistent error responses +- **Validation**: Input validation in all POST/PUT routes +- **Transactions**: Used for multi-step operations (user creation, reordering) +- **Pagination**: Implemented for WeChat users list + +### 4. Security +- โœ… Session-based authentication +- โœ… Password hashing (bcryptjs + better-auth/crypto) +- โœ… Cookie validation (handles HTTP and HTTPS prefixes) +- โœ… Self-delete prevention in user management +- โœ… Temporary COS credentials (30-minute expiry) + +### 5. Notable Gotchas +- โš ๏ธ BETTER_AUTH_URL must NOT contain path component +- โš ๏ธ basePath excluded from request.nextUrl.pathname in Next.js 14 +- โš ๏ธ Middleware uses cookie check only (no Prisma in Edge Runtime) +- โš ๏ธ HTTPS adds `__Secure-` prefix to cookies +- โš ๏ธ apiFetch() must be used for client-side API calls + +### 6. Missing Features +- โŒ No sharing/invite system +- โŒ No permission/role system +- โŒ No audit logging +- โŒ No soft deletes +- โŒ No rate limiting +- โŒ All authenticated users have full admin access + +--- + +## Frontend Technology Stack + +- **React**: 18.3.1 +- **TypeScript**: 5.8.2 +- **Tailwind CSS**: 3.4.17 +- **shadcn/ui**: Latest (Radix UI based) +- **React Query**: TanStack v5.69.0 +- **React Hook Form**: 7.54.2 +- **Zod**: 3.24.2 (validation) +- **@dnd-kit**: For drag-and-drop (sortable) +- **lucide-react**: Icons (0.483.0) + +--- + +## Backend Technology Stack + +- **Next.js**: 14.2.28 (App Router) +- **Better Auth**: 1.2.7 +- **Prisma**: 6.5.0 +- **MySQL**: Database +- **Node.js**: Runtime +- **Tencent COS**: Cloud storage +- **bcryptjs**: Password hashing + +--- + +## Development Commands + +```bash +# Development +pnpm run dev # Start dev server (port 3001) + +# Build & Deploy +pnpm run build # Production build +pnpm run deploy # Build + deploy via SSH + +# Linting +pnpm run lint # ESLint + +# Database +pnpm run db:generate # Generate Prisma client +pnpm run db:push # Push schema (dev) +pnpm run db:migrate # Create migration +pnpm run db:studio # Open Prisma Studio (visual editor) +pnpm run db:seed # Create/update admin user +``` + +--- + +## Deployment Information + +**Server Details**: +- **Host**: `root@119.91.211.52` +- **Path**: `/root/apps/meme-studio` +- **Process Manager**: PM2 +- **Staging Path**: `/studio` (behind reverse proxy) + +**Deployment Process**: +1. Local build: `pnpm run build` +2. Remote setup: `npm install --production` +3. Database: `npx prisma generate` +4. Process: PM2 restart + +**Critical Configuration**: +- `output: 'standalone'` - Bundles Next.js runtime +- `basePath: '/studio'` - App served at /studio +- Image remotes: `*.myqcloud.com` (Tencent COS) + +--- + +## Search Findings + +**No share/invite logic found** โŒ +- Searched entire codebase for "share", "invite", "permission" +- No models, APIs, or UI components for sharing +- No role-based access control +- All authenticated users = full admin access + +--- + +## Project Health Indicators + +โœ… **Strengths**: +- Clean code organization +- Consistent patterns +- Good error handling +- Type-safe throughout +- RESTful API design +- Proper session management +- Transaction support in DB + +โš ๏ธ **Considerations**: +- No permission system +- No audit logging +- No sharing/invite feature +- Single deployment server +- No multi-environment setup documented +- Email verification model exists but not used + +--- + +## Next Steps for Development + +If expanding this project: + +1. **Add Permissions System** + - Add role field to User model + - Implement permission checks in API routes + - Add UI for role assignment + +2. **Implement Sharing** + - Create ShareToken model + - Add share generation endpoints + - Add permission validation on share access + +3. **Add Audit Logging** + - Create AuditLog model + - Log all CRUD operations + - Track user actions + +4. **Multi-Environment** + - Separate env configs (dev, staging, prod) + - Add database migrations + - Document deployment process + +5. **API Security** + - Add rate limiting + - Implement CORS policies + - Add request validation schemas + +--- + +## Documentation Generated + +Two comprehensive documents created: + +1. **PROJECT_ANALYSIS.md** (14 sections, 1000+ lines) + - Complete architecture breakdown + - All API endpoint documentation + - Database schema details + - Auth flow explanation + - Deployment information + - Common patterns and gotchas + +2. **QUICK_REFERENCE.md** (Visual guide) + - Architecture diagram + - Quick lookup tables + - Common tasks + - Critical gotchas + - Quick stats + +Both files saved to: `/Users/richard/Documents/code/xieyingeng/MemeStudio/` + diff --git a/EXPLORATION_REPORT.md b/EXPLORATION_REPORT.md new file mode 100644 index 0000000..481f66a --- /dev/null +++ b/EXPLORATION_REPORT.md @@ -0,0 +1,871 @@ +# MemeStudio Server Project - Thorough Exploration Report + +## 1. PROJECT OVERVIEW + +**Project Name:** Meme Studio (Homophone Pun Game Operation Platform) +**Framework:** Next.js 14 (App Router) +**Type:** Backend/Admin Dashboard for a wordplay game +**Location:** `/Users/richard/Documents/code/xieyingeng/MemeStudio` + +### Tech Stack +- **Framework**: Next.js 14 App Router +- **Runtime**: Node.js (Standalone output) +- **Language**: TypeScript +- **ORM**: Prisma v6.5.0 +- **Database**: MySQL +- **Authentication**: Better Auth v1.2.7 (email/password with Prisma adapter) +- **Frontend UI**: shadcn/ui + Tailwind CSS +- **State Management**: TanStack Query v5.69.0 +- **Drag & Drop**: @dnd-kit/sortable +- **File Upload**: Tencent COS (Cloud Object Storage) +- **Password Hashing**: bcryptjs v3.0.2 +- **Process Manager**: PM2 (ecosystem.config.js) + +--- + +## 2. PROJECT STRUCTURE + +``` +MemeStudio/ +โ”œโ”€โ”€ app/ # Next.js App Router +โ”‚ โ”œโ”€โ”€ (auth)/ # Auth routes (no sidebar) +โ”‚ โ”‚ โ”œโ”€โ”€ layout.tsx +โ”‚ โ”‚ โ””โ”€โ”€ login/page.tsx +โ”‚ โ”œโ”€โ”€ (dashboard)/ # Protected routes (with sidebar) +โ”‚ โ”‚ โ”œโ”€โ”€ layout.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ levels/page.tsx # Game levels management +โ”‚ โ”‚ โ”œโ”€โ”€ users/page.tsx # Admin user management +โ”‚ โ”‚ โ””โ”€โ”€ wx-users/page.tsx # WeChat mini program users +โ”‚ โ”œโ”€โ”€ api/ # API routes +โ”‚ โ”‚ โ”œโ”€โ”€ auth/[...all]/ # Better Auth endpoints +โ”‚ โ”‚ โ”œโ”€โ”€ levels/ # Level CRUD +โ”‚ โ”‚ โ”œโ”€โ”€ users/ # User CRUD +โ”‚ โ”‚ โ”œโ”€โ”€ wx-users/ # WeChat user endpoints +โ”‚ โ”‚ โ”œโ”€โ”€ cos/temp-key/ # Tencent COS credentials +โ”‚ โ”‚ โ””โ”€โ”€ v1/wechat-game/ # (empty, future API) +โ”‚ โ”œโ”€โ”€ layout.tsx # Root layout +โ”‚ โ”œโ”€โ”€ page.tsx # Root page (redirects to /dashboard) +โ”‚ โ”œโ”€โ”€ providers.tsx # Client providers (Query, etc) +โ”‚ โ””โ”€โ”€ globals.css +โ”œโ”€โ”€ lib/ # Core utilities +โ”‚ โ”œโ”€โ”€ auth.ts # Better Auth config +โ”‚ โ”œโ”€โ”€ auth-client.ts # Client-side auth hooks +โ”‚ โ”œโ”€โ”€ prisma.ts # Prisma client singleton +โ”‚ โ”œโ”€โ”€ api.ts # API fetch wrapper with basePath +โ”‚ โ”œโ”€โ”€ cos.ts # Tencent COS utilities +โ”‚ โ””โ”€โ”€ utils.ts # UI utilities (cn()) +โ”œโ”€โ”€ components/ # React components +โ”‚ โ”œโ”€โ”€ ui/ # shadcn/ui components (button, card, dialog, input, etc) +โ”‚ โ”œโ”€โ”€ layout/ # Header, sidebar +โ”‚ โ”œโ”€โ”€ levels/ # Level-related components +โ”‚ โ”œโ”€โ”€ users/ # User dialog +โ”‚ โ””โ”€โ”€ wx-users/ # WeChat user detail dialog +โ”œโ”€โ”€ types/index.ts # TypeScript interfaces +โ”œโ”€โ”€ middleware.ts # Next.js middleware (session check) +โ”œโ”€โ”€ prisma/ +โ”‚ โ”œโ”€โ”€ schema.prisma # Prisma schema +โ”‚ โ””โ”€โ”€ seed.ts # Admin user seeding +โ”œโ”€โ”€ public/ # Static assets +โ”œโ”€โ”€ next.config.js # Next.js config (basePath, COS remotePatterns) +โ”œโ”€โ”€ tsconfig.json +โ”œโ”€โ”€ tailwind.config.ts +โ”œโ”€โ”€ package.json +โ”œโ”€โ”€ ecosystem.config.js # PM2 config +โ””โ”€โ”€ deploy.sh # Deployment script +``` + +--- + +## 3. AUTHENTICATION & MIDDLEWARE + +### Middleware Pattern (`middleware.ts`) + +```typescript +// Non-blocking: API routes, static files, login page +// Blocking: All dashboard pages require session cookie + +// Cookie detection: +- 'better-auth.session_token' (HTTP) +- '__Secure-better-auth.session_token' (HTTPS) + +// Redirect pattern: +- Unauthenticated users โ†’ /login?callbackUrl=/original/path +- Note: basePath (/studio) is NOT included in pathname within middleware +``` + +### Auth Implementation (`lib/auth.ts`) + +```typescript +betterAuth({ + database: prismaAdapter(prisma), + emailAndPassword: { enabled: true }, + basePath: '/api/auth', // CRITICAL: NO basePath prefix here + secret: process.env.BETTER_AUTH_SECRET, + session: { + expiresIn: 7 days, + updateAge: 1 day, + }, +}) +``` + +### Key Auth Files +- `lib/auth.ts` - Server-side Better Auth configuration +- `lib/auth-client.ts` - Client-side hooks: `useSession`, `signIn`, `signOut` +- `middleware.ts` - Edge Runtime middleware (cannot use Prisma, only cookie check) +- `app/api/auth/[...all]/route.ts` - Better Auth endpoints + +--- + +## 4. DATABASE SCHEMA (Prisma) + +### Models + +#### 1. **User** (Admin/staff users) +```prisma +model User { + id String @id @default(uuid()) + email String @unique + emailVerified Boolean @default(false) + name String? + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + sessions Session[] + accounts Account[] +} +``` + +#### 2. **Session** (Better Auth) +```prisma +model Session { + id String @id + expiresAt DateTime + token String @unique # Critical for session validation + ipAddress String? + userAgent String? + userId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} +``` + +#### 3. **Account** (Provider credentials) +```prisma +model Account { + id String @id @default(uuid()) + accountId String + providerId String # 'credential' for email/password + userId String + password String? # Only for 'credential' provider + accessToken String? + refreshToken String? + # ... expires, scope, tokens + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} +``` + +#### 4. **Verification** (Email verification tokens) +```prisma +model Verification { + id String @id @default(uuid()) + identifier String + value String + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +#### 5. **Level** (Game levels) +```prisma +model Level { + id String @id @default(uuid()) + imageUrl String # COS URL + answer String # Correct answer (Chinese pun text) + hint1 String? + hint2 String? + hint3 String? + sortOrder Int @default(0) # Display order + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + userProgress WxUserLevelProgress[] +} +``` + +#### 6. **WxUser** (WeChat mini program players) +```prisma +model WxUser { + id String @id @default(uuid()) + openid String @unique # WeChat OpenID + sessionKey String? # WeChat session key + nickname String? + avatarUrl String? + points Int @default(10) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + levelProgress WxUserLevelProgress[] +} +``` + +#### 7. **WxUserLevelProgress** (Game progress tracking) +```prisma +model WxUserLevelProgress { + id String @id @default(uuid()) + userId String + levelId String + completedAt DateTime @default(now()) # When level was completed + + user WxUser @relation(fields: [userId], references: [id], onDelete: Cascade) + level Level @relation(fields: [levelId], references: [id]) +} +``` + +### Database Configuration +- **Provider**: MySQL +- **Binary Targets**: `["native", "rhel-openssl-3.0.x"]` (macOS dev โ†’ Linux server compatibility) +- **Environment Variable**: `DATABASE_URL=mysql://...` + +--- + +## 5. API ROUTES & ENDPOINTS + +### 5.1 Authentication Endpoints +**Route:** `/api/auth/[...all]` +**Handler:** Better Auth (`toNextJsHandler(auth)`) +**Methods:** GET, POST +**Auth Required:** No (this is the auth endpoint itself) + +Common endpoints: +- `POST /api/auth/sign-in/email` - Email sign in +- `POST /api/auth/sign-out` - Sign out +- `POST /api/auth/sign-up/email` - Create new account (might be disabled) + +--- + +### 5.2 Levels API +**Route:** `/api/levels` +**Auth Required:** Yes (session check) + +#### GET - Fetch all levels +```typescript +GET /api/levels +Response: Level[] +Sorting: By sortOrder (ASC) +``` + +#### POST - Create level +```typescript +POST /api/levels +Body: { + imageUrl: string (required), + answer: string (required), + hint1?: string, + hint2?: string, + hint3?: string +} +Response: Level (with auto-generated UUID, sortOrder = max+1) +``` + +#### PUT - Update level +```typescript +PUT /api/levels +Body: { + id: string (required), + imageUrl?: string, + answer?: string, + hint1?: string, + hint2?: string, + hint3?: string +} +Response: Updated Level +``` + +#### DELETE - Delete level +```typescript +DELETE /api/levels?id= +Response: { success: true } +``` + +--- + +### 5.3 Levels Reorder API +**Route:** `/api/levels/reorder` +**Auth Required:** Yes + +#### PUT - Batch update sort order +```typescript +PUT /api/levels/reorder +Body: { + orders: Array<{ id: string, sortOrder: number }> +} +Response: { success: true } +Implementation: Prisma $transaction (atomic operation) +``` + +**Use Case:** Drag & drop reordering in UI + +--- + +### 5.4 Users API (Admin) +**Route:** `/api/users` +**Auth Required:** Yes +**Purpose:** Manage admin/staff accounts + +#### GET - Fetch all users +```typescript +GET /api/users +Response: Array<{ + id, email, emailVerified, name, image, createdAt, updatedAt +}> +Sorting: By createdAt DESC +``` + +#### POST - Create new admin user +```typescript +POST /api/users +Body: { + email: string (required), + password: string (required), + name?: string +} +Response: User object +- Auto-generates UUID for user ID +- Hashes password with better-auth/crypto +- Creates User + Account records in transaction +- Error: "่ฏฅ้‚ฎ็ฎฑๅทฒ่ขซๆณจๅ†Œ" if email exists +``` + +#### PUT - Update user +```typescript +PUT /api/users +Body: { + id: string (required), + email?: string, + password?: string, + name?: string +} +Response: Updated User +- Can update email (checks uniqueness vs other users) +- Can update password (hashes with better-auth) +- Transaction: Updates User + Account records +``` + +#### DELETE - Delete user +```typescript +DELETE /api/users?id= +Response: { success: true } +- Prevents self-deletion: "ไธ่ƒฝๅˆ ้™ค่‡ชๅทฑ็š„่ดฆๆˆท" +- Cascading delete via Prisma relations +``` + +**Password Hashing:** Always uses `hashPassword` from `better-auth/crypto` for consistency + +--- + +### 5.5 WeChat Users API +**Route:** `/api/wx-users` +**Auth Required:** Yes +**Purpose:** Manage WeChat mini program player accounts + +#### GET - List WeChat users (paginated + search) +```typescript +GET /api/wx-users?search=&page=<1>&limit=<20> +Response: { + users: Array, + meta: { total, page, limit, totalPages } +} +Filtering: Search by nickname OR openid (contains search) +Sorting: By createdAt DESC +Dynamic Rendering: export const dynamic = 'force-dynamic' +``` + +--- + +### 5.6 WeChat User Detail +**Route:** `/api/wx-users/[id]` +**Auth Required:** Yes +**Method:** GET + +```typescript +GET /api/wx-users/ +Response: WxUser with nested levelProgress array +Include: +- All WxUser fields +- levelProgress: Array of { + id, + userId, + levelId, + completedAt, + level: { id, answer } + } +Sorting: levelProgress by completedAt DESC +``` + +--- + +### 5.7 WeChat User Level Progress +**Route:** `/api/wx-users/level-progress` +**Auth Required:** Yes +**Method:** DELETE + +```typescript +DELETE /api/wx-users/level-progress +Body: { + ids: Array # Array of progress record IDs +} +Response: { deleted: number } +Validation: ids must be non-empty array of strings +``` + +**Use Case:** Batch delete player progress (e.g., reset levels) + +--- + +### 5.8 Tencent COS Credentials +**Route:** `/api/cos/temp-key` +**Auth Required:** Yes +**Method:** GET + +```typescript +GET /api/cos/temp-key +Response: { + credentials: { + tmpSecretId: string, + tmpSecretKey: string, + sessionToken: string + }, + startTime: number, + expiredTime: number, + bucket: string, + region: string +} +``` + +**Implementation Details:** +- Uses `qcloud-cos-sts` library +- Policy allows: `cos:PutObject`, `cos:PostObject` +- Limited to: `mini_game/images/*` directory +- Duration: 30 minutes (1800 seconds) +- Returns bucket config for frontend upload + +--- + +### 5.9 Empty/Stub Routes +- `/api/v1/wechat-game/` - Directory structure exists but no handlers + +--- + +## 6. LIB UTILITIES + +### 6.1 `lib/auth.ts` - Better Auth Configuration +```typescript +export const auth = betterAuth({ + database: prismaAdapter(prisma), + emailAndPassword: { enabled: true }, + basePath: '/api/auth', // CRITICAL GOTCHA + trustedOrigins: [process.env.BETTER_AUTH_URL], + secret: process.env.BETTER_AUTH_SECRET, + session: { expiresIn: 7 days, updateAge: 1 day } +}) +export type Auth = typeof auth +``` + +**Critical Gotchas:** +1. `basePath` must be `/api/auth` (NOT `/studio/api/auth`) +2. `BETTER_AUTH_URL` must be origin-only (NOT include path) +3. Better Auth's `withPath()` silently ignores basePath if URL has path component + +--- + +### 6.2 `lib/auth-client.ts` - Client Auth Hooks +```typescript +const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '/studio' +const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001' + +export const authClient = createAuthClient({ + baseURL: `${appUrl}${basePath}/api/auth` +}) + +export const { signIn, signOut, useSession } = authClient +``` + +**Usage:** +- `useSession()` - Hook to get current session +- `signIn(email, password)` - Email/password login +- `signOut()` - Logout + +--- + +### 6.3 `lib/prisma.ts` - Prisma Singleton +```typescript +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined +} + +export const prisma = globalForPrisma.prisma ?? new PrismaClient() + +if (process.env.NODE_ENV !== 'production') + globalForPrisma.prisma = prisma +``` + +**Purpose:** Prevent multiple Prisma client instances in dev mode + +--- + +### 6.4 `lib/api.ts` - API Fetch Helper +```typescript +const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '' + +export function apiFetch(input: string, init?: RequestInit): Promise { + const url = input.startsWith('/') ? `${basePath}${input}` : input + return fetch(url, init) +} +``` + +**Purpose:** Wrapper that auto-prepends basePath to relative URLs (client-side) + +--- + +### 6.5 `lib/cos.ts` - Tencent COS Utilities +```typescript +interface TempKeyResult { + credentials: { + tmpSecretId: string + tmpSecretKey: string + sessionToken: string + } + startTime: number + expiredTime: number +} + +export function getBucketName(): string + // Returns "bucket-appid" format + +export async function getTempKey(): Promise + // STS.getCredential with 30-min duration + // Policy: PUT/POST to mini_game/images/* + +export function getBucketConfig() + // Returns { bucket, region } +``` + +**Environment Variables:** +- `COS_SECRET_ID` +- `COS_SECRET_KEY` +- `COS_BUCKET` +- `COS_REGION` (default: ap-guangzhou) +- `COS_APPID` + +--- + +### 6.6 `lib/utils.ts` - UI Utilities +```typescript +export function cn(...inputs: ClassValue[]) + // Combines clsx + tailwind-merge for class deduplication +``` + +--- + +## 7. ENVIRONMENT VARIABLES + +**Required Variables:** +``` +DATABASE_URL=mysql://user:pass@host:port/dbname +BETTER_AUTH_SECRET=<32+ chars, openssl rand -base64 32> +BETTER_AUTH_URL=http://localhost:3001 # Origin only, NO path +NEXT_PUBLIC_APP_URL=http://localhost:3001 +NEXT_PUBLIC_BASE_PATH=/studio +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=password123 +COS_SECRET_ID= +COS_SECRET_KEY= +COS_BUCKET=bucket-name +COS_REGION=ap-guangzhou +COS_APPID= +``` + +**Production Considerations:** +- `.env.production` exists alongside `.env` +- `BETTER_AUTH_SECRET` should be 32+ characters +- HTTPS deployment adds `__Secure-` cookie prefix +- Database should use SSL connection + +--- + +## 8. DEPLOYMENT ARCHITECTURE + +### Standalone Output +```javascript +// next.config.js +module.exports = { + output: 'standalone', # Self-contained binary + basePath: '/studio', # Reverse proxy path + images: { + remotePatterns: [{ + protocol: 'https', + hostname: '*.myqcloud.com' # Tencent COS pattern + }] + } +} +``` + +### Deployment Flow (deploy.sh) +1. Build locally: `next build` (creates .next/standalone) +2. rsync files to server (excluding node_modules) +3. SSH to server: `npm install --production` +4. Generate Prisma: `npx prisma generate` +5. PM2 restart: `pm2 restart ecosystem.config.js` + +### Server Details +- **Host:** root@119.91.211.52 +- **Path:** /root/apps/meme-studio +- **Process Manager:** PM2 +- **Config:** ecosystem.config.js + +### PM2 Configuration +```javascript +// ecosystem.config.js (typical setup) +module.exports = { + apps: [{ + name: 'meme-studio', + script: './.next/standalone/server.js', + instances: 1, + exec_mode: 'cluster' + }] +} +``` + +--- + +## 9. CRITICAL GOTCHAS & IMPORTANT NOTES + +### 9.1 basePath Handling +- โœ… `next.config.js` sets `basePath: '/studio'` +- โœ… All pages/API routes served under `/studio/...` +- โŒ **NEVER** include `/studio` in `BETTER_AUTH_URL` or hardcoded paths +- โš ๏ธ `request.nextUrl.pathname` in middleware EXCLUDES basePath (shows `/levels` not `/studio/levels`) +- โš ๏ธ Next.js strips basePath from `request.url` in route handlers + +### 9.2 Authentication Patterns +- โœ… Session validation: `await auth.api.getSession({ headers: request.headers })` +- โœ… Session check in middleware uses cookies (Edge Runtime compatible) +- โš ๏ธ Password hashing: ALWAYS use `hashPassword` from `better-auth/crypto` +- โš ๏ธ Session token field has unique constraint in database + +### 9.3 Database & ORM +- โœ… Prisma adapter with MySQL provider +- โœ… Binary targets: `["native", "rhel-openssl-3.0.x"]` for cross-platform deployment +- โš ๏ธ Middleware cannot use Prisma (Edge Runtime incompatible) +- โš ๏ธ Transaction usage for consistency: `prisma.$transaction([...])` + +### 9.4 File Upload (COS) +- โœ… Frontend gets temporary credentials via `/api/cos/temp-key` +- โœ… Browser uploads directly to COS (S3-compatible) +- โœ… Limited to `mini_game/images/*` directory by policy +- โš ๏ธ Policy duration: 30 minutes (1800 seconds) + +### 9.5 Git & Development +- โš ๏ธ **Commit messages must be written in Chinese** +- โœ… ESLint configured: `npm run lint` +- โœ… Dev server runs on port 3001: `npm run dev` +- โœ… Database seeding: `npm run db:seed` (creates/updates admin user) + +--- + +## 10. SHARE/INVITE LOGIC + +**Current Status:** โŒ **NO existing share or invite functionality** + +No database models, API endpoints, or UI components for: +- Sharing levels with other users +- Generating shareable links/tokens +- Inviting users to teams or projects +- Publishing/unpublishing levels +- Level access control + +**All levels are:** +- Managed by admin users +- Accessible to all authenticated admins +- Automatically available in the WeChat mini program + +--- + +## 11. KEY PATTERNS USED + +### 11.1 API Route Pattern +All protected API routes follow: +```typescript +import { auth } from '@/lib/auth' + +export async function GET(request: NextRequest) { + const session = await auth.api.getSession({ headers: request.headers }) + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + // ... handler logic +} +``` + +### 11.2 Error Handling +- 400: Bad request (validation errors) +- 401: Unauthorized (no session) +- 404: Not found (resource doesn't exist) +- 500: Server error (logged to console) + +### 11.3 Response Format +- Success: `NextResponse.json(data)` +- Error: `NextResponse.json({ error: string }, { status: number })` +- Batch operations: `{ success: true }` or `{ deleted: count }` + +### 11.4 Transaction Pattern +```typescript +await prisma.$transaction(async (tx) => { + const user = await tx.user.create({ data: {...} }) + await tx.account.create({ data: {...} }) + return user +}) +``` + +### 11.5 Pagination Pattern +```typescript +const skip = (page - 1) * limit +const [data, total] = await Promise.all([ + prisma.model.findMany({ skip, take: limit }), + prisma.model.count() +]) +return { data, meta: { total, page, limit, totalPages: Math.ceil(total/limit) } } +``` + +--- + +## 12. FILE STRUCTURE SUMMARY + +| File | Purpose | +|------|---------| +| `middleware.ts` | Session validation, redirect to login | +| `next.config.js` | basePath, COS image patterns, standalone output | +| `lib/auth.ts` | Better Auth server configuration | +| `lib/auth-client.ts` | Client-side auth hooks | +| `lib/prisma.ts` | Prisma singleton | +| `lib/api.ts` | Fetch wrapper with basePath | +| `lib/cos.ts` | Tencent COS STS credentials | +| `prisma/schema.prisma` | Database schema (7 models) | +| `prisma/seed.ts` | Admin user seeding | +| `app/api/auth/[...all]/route.ts` | Better Auth endpoints | +| `app/api/levels/route.ts` | Level CRUD (GET, POST, PUT, DELETE) | +| `app/api/levels/reorder/route.ts` | Batch reorder (PUT) | +| `app/api/users/route.ts` | Admin user CRUD (GET, POST, PUT, DELETE) | +| `app/api/wx-users/route.ts` | List WeChat users (GET, paginated) | +| `app/api/wx-users/[id]/route.ts` | Get user + progress (GET) | +| `app/api/wx-users/level-progress/route.ts` | Delete progress (DELETE) | +| `app/api/cos/temp-key/route.ts` | Get COS credentials (GET) | +| `app/(dashboard)/levels/page.tsx` | Levels management UI | +| `app/(dashboard)/users/page.tsx` | Admin users management UI | +| `app/(dashboard)/wx-users/page.tsx` | WeChat users viewer UI | +| `app/(auth)/login/page.tsx` | Login page | +| `types/index.ts` | TypeScript interfaces (Level, User, WxUser, etc) | + +--- + +## 13. COMPONENT STRUCTURE + +**UI Components (shadcn/ui):** +- `button.tsx` - Button component +- `card.tsx` - Card container +- `dialog.tsx` - Modal dialog +- `input.tsx` - Text input +- `label.tsx` - Form label +- `textarea.tsx` - Textarea input +- `spinner.tsx` - Loading spinner + +**Layout Components:** +- `header.tsx` - Top navigation header +- `sidebar.tsx` - Left sidebar with navigation + +**Feature Components:** +- `levels/level-dialog.tsx` - Create/edit level modal +- `levels/level-list.tsx` - Drag-and-drop sortable list +- `levels/level-card.tsx` - Individual level card +- `levels/image-uploader.tsx` - COS image upload +- `users/user-dialog.tsx` - Create/edit admin user modal +- `wx-users/wx-user-detail-dialog.tsx` - View WeChat user progress + +--- + +## 14. DATA FLOW EXAMPLES + +### Example 1: Create Level +1. Admin clicks "Add Level" button +2. UI opens `level-dialog.tsx` modal +3. Admin fills form (imageUrl, answer, hints) +4. `image-uploader.tsx` gets temp COS credentials from `/api/cos/temp-key` +5. Browser uploads image directly to COS +6. Admin submits form +7. API calls `POST /api/levels` with imageUrl, answer, hints +8. Backend validates session, creates Level record +9. sortOrder auto-calculated (max+1) +10. Level appears in list (sorted by sortOrder) + +### Example 2: Reorder Levels +1. Admin drags level up/down in `level-list.tsx` +2. @dnd-kit/sortable updates UI state +3. Admin confirms or auto-save triggers +4. API calls `PUT /api/levels/reorder` with reordered IDs +5. Backend updates sortOrder for all levels in transaction +6. List re-renders in new order + +### Example 3: View WeChat Player +1. Admin opens "WeChat Users" dashboard page +2. `wx-users/page.tsx` calls `GET /api/wx-users?page=1&limit=20` +3. Backend queries paginated WxUser list +4. Admin clicks on player name +5. Opens `wx-user-detail-dialog.tsx` +6. Component calls `GET /api/wx-users/` +7. Backend returns WxUser + nested levelProgress array +8. Dialog displays player info and completed levels + +--- + +## 15. SECURITY CONSIDERATIONS + +### Implemented +- โœ… Session-based authentication (Better Auth) +- โœ… Password hashing (bcryptjs via Better Auth) +- โœ… CSRF protection (Better Auth built-in) +- โœ… Middleware session validation +- โœ… COS temporary credentials (limited scope, 30-min expiry) +- โœ… SQL injection protection (Prisma ORM) +- โœ… Email uniqueness constraints + +### Not Implemented +- โŒ Role-based access control (all authenticated users have admin access) +- โŒ Audit logging +- โŒ Rate limiting +- โŒ Input validation/sanitization (rely on Prisma types) +- โŒ API key authentication (only cookie-based) +- โŒ CORS configuration (not needed for same-origin) + +--- + +## SUMMARY + +MemeStudio is a **well-structured Next.js admin dashboard** for managing a WeChat mini program game. It has: + +- **Clean Auth**: Better Auth with Prisma adapter, session-based +- **Full CRUD**: Levels, admin users, WeChat player tracking +- **Cloud Integration**: Tencent COS for image uploads +- **Scalable ORM**: Prisma with MySQL +- **No Sharing Logic**: All data is globally accessible to admins + +**For Adding Share/Invite Feature:** +- Add `SharedLevel` or `LevelAccess` model (permissions) +- Add `InviteToken` model (temporary join links) +- Add `/api/invites` endpoints (generate, accept, list) +- Add role/permission fields to User model +- Add UI for managing shares/invites + diff --git a/EXPLORATION_SUMMARY.txt b/EXPLORATION_SUMMARY.txt new file mode 100644 index 0000000..c9f9718 --- /dev/null +++ b/EXPLORATION_SUMMARY.txt @@ -0,0 +1,364 @@ +================================================================================ + MEMESTUDIO PROJECT EXPLORATION COMPLETE +================================================================================ + +PROJECT: Meme Studio - ่ฐ้Ÿณๆข—ๅฐๆธธๆˆ่ฟ่ฅๅนณๅฐ (Homophone Pun Game Admin Platform) +DATE: April 6, 2026 +STATUS: โœ… THOROUGHLY ANALYZED + +================================================================================ + QUICK FACTS +================================================================================ + +Framework: Next.js 14.2.28 (App Router) +Backend Language: TypeScript +Database: MySQL + Prisma ORM v6.5.0 +Authentication: Better Auth v1.2.7 (email/password) +Session Duration: 7 days +Cloud Storage: Tencent COS (with STS temp credentials) +UI Framework: shadcn/ui + Tailwind CSS +State Management: TanStack React Query +Deployment: PM2 on Linux server (119.91.211.52) +Reverse Proxy: Behind /studio basePath + +================================================================================ + DATABASE MODELS (7 Total) +================================================================================ + +1. User - Admin/portal users (Better Auth) +2. Session - Auth sessions (7-day expiry, Better Auth) +3. Account - Auth accounts with password hashing +4. Verification - Email verification tokens +5. Level - Game levels (image, answer, 3 hints, sortOrder) +6. WxUser - WeChat mini-program users (openid, points) +7. WxUserLevelProgress - User level completion tracking + +================================================================================ + API ENDPOINTS (14 Total) +================================================================================ + +AUTHENTICATION (Better Auth - Catch-all) + โ€ข POST /api/auth/[...all] - All auth routes + +LEVELS (4 endpoints) + โ€ข GET /api/levels - List all levels + โ€ข POST /api/levels - Create level + โ€ข PUT /api/levels - Update level + โ€ข DELETE /api/levels?id=... - Delete level + โ€ข PUT /api/levels/reorder - Batch reorder (transaction) + +USERS (4 endpoints) + โ€ข GET /api/users - List admin users + โ€ข POST /api/users - Create user + โ€ข PUT /api/users - Update user/password + โ€ข DELETE /api/users?id=... - Delete user + +WECHAT USERS (3 endpoints) + โ€ข GET /api/wx-users?search=...&page=... - List with pagination + โ€ข GET /api/wx-users/[id] - Get user + progress + โ€ข DELETE /api/wx-users/level-progress - Batch delete progress + +CLOUD STORAGE (1 endpoint) + โ€ข GET /api/cos/temp-key - Get 30-min COS credentials + +================================================================================ + PROTECTED PAGES (3) +================================================================================ + +1. /levels - Drag-and-drop level management (add/edit/delete) +2. /users - Admin user management +3. /wx-users - WeChat user viewing + progress history + +Public Pages: /login (before auth) + +================================================================================ + KEY LIBRARIES & UTILITIES +================================================================================ + +lib/auth.ts - Better Auth configuration +lib/auth-client.ts - Client-side auth hooks (useSession, signIn, signOut) +lib/prisma.ts - Prisma singleton pattern +lib/api.ts - apiFetch() helper (auto-adds basePath) +lib/cos.ts - Tencent COS utilities (getTempKey, getBucketName) +lib/utils.ts - Tailwind CSS cn() utility + +================================================================================ + AUTHENTICATION FLOW +================================================================================ + +1. User visits /login +2. Enters email/password +3. POST /api/auth/sign-in (Better Auth) +4. Session created in DB, cookie set (7 days) +5. Middleware checks cookie on page navigation +6. If valid cookie โ†’ Allow access +7. If missing/invalid โ†’ Redirect to login +8. All API calls validate session before proceeding + +Session Token Cookies: + - HTTP: "better-auth.session_token" + - HTTPS: "__Secure-better-auth.session_token" + +================================================================================ + CRITICAL CONFIGURATION NOTES +================================================================================ + +โš ๏ธ BASEPATH HANDLING (Next.js 14) + - request.nextUrl.pathname EXCLUDES basePath + - Middleware checks "/levels", not "/studio/levels" + - apiFetch() automatically prepends basePath + - DO NOT manually add "/studio" to routes + +โš ๏ธ BETTER AUTH CONFIGURATION + - BETTER_AUTH_URL must be origin ONLY (no path) + - โœ… Correct: "https://domain.com" + - โŒ Wrong: "https://domain.com/studio" + - basePath in config must be "/api/auth" (not "/studio/api/auth") + +โš ๏ธ ENVIRONMENT VARIABLES CRITICAL + - DATABASE_URL: MySQL connection string + - BETTER_AUTH_SECRET: 32+ character random string + - BETTER_AUTH_URL: Origin only, NO path component + - NEXT_PUBLIC_APP_URL: Same as BETTER_AUTH_URL + - NEXT_PUBLIC_BASE_PATH: "/studio" + +โš ๏ธ IMAGE UPLOAD FLOW + - Frontend requests temp credentials: GET /api/cos/temp-key + - Frontend uploads directly to COS (not through backend) + - Backend stores URL in database only + - Credentials valid for 30 minutes + - Restricted to "mini_game/images/*" path prefix + +================================================================================ + PROJECT STRUCTURE OVERVIEW +================================================================================ + +app/ + โ”œโ”€โ”€ (auth)/ - Route group: login page (no sidebar) + โ”œโ”€โ”€ (dashboard)/ - Route group: protected pages (with sidebar) + โ”‚ โ”œโ”€โ”€ levels/page.tsx - Level management UI + โ”‚ โ”œโ”€โ”€ users/page.tsx - User management UI + โ”‚ โ””โ”€โ”€ wx-users/page.tsx - WeChat users UI + โ””โ”€โ”€ api/ - All API routes (protected) + +lib/ + โ”œโ”€โ”€ auth.ts - Better Auth config + โ”œโ”€โ”€ auth-client.ts - Client hooks + โ”œโ”€โ”€ prisma.ts - Singleton + โ”œโ”€โ”€ cos.ts - COS utilities + โ”œโ”€โ”€ api.ts - apiFetch wrapper + โ””โ”€โ”€ utils.ts - Tailwind utilities + +components/ + โ”œโ”€โ”€ layout/ - Header, sidebar + โ”œโ”€โ”€ levels/ - Level components + โ”œโ”€โ”€ users/ - User components + โ”œโ”€โ”€ wx-users/ - WeChat user components + โ””โ”€โ”€ ui/ - shadcn/ui components + +prisma/ + โ””โ”€โ”€ schema.prisma - Database schema (7 models) + +types/ + โ””โ”€โ”€ index.ts - TypeScript interfaces + +================================================================================ + SECURITY FEATURES +================================================================================ + +โœ… Session-Based Authentication +โœ… Password Hashing (bcryptjs + better-auth/crypto) +โœ… Cookie Validation (handles HTTP and HTTPS prefixes) +โœ… Self-Delete Prevention (can't delete own account) +โœ… Temporary COS Credentials (30-minute expiry) +โœ… Session Expiration (7 days) +โœ… Cascade Delete (sessions/accounts deleted with user) + +โŒ NOT IMPLEMENTED: + - Role-based access control (all authenticated = full admin) + - Permission system + - Sharing/invite functionality + - Rate limiting + - Audit logging + - Email verification (model exists, not used) + +================================================================================ + DEPLOYMENT CONFIGURATION +================================================================================ + +Server: root@119.91.211.52 +Installation Path: /root/apps/meme-studio +Process Manager: PM2 +Staging: Behind reverse proxy at /studio + +Next.js Config: + - output: 'standalone' (single binary, bundles Next.js) + - basePath: '/studio' (app served at /studio) + - Remote images: '*.myqcloud.com' (Tencent COS) + +Deployment Steps: + 1. npm run build (locally) + 2. ./deploy.sh (builds, rsyncs, installs, restarts PM2) + +Notes: + - Prisma is devDependency but must be generated on server + - Cross-platform binary targets: ["native", "rhel-openssl-3.0.x"] + - production npm install uses --production (no devDeps) + +================================================================================ + TECHNOLOGY STACK COMPLETE +================================================================================ + +FRONTEND: + - React 18.3.1 + - TypeScript 5.8.2 + - Next.js 14.2.28 (App Router) + - Tailwind CSS 3.4.17 + - shadcn/ui (Radix UI based) + - TanStack React Query 5.69.0 + - React Hook Form 7.54.2 + - Zod 3.24.2 + - @dnd-kit (drag-and-drop) + - lucide-react (icons) + +BACKEND: + - Node.js (Next.js runtime) + - Better Auth 1.2.7 + - Prisma 6.5.0 + - MySQL (database) + - bcryptjs 3.0.2 + +DEPLOYMENT: + - PM2 (process management) + - rsync (file transfer) + - SSH (remote access) + +CLOUD: + - Tencent COS (object storage) + - Tencent COS STS (temporary credentials) + +================================================================================ + WHAT'S MISSING +================================================================================ + +โŒ SHARE/INVITE SYSTEM + - No share links or tokens + - No invite functionality + - No permission hierarchy + - All authenticated users = full admin access + +โŒ ADVANCED FEATURES + - No role-based access control + - No audit logging + - No soft deletes + - No rate limiting + - No two-factor authentication + - Email verification model unused + +โŒ OPERATIONAL FEATURES + - No backup/restore utilities + - No analytics/reporting + - No notification system + - No scheduled tasks + +================================================================================ + RECOMMENDATIONS +================================================================================ + +If adding Sharing/Invite Feature: + 1. Create ShareToken model with expiry + 2. Add share generation endpoints + 3. Implement permission validation + 4. Add UI for share management + 5. Consider adding role system first + +If adding Permissions: + 1. Add role field to User model (admin, editor, viewer) + 2. Add permission checks in all API routes + 3. Add UI for role assignment + 4. Update middleware for role-based access + +If scaling further: + 1. Add audit logging + 2. Implement rate limiting + 3. Add caching layer (Redis) + 4. Setup database replication + 5. Implement API versioning + +================================================================================ + DOCUMENTATION FILES +================================================================================ + +Three comprehensive documentation files have been created: + +1. PROJECT_ANALYSIS.md (27 KB) + - 14 detailed sections + - Complete API reference + - Database schema breakdown + - Auth flow explanation + - Deployment guide + - Common patterns + - Gotchas and best practices + +2. QUICK_REFERENCE.md (7.8 KB) + - Visual architecture diagram + - Quick lookup tables + - Common tasks + - Critical gotchas + - Quick stats summary + +3. EXPLORATION_MANIFEST.md (8.1 KB) + - Files analyzed checklist + - Key findings summary + - Technology stacks + - Health indicators + - Next steps for development + +All files located in: /Users/richard/Documents/code/xieyingeng/MemeStudio/ + +================================================================================ + EXPLORATION STATISTICS +================================================================================ + +Files Analyzed: 30+ +Total Lines Read: 5000+ lines of code +API Routes: 14 endpoints across 8 files +Components: 15+ React components +Utility Files: 6 core utilities +Database Models: 7 Prisma models +Frontend Pages: 4 main pages +UI Components: 10+ shadcn/ui components + +Code Quality: Excellent + โœ… Well-organized structure + โœ… Consistent naming + โœ… Type-safe throughout + โœ… Good error handling + โœ… Clean component composition + โœ… Proper auth patterns + โœ… Transaction support + +================================================================================ + CONCLUSION +================================================================================ + +The MemeStudio project is a well-structured, production-ready admin platform +for managing game levels and tracking user progress. The codebase demonstrates: + +โœ… Best practices in Next.js 14 development +โœ… Proper authentication implementation +โœ… Clean API design patterns +โœ… Good database schema design +โœ… Solid TypeScript usage +โœ… Proper deployment configuration + +The main gap is the absence of a sharing/invite system and permission +hierarchy, which are not currently implemented. All authenticated users +have full admin access to all features. + +Perfect starting point for adding collaborative features or expanding +the admin panel with more sophisticated access controls. + +================================================================================ + END OF EXPLORATION REPORT +================================================================================ diff --git a/PROJECT_ANALYSIS.md b/PROJECT_ANALYSIS.md new file mode 100644 index 0000000..1a23673 --- /dev/null +++ b/PROJECT_ANALYSIS.md @@ -0,0 +1,944 @@ +# MemeStudio Server Project - Comprehensive Analysis + +## 1. PROJECT OVERVIEW + +**Project Name**: Meme Studio (่ฐ้Ÿณๆข—ๅฐๆธธๆˆ่ฟ่ฅๅนณๅฐ) +**Description**: A homophone pun game operation platform built with Next.js 14 for level configuration management +**Current Version**: 0.1.0 +**Key Purpose**: Management platform for game levels, users, and mini-program users + +--- + +## 2. FRAMEWORK & TECH STACK + +### Core Framework +- **Next.js**: v14.2.28 (App Router) +- **Runtime**: Node.js (Standalone output mode) +- **Language**: TypeScript 5.8.2 +- **Build Output**: `output: 'standalone'` (for server deployment) + +### Authentication & Database +- **Auth System**: Better Auth v1.2.7 + - Method: Email/Password with Prisma adapter + - Session Duration: 7 days + - Session Update Age: 1 day + - Database Provider: MySQL via Prisma +- **ORM**: Prisma v6.5.0 +- **Database**: MySQL +- **Adapter**: Better Auth Prisma adapter + +### UI & Styling +- **UI Components**: shadcn/ui (Radix UI based) +- **CSS Framework**: Tailwind CSS v3.4.17 +- **Form Handling**: react-hook-form v7.54.2 + Zod validation +- **State Management**: TanStack React Query v5.69.0 +- **Drag & Drop**: @dnd-kit (core v6.3.1 + sortable v10.0.0) +- **Icons**: lucide-react v0.483.0 + +### File Storage & CDN +- **Cloud Storage**: Tencent Cloud COS (Object Storage Service) +- **SDK**: + - qcloud-cos-sts v3.1.1 (For temporary credentials) + - cos-nodejs-sdk-v5 v2.14.0 (Server-side) + - cos-js-sdk-v5 v1.10.1 (Client-side) + +### Utilities +- **UUID Generation**: uuid v11.1.0 +- **Password Hashing**: bcryptjs v3.0.2 + better-auth/crypto +- **Form Validation**: Zod v3.24.2 + +--- + +## 3. PROJECT STRUCTURE + +``` +MemeStudio/ +โ”œโ”€โ”€ app/ # Next.js App Router +โ”‚ โ”œโ”€โ”€ (auth)/ # Route group: No sidebar (login page) +โ”‚ โ”‚ โ””โ”€โ”€ login/ +โ”‚ โ”‚ โ””โ”€โ”€ page.tsx +โ”‚ โ”œโ”€โ”€ (dashboard)/ # Route group: With sidebar (protected) +โ”‚ โ”‚ โ”œโ”€โ”€ layout.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ levels/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ page.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ users/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ page.tsx +โ”‚ โ”‚ โ””โ”€โ”€ wx-users/ +โ”‚ โ”‚ โ””โ”€โ”€ page.tsx +โ”‚ โ”œโ”€โ”€ api/ # API Routes +โ”‚ โ”‚ โ”œโ”€โ”€ auth/[...all]/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ route.ts # Better Auth endpoints +โ”‚ โ”‚ โ”œโ”€โ”€ levels/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ route.ts # CRUD endpoints +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ reorder/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ route.ts # Batch update sort order +โ”‚ โ”‚ โ”œโ”€โ”€ cos/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ temp-key/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ route.ts # Tencent COS credentials +โ”‚ โ”‚ โ”œโ”€โ”€ users/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ route.ts # User management CRUD +โ”‚ โ”‚ โ””โ”€โ”€ wx-users/ +โ”‚ โ”‚ โ”œโ”€โ”€ route.ts # Mini-program user list +โ”‚ โ”‚ โ”œโ”€โ”€ [id]/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ route.ts # Single user details +โ”‚ โ”‚ โ””โ”€โ”€ level-progress/ +โ”‚ โ”‚ โ””โ”€โ”€ route.ts # Batch delete progress +โ”‚ โ”œโ”€โ”€ layout.tsx # Root layout +โ”‚ โ”œโ”€โ”€ page.tsx # Home page (redirects to /levels) +โ”‚ โ””โ”€โ”€ providers.tsx # Client providers +โ”‚ +โ”œโ”€โ”€ components/ # React Components +โ”‚ โ”œโ”€โ”€ layout/ +โ”‚ โ”‚ โ”œโ”€โ”€ header.tsx # Header with session info +โ”‚ โ”‚ โ””โ”€โ”€ sidebar.tsx # Navigation sidebar +โ”‚ โ”œโ”€โ”€ levels/ +โ”‚ โ”‚ โ”œโ”€โ”€ level-list.tsx # Drag-and-drop list +โ”‚ โ”‚ โ”œโ”€โ”€ level-card.tsx # Individual level card +โ”‚ โ”‚ โ”œโ”€โ”€ level-dialog.tsx # Create/edit dialog +โ”‚ โ”‚ โ””โ”€โ”€ image-uploader.tsx # COS image upload +โ”‚ โ”œโ”€โ”€ users/ +โ”‚ โ”‚ โ””โ”€โ”€ user-dialog.tsx # User create/edit dialog +โ”‚ โ”œโ”€โ”€ wx-users/ +โ”‚ โ”‚ โ””โ”€โ”€ wx-user-detail-dialog.tsx # Mini-program user details +โ”‚ โ””โ”€โ”€ ui/ # shadcn/ui components +โ”‚ โ”œโ”€โ”€ button.tsx +โ”‚ โ”œโ”€โ”€ input.tsx +โ”‚ โ”œโ”€โ”€ label.tsx +โ”‚ โ”œโ”€โ”€ dialog.tsx +โ”‚ โ”œโ”€โ”€ card.tsx +โ”‚ โ”œโ”€โ”€ textarea.tsx +โ”‚ โ””โ”€โ”€ spinner.tsx +โ”‚ +โ”œโ”€โ”€ lib/ # Utilities & Helpers +โ”‚ โ”œโ”€โ”€ auth.ts # Better Auth configuration +โ”‚ โ”œโ”€โ”€ auth-client.ts # Client-side auth hooks +โ”‚ โ”œโ”€โ”€ prisma.ts # Prisma singleton +โ”‚ โ”œโ”€โ”€ cos.ts # Tencent COS utilities +โ”‚ โ”œโ”€โ”€ api.ts # apiFetch helper with basePath +โ”‚ โ””โ”€โ”€ utils.ts # General utilities (cn for Tailwind) +โ”‚ +โ”œโ”€โ”€ types/ # TypeScript interfaces +โ”‚ โ””โ”€โ”€ index.ts # All data models +โ”‚ +โ”œโ”€โ”€ prisma/ +โ”‚ โ”œโ”€โ”€ schema.prisma # Database schema +โ”‚ โ””โ”€โ”€ seed.ts # Seed script for admin user +โ”‚ +โ”œโ”€โ”€ public/ # Static assets +โ”œโ”€โ”€ middleware.ts # Session validation middleware +โ”œโ”€โ”€ next.config.js # Next.js configuration +โ”œโ”€โ”€ tsconfig.json # TypeScript config +โ”œโ”€โ”€ tailwind.config.ts # Tailwind CSS config +โ”œโ”€โ”€ package.json # Dependencies +โ”œโ”€โ”€ deploy.sh # Deployment script +โ””โ”€โ”€ ecosystem.config.js # PM2 configuration +``` + +--- + +## 4. DATABASE SCHEMA (Prisma) + +### Models Overview + +#### 1. **Level** (Game Levels) +```prisma +model Level { + id String @id @default(uuid()) + imageUrl String @map("image_url") + answer String + hint1 String? + hint2 String? + hint3 String? + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + userProgress WxUserLevelProgress[] # Relationship to progress +} +``` +- Stores game level information +- Each level has one image, one answer, and up to 3 hints +- `sortOrder` is used for drag-and-drop reordering +- Related to WxUserLevelProgress (one-to-many) + +#### 2. **User** (Admin/Portal Users) +```prisma +model User { + id String @id @default(uuid()) + email String @unique + emailVerified Boolean @default(false) + name String? + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + sessions Session[] + accounts Account[] +} +``` +- Better Auth user model +- Email-password authentication +- Can have multiple sessions and accounts + +#### 3. **Session** (Auth Sessions) +```prisma +model Session { + id String @id + expiresAt DateTime + token String @unique + ipAddress String? + userAgent String? + userId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} +``` +- Better Auth session model +- Stores session tokens with IP and user agent info +- 7-day expiration + +#### 4. **Account** (Auth Accounts) +```prisma +model Account { + id String @id @default(uuid()) + accountId String + providerId String + userId String + accessToken String? + refreshToken String? + idToken String? + accessTokenExpires DateTime? + refreshTokenExpires DateTime? + scope String? + password String? # Hash for email/password provider + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} +``` +- Better Auth account model +- Supports multiple auth providers +- Stores hashed passwords for credential provider + +#### 5. **Verification** (Email Verification) +```prisma +model Verification { + id String @id @default(uuid()) + identifier String + value String + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` +- Better Auth verification model for email verification tokens + +#### 6. **WxUser** (Mini-Program Users) +```prisma +model WxUser { + id String @id @default(uuid()) + openid String @unique + sessionKey String? + nickname String? + avatarUrl String? + points Int @default(10) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + levelProgress WxUserLevelProgress[] +} +``` +- Users from WeChat mini-program +- OpenID uniquely identifies users (WeChat standard) +- Stores user points/score +- Related to level progress + +#### 7. **WxUserLevelProgress** (User Progress Tracking) +```prisma +model WxUserLevelProgress { + id String @id @default(uuid()) + userId String + levelId String + completedAt DateTime @default(now()) + + user WxUser @relation(fields: [userId], references: [id], onDelete: Cascade) + level Level @relation(fields: [levelId], references: [id]) +} +``` +- Tracks which levels each WeChat user has completed +- Stores completion timestamp +- Composite relationship between WxUser and Level + +### Database Configuration +- **Provider**: MySQL +- **Connection String**: Defined via `DATABASE_URL` env var +- **Targets**: `["native", "rhel-openssl-3.0.x"]` for cross-platform compatibility (macOS dev โ†’ Linux server) + +--- + +## 5. API ROUTES - COMPREHENSIVE DOCUMENTATION + +### Authentication Routes +#### `POST /api/auth/[...all]` +- **Handler**: Better Auth routes +- **Auth**: Yes (Better Auth handles internally) +- **Purpose**: All authentication endpoints +- **Endpoints Provided**: + - `POST /api/auth/sign-up` - User registration + - `POST /api/auth/sign-in` - User login + - `POST /api/auth/sign-out` - User logout + - `GET /api/auth/session` - Get current session + - etc. (all Better Auth endpoints) + +### Levels Management +#### `GET /api/levels` +- **Auth**: Required (401 if unauthorized) +- **Purpose**: Fetch all game levels +- **Query Params**: None +- **Returns**: Array of Level objects, ordered by sortOrder ASC +- **Response**: + ```json + [ + { + "id": "uuid", + "imageUrl": "https://...", + "answer": "็ญ”ๆกˆ", + "hint1": "ๆ็คบ1", + "hint2": null, + "hint3": null, + "sortOrder": 0, + "createdAt": "2026-04-05T...", + "updatedAt": "2026-04-05T..." + } + ] + ``` + +#### `POST /api/levels` +- **Auth**: Required +- **Purpose**: Create a new game level +- **Request Body**: + ```json + { + "imageUrl": "string (required)", + "answer": "string (required)", + "hint1": "string (optional)", + "hint2": "string (optional)", + "hint3": "string (optional)" + } + ``` +- **Logic**: + - Gets max sortOrder and sets new level to max+1 + - Uses UUID for ID + - Validates required fields (imageUrl, answer) +- **Returns**: Created Level object (201) + +#### `PUT /api/levels` +- **Auth**: Required +- **Purpose**: Update an existing level +- **Request Body**: + ```json + { + "id": "string (required)", + "imageUrl": "string", + "answer": "string", + "hint1": "string", + "hint2": "string", + "hint3": "string" + } + ``` +- **Logic**: Direct update via Prisma +- **Returns**: Updated Level object + +#### `DELETE /api/levels` +- **Auth**: Required +- **Purpose**: Delete a level +- **Query Params**: `id` (string, required) +- **Returns**: `{ "success": true }` + +#### `PUT /api/levels/reorder` +- **Auth**: Required +- **Purpose**: Batch update sort order for drag-and-drop +- **Request Body**: + ```json + { + "orders": [ + { "id": "level-id", "sortOrder": 0 }, + { "id": "level-id", "sortOrder": 1 } + ] + } + ``` +- **Logic**: Uses Prisma transaction to update all in parallel +- **Returns**: `{ "success": true }` + +### Users Management (Admin/Portal Users) +#### `GET /api/users` +- **Auth**: Required +- **Purpose**: Fetch all portal users +- **Query Params**: None +- **Returns**: Array of User objects (ordered by createdAt DESC) +- **Excludes**: Password hash is not returned + +#### `POST /api/users` +- **Auth**: Required +- **Purpose**: Create a new portal user +- **Request Body**: + ```json + { + "email": "string (required)", + "password": "string (required)", + "name": "string (optional)" + } + ``` +- **Logic**: + - Checks if email already exists (error: "่ฏฅ้‚ฎ็ฎฑๅทฒ่ขซๆณจๅ†Œ") + - Hashes password using `better-auth/crypto` + - Creates User + Account in transaction + - Account has providerId="credential" +- **Returns**: Created User object (201) + +#### `PUT /api/users` +- **Auth**: Required +- **Purpose**: Update a portal user +- **Request Body**: + ```json + { + "id": "string (required)", + "email": "string (optional)", + "password": "string (optional)", + "name": "string (optional)" + } + ``` +- **Logic**: + - Validates email not taken by another user + - Updates password if provided (hashing in transaction) + - Uses transaction for consistency +- **Returns**: Updated User object + +#### `DELETE /api/users` +- **Auth**: Required +- **Purpose**: Delete a portal user +- **Query Params**: `id` (string, required) +- **Logic**: + - Prevents deleting yourself (checks session.user.id) + - Error: "ไธ่ƒฝๅˆ ้™ค่‡ชๅทฑ็š„่ดฆๆˆท" +- **Returns**: `{ "success": true }` + +### Mini-Program Users +#### `GET /api/wx-users` +- **Auth**: Required +- **Purpose**: Get WeChat mini-program users with pagination and search +- **Query Params**: + - `search` - Search in nickname or openid (optional) + - `page` - Page number (default: 1) + - `limit` - Results per page (default: 20) +- **Logic**: + - Search: OR condition on nickname.contains and openid.contains + - Returns paginated results with metadata +- **Returns**: + ```json + { + "users": [ + { + "id": "uuid", + "openid": "string", + "nickname": "string", + "avatarUrl": "string", + "points": 10, + "createdAt": "2026-04-05T...", + "updatedAt": "2026-04-05T..." + } + ], + "meta": { + "total": 100, + "page": 1, + "limit": 20, + "totalPages": 5 + } + } + ``` +- **Performance**: Uses `select` to exclude sessionKey + +#### `GET /api/wx-users/[id]` +- **Auth**: Required +- **Purpose**: Get single WeChat user with level progress history +- **Params**: `id` - User UUID +- **Returns**: + ```json + { + "id": "uuid", + "openid": "string", + "nickname": "string", + "avatarUrl": "string", + "points": 10, + "createdAt": "2026-04-05T...", + "updatedAt": "2026-04-05T...", + "levelProgress": [ + { + "id": "uuid", + "userId": "uuid", + "levelId": "uuid", + "completedAt": "2026-04-05T...", + "level": { + "id": "uuid", + "answer": "็ญ”ๆกˆ" + } + } + ] + } + ``` +- **Logic**: Includes levelProgress ordered by completedAt DESC + +#### `DELETE /api/wx-users/level-progress` +- **Auth**: Required +- **Purpose**: Batch delete level progress records +- **Request Body**: + ```json + { + "ids": ["progress-id-1", "progress-id-2"] + } + ``` +- **Logic**: + - Validates ids is non-empty array of strings + - Uses deleteMany with IN clause +- **Returns**: `{ "deleted": 5 }` + +### COS (Tencent Cloud Object Storage) +#### `GET /api/cos/temp-key` +- **Auth**: Required +- **Purpose**: Get temporary credentials for frontend image upload +- **Query Params**: None +- **Logic**: + - Uses Tencent COS STS service + - Generates temp credentials valid for 30 minutes (1800 seconds) + - Restricts upload to `mini_game/images/*` prefix +- **Returns**: + ```json + { + "credentials": { + "tmpSecretId": "string", + "tmpSecretKey": "string", + "sessionToken": "string" + }, + "startTime": 1712345678, + "expiredTime": 1712347478, + "bucket": "lookai-1308511832", + "region": "ap-guangzhou" + } + ``` + +### Authentication Patterns +- **Session Check Pattern**: + ```typescript + const session = await auth.api.getSession({ headers: request.headers }) + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + ``` +- **All routes use the same pattern** (except Better Auth routes) +- **No role/permission system yet** - all authenticated users have full access + +--- + +## 6. MIDDLEWARE & AUTHENTICATION + +### File: `middleware.ts` +**Location**: Root level +**Purpose**: Session validation for protected pages + +**Features**: +- Checks for session cookies on protected pages +- Handles both HTTP and HTTPS cookie naming: + - `better-auth.session_token` (HTTP) + - `__Secure-better-auth.session_token` (HTTPS with Secure prefix) +- Redirects to login if no session +- Allows public access to: + - `/login` page + - `/api/*` routes (API auth handled separately) + - Static assets (`/_next`, `/favicon`, files with extensions) +- **Important**: Middleware runs at Edge Runtime, so **cannot use Prisma** - only cookie check +- Preserves callbackUrl for redirect after login + +**basePath Gotcha**: +- `request.nextUrl.pathname` does NOT include basePath +- Path comparison is done without `/studio` prefix +- Redirect URLs must manually prepend basePath + +### Better Auth Configuration + +**File**: `lib/auth.ts` + +```typescript +export const auth = betterAuth({ + database: prismaAdapter(prisma, { provider: 'mysql' }), + emailAndPassword: { enabled: true }, + basePath: '/api/auth', + trustedOrigins: [process.env.BETTER_AUTH_URL], + secret: process.env.BETTER_AUTH_SECRET, + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days + updateAge: 60 * 60 * 24, // Update token every 1 day of activity + }, +}) +``` + +**Key Points**: +- Uses Prisma adapter for MySQL +- basePath: `/api/auth` (NOT `/studio/api/auth`) +- BETTER_AUTH_URL must be origin only (NO path component) +- Secret must be 32+ characters +- Sessions auto-refresh if 1+ day of activity + +### Client Auth Hooks + +**File**: `lib/auth-client.ts` + +```typescript +export const { signIn, signOut, useSession } = authClient +``` + +Provides: +- `useSession()` hook - Get current session data +- `signIn()` - Login function +- `signOut()` - Logout function +- Automatically uses basePath + appUrl to construct auth endpoint URL + +--- + +## 7. LIBRARY UTILITIES + +### `lib/api.ts` - API Fetch Helper +```typescript +export function apiFetch(input: string, init?: RequestInit): Promise +``` +- Prepends `basePath` to API routes +- Example: `apiFetch('/api/levels')` โ†’ `/studio/api/levels` +- Used throughout all client components + +### `lib/prisma.ts` - Prisma Singleton +```typescript +const prisma = globalForPrisma.prisma ?? new PrismaClient() +``` +- Prevents multiple Prisma instances in development (hot reload) +- Exports single prisma instance for all API routes + +### `lib/cos.ts` - Tencent COS Utilities +**Functions**: +- `getTempKey()` - Get temporary STS credentials + - Duration: 30 minutes + - Policy: Upload only to `mini_game/images/*` +- `getBucketName()` - Format bucket name (appends APPID if needed) +- `getBucketConfig()` - Get bucket and region for SDK + +### `lib/utils.ts` - General Utilities +```typescript +export function cn(...inputs: ClassValue[]) +``` +- Tailwind CSS utility merger (clsx + tailwind-merge) +- Used throughout components for className management + +--- + +## 8. UI COMPONENTS & PATTERNS + +### Component Structure +All components use React patterns: +- **Client Components**: `'use client'` directive +- **Controlled Forms**: State-driven form handling +- **TanStack Query**: For server state management +- **Dialog-based Operations**: Create/Edit in modals + +### Key Components + +#### Pages +1. **Levels Page** (`app/(dashboard)/levels/page.tsx`) + - List all levels with drag-and-drop reordering + - Add/Edit/Delete levels + - Uses TanStack Query mutations + +2. **Users Page** (`app/(dashboard)/users/page.tsx`) + - Table view of portal users + - Add/Edit/Delete users + - Delete prevention (can't delete self) + +3. **WeChat Users Page** (`app/(dashboard)/wx-users/page.tsx`) + - Search and pagination + - Click to view user details + progress history + - Batch delete progress records + +#### UI Components +- **Button**: shadcn/ui with variants (default, outline, ghost) +- **Input/Textarea**: Form inputs +- **Dialog**: Modal dialogs with header, content, footer +- **Spinner**: Loading indicator +- **Card**: Container component + +### Image Upload Flow +1. User clicks upload in level dialog +2. Frontend calls `/api/cos/temp-key` to get credentials +3. Frontend uses Tencent COS SDK to upload directly to COS +4. COS returns image URL +5. URL stored in database + +### Form Submission Pattern +```typescript +// TanStack Query mutation +const mutation = useMutation({ + mutationFn: async (data) => { /* API call */ }, + onSuccess: () => { queryClient.invalidateQueries(...) } +}) + +// On form submit +await mutation.mutateAsync(data) +// Automatic refetch on success +``` + +--- + +## 9. ENVIRONMENT VARIABLES + +### Required Variables + +``` +# Database +DATABASE_URL=mysql://user:password@host:port/database + +# Better Auth +BETTER_AUTH_SECRET=32+ character random string +BETTER_AUTH_URL=https://domain.com (origin only, NO path) + +# Public URLs (Client-side) +NEXT_PUBLIC_APP_URL=https://domain.com (same as BETTER_AUTH_URL) +NEXT_PUBLIC_BASE_PATH=/studio + +# Admin Account (Seed script) +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=secure_password + +# Tencent COS +COS_SECRET_ID=AKID... +COS_SECRET_KEY=... +COS_BUCKET=lookai-1308511832 +COS_REGION=ap-guangzhou +COS_APPID=1308511832 +``` + +### Important Notes +- **BETTER_AUTH_URL**: Must NOT contain path (e.g., โŒ `https://domain.com/studio`, โœ… `https://domain.com`) +- **NEXT_PUBLIC_BASE_PATH**: Used client-side for apiFetch +- **COS_BUCKET**: Can include or exclude APPID (utility handles both) + +--- + +## 10. DEPLOYMENT & CONFIGURATION + +### Next.js Configuration (`next.config.js`) +```javascript +{ + output: 'standalone', // Single binary output + basePath: '/studio', // App served at /studio + images: { + remotePatterns: [ + { protocol: 'https', hostname: '*.myqcloud.com' } // Allow COS images + ] + } +} +``` + +### Deployment Setup +**Server**: `root@119.91.211.52` at `/root/apps/meme-studio` +**Process Manager**: PM2 (`ecosystem.config.js`) + +**Deploy Script** (`./deploy.sh`): +1. Build locally: `next build` +2. rsync files (excludes node_modules, .next) +3. SSH to server, run: + - `npm install --production` + - `npx prisma generate` + - Restart PM2 process + +**Key Gotchas**: +- Prisma is devDependency but needs to be generated on server +- `output: 'standalone'` bundles Next.js runtime +- Deployment requires `DATABASE_URL` set on server +- Cross-platform binary target: `rhel-openssl-3.0.x` for Linux server + +--- + +## 11. EXISTING PATTERNS & CONVENTIONS + +### API Route Pattern +```typescript +// 1. Import required modules +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +// 2. Check session +const session = await auth.api.getSession({ headers: request.headers }) +if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + +// 3. Parse request +const body = await request.json() +const { searchParams } = new URL(request.url) + +// 4. Validate input +if (!required_field) return NextResponse.json({ error: 'msg' }, { status: 400 }) + +// 5. Database operation +const result = await prisma.model.operation() + +// 6. Return response +return NextResponse.json(result, { status: 201 }) +``` + +### Mutation Pattern (Client) +```typescript +const mutation = useMutation({ + mutationFn: async (data) => { + const res = await apiFetch('/api/endpoint', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) { + const error = await res.json() + throw new Error(error.error || 'Failed') + } + return res.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['levels'] }) + }, +}) + +mutation.mutate(data) +``` + +### Type Definitions Pattern +```typescript +// Database model +export interface Level { + id: string + imageUrl: string + // ... + createdAt: Date + updatedAt: Date +} + +// Form data (typically a subset) +export interface LevelFormData { + imageUrl: string + answer: string + hint1?: string + // ... (no id, dates for new records) +} +``` + +--- + +## 12. KEY GOTCHAS & IMPORTANT NOTES + +### 1. basePath Handling +- โœ… `request.nextUrl.pathname` excludes basePath (returns `/levels`, not `/studio/levels`) +- โŒ Never manually prepend `/studio` in API routes +- โœ… Client-side: use `apiFetch()` which prepends basePath automatically +- โŒ `router.push()` auto-prepends basePath, don't add manually + +### 2. Better Auth Configuration +- **CRITICAL**: `BETTER_AUTH_URL` must be origin only, NO path component +- Better Auth's `withPath()` silently ignores `basePath` if URL has path +- Cookie naming: HTTPS adds `__Secure-` prefix +- Middleware must check both cookie names + +### 3. Session Validation +- โœ… Middleware: Cookie check only (no Prisma in Edge Runtime) +- โœ… API Routes: Full session validation with `auth.api.getSession()` +- Middleware doesn't verify token validity, only presence + +### 4. Database Relationships +- **No explicit join tables** for many-to-many (not needed here) +- One-to-many: WxUser โ†’ WxUserLevelProgress โ†’ Level +- Cascade delete: Sessions/Accounts deleted when User deleted + +### 5. Image Upload Flow +- Frontend gets temp credentials from `/api/cos/temp-key` +- Frontend uploads directly to COS (not through backend) +- Backend only stores URL +- URLs expire or become invalid if credentials expire + +### 6. Authentication Flow +- Login โ†’ Session created โ†’ Cookie set +- Page navigation โ†’ Middleware checks cookie +- Redirects to login if missing +- All API calls check session in route handler + +### 7. Search Patterns +- WeChat users: `OR` condition on nickname OR openid contains +- Case-sensitive by default (MySQL depends on collation) +- Pagination: `skip = (page - 1) * limit` + +--- + +## 13. ABSENCE OF SHARE/INVITE LOGIC + +**Current Status**: โŒ NO sharing/invite functionality exists + +**What's Missing**: +- No share link/token model in schema +- No invite API endpoint +- No permissions/role system +- All authenticated users have full admin access to: + - Level management + - User management + - WeChat user data + +**Considerations for Implementation**: +- Determine if shares/invites are for: + - Sharing levels with mini-program users? + - Inviting new admins to platform? + - Sharing user progress data? +- Would need new database model +- Permission checks in API routes +- Share link generation and validation +- Expiration logic + +--- + +## 14. RECOMMENDATIONS FOR EXPLORATION + +### For Share/Invite Feature (if needed): +1. **Schema Design**: + - ShareToken model with expiry, permissions + - Or UserRole model for permission levels + +2. **API Endpoints Needed**: + - `POST /api/shares` - Generate share link + - `GET /api/shares/[token]` - Validate share token + - `DELETE /api/shares/[id]` - Revoke share + +3. **Permission System**: + - Add role field to User model + - Permission checks in middleware or API routes + +4. **Frontend Needed**: + - Share dialog component + - Permission management UI + +--- + +## SUMMARY TABLE + +| Category | Item | Details | +|----------|------|---------| +| **Framework** | Next.js | 14.2.28, App Router, Standalone output | +| **Auth** | Better Auth | Email/password, MySQL Prisma adapter | +| **Database** | MySQL | Prisma ORM, 7 models | +| **Storage** | Tencent COS | STS temp credentials, 30 min validity | +| **UI** | shadcn/ui | Tailwind, React Query, @dnd-kit | +| **Deployment** | PM2 | Server at 119.91.211.52, basePath=/studio | +| **Auth Routes** | 5 endpoints | Levels, Users, WxUsers, COS, Auth | +| **Protected Routes** | YES | Middleware + per-route checks | +| **Sharing** | โŒ NONE | Not implemented | +| **Permissions** | โŒ FLAT | All authenticated users = full admin | +| **Search** | Implemented | WeChat users only (nickname, openid) | + diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md new file mode 100644 index 0000000..440da9e --- /dev/null +++ b/QUICK_REFERENCE.md @@ -0,0 +1,240 @@ +# MemeStudio - Quick Reference Guide + +## ๐Ÿ—๏ธ ARCHITECTURE AT A GLANCE + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Next.js 14 (App Router) โ”‚ +โ”‚ With basePath=/studio โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ โ”‚ +โ”Œโ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Frontend โ”‚ โ”‚ API Routes โ”‚ +โ”‚ Pages โ”‚ โ”‚ (Protected)โ”‚ +โ”‚ & UX โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ +โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ โ”‚ Better Auth (7-day) โ”‚ + โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ MySQL via โ”‚ + โ”‚ Prisma ORM โ”‚ + โ”‚ (7 models) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## ๐Ÿ“Š DATABASE MODELS (7) + +| Model | Purpose | Key Fields | +|-------|---------|-----------| +| **Level** | Game levels | id, imageUrl, answer, hint1-3, sortOrder | +| **User** | Admin users | id, email, name, password (in Account) | +| **Session** | Auth sessions | id, token (unique), expiresAt, userId | +| **Account** | Auth accounts | id, password, providerId, userId | +| **Verification** | Email verification | id, identifier, expiresAt | +| **WxUser** | Mini-program users | id, openid, nickname, points | +| **WxUserLevelProgress** | User progress | id, userId, levelId, completedAt | + +## ๐Ÿ” AUTH FLOW + +``` +1. User Login + โ””โ”€> POST /api/auth/sign-in (Better Auth) + โ””โ”€> Session created in DB + โ””โ”€> Cookie set (7-day expiry) + +2. Page Navigation + โ””โ”€> Middleware checks cookie + โ”œโ”€> If missing โ†’ Redirect to login + โ””โ”€> If present โ†’ Allow access + +3. API Calls + โ””โ”€> auth.api.getSession({ headers: request.headers }) + โ”œโ”€> If null โ†’ Return 401 Unauthorized + โ””โ”€> If valid โ†’ Proceed with operation +``` + +## ๐Ÿ›ฃ๏ธ API ENDPOINTS (14 total) + +### Auth (1 catch-all) +- `POST /api/auth/[...all]` - Better Auth routes + +### Levels (4 routes) +- `GET /api/levels` - List all (sorted by sortOrder) +- `POST /api/levels` - Create new (auto-increment sortOrder) +- `PUT /api/levels` - Update existing +- `DELETE /api/levels?id=...` - Delete level +- `PUT /api/levels/reorder` - Batch reorder (transaction) + +### Users (4 routes) +- `GET /api/users` - List all admin users +- `POST /api/users` - Create new admin +- `PUT /api/users` - Update user/password +- `DELETE /api/users?id=...` - Delete user (prevent self-delete) + +### WeChat Users (3 routes) +- `GET /api/wx-users?search=...&page=...` - List with pagination +- `GET /api/wx-users/[id]` - Get user with progress history +- `DELETE /api/wx-users/level-progress` - Batch delete progress + +### COS (1 route) +- `GET /api/cos/temp-key` - Get 30-min temp credentials + +## ๐Ÿ“ PROJECT STRUCTURE + +``` +MemeStudio/ +โ”œโ”€โ”€ app/ +โ”‚ โ”œโ”€โ”€ (auth)/login/page.tsx # Public login +โ”‚ โ”œโ”€โ”€ (dashboard)/ # Protected pages +โ”‚ โ”‚ โ”œโ”€โ”€ levels/page.tsx # Levels UI + CRUD +โ”‚ โ”‚ โ”œโ”€โ”€ users/page.tsx # User management +โ”‚ โ”‚ โ””โ”€โ”€ wx-users/page.tsx # WeChat users +โ”‚ โ””โ”€โ”€ api/ # All API routes +โ”œโ”€โ”€ lib/ +โ”‚ โ”œโ”€โ”€ auth.ts # Better Auth config +โ”‚ โ”œโ”€โ”€ auth-client.ts # Client hooks +โ”‚ โ”œโ”€โ”€ prisma.ts # Singleton +โ”‚ โ”œโ”€โ”€ cos.ts # COS utilities +โ”‚ โ”œโ”€โ”€ api.ts # apiFetch (adds basePath) +โ”‚ โ””โ”€โ”€ utils.ts # Tailwind cn() +โ”œโ”€โ”€ components/ # React UI components +โ”œโ”€โ”€ prisma/schema.prisma # Database schema +โ”œโ”€โ”€ middleware.ts # Session validation +โ”œโ”€โ”€ types/index.ts # TypeScript interfaces +โ””โ”€โ”€ next.config.js # basePath=/studio config +``` + +## ๐Ÿ”‘ ENVIRONMENT VARIABLES + +```bash +# Required +DATABASE_URL=mysql://user:pass@host:port/db +BETTER_AUTH_SECRET=32+ chars +BETTER_AUTH_URL=https://domain.com # โš ๏ธ NO path! +NEXT_PUBLIC_APP_URL=https://domain.com +NEXT_PUBLIC_BASE_PATH=/studio + +# Admin seed +ADMIN_EMAIL=user@example.com +ADMIN_PASSWORD=password + +# Tencent COS +COS_SECRET_ID=AKID... +COS_SECRET_KEY=... +COS_BUCKET=lookai-1308511832 +COS_REGION=ap-guangzhou +COS_APPID=1308511832 +``` + +## โš™๏ธ KEY UTILITIES + +### `apiFetch(path, options)` - API Wrapper +- Automatically prepends basePath +- Usage: `apiFetch('/api/levels')` โ†’ requests `/studio/api/levels` +- Used in all client components + +### `getTempKey()` - COS Credentials +- Returns temp credentials valid 30 minutes +- Restricts uploads to `mini_game/images/*` +- Called before image upload + +### `useSession()` - Auth Hook +- Returns: `{ data: session, isPending: boolean }` +- Shows current user email in header +- Used in all protected pages + +## ๐Ÿš€ DEPLOYMENT + +**Server**: `root@119.91.211.52:/root/apps/meme-studio` +**Process**: PM2 +**Staging**: `/studio` path (reverse proxy) + +**Deploy Steps**: +1. `npm run build` (locally) +2. `./deploy.sh` (rsync + npm install + PM2 restart) + +## โš ๏ธ CRITICAL GOTCHAS + +### 1. basePath Issues +``` +โŒ request.nextUrl.pathname INCLUDES basePath in Next.js 12 +โœ… request.nextUrl.pathname EXCLUDES basePath in Next.js 13+/14 +โœ… Middleware: DO check for `/levels`, NOT `/studio/levels` +โŒ Don't manually prepend /studio to routes +โœ… Use apiFetch() which handles basePath automatically +``` + +### 2. Better Auth Configuration +``` +โœ… BETTER_AUTH_URL = "https://domain.com" +โŒ BETTER_AUTH_URL = "https://domain.com/studio" (WRONG!) +โœ… basePath = "/api/auth" +โŒ basePath = "/studio/api/auth" (WRONG!) +``` + +### 3. Cookie Naming +``` +HTTP: better-auth.session_token +HTTPS: __Secure-better-auth.session_token +Middleware checks both! +``` + +### 4. Image Upload +``` +Frontend โ†’ GET /api/cos/temp-key + โ†’ Upload directly to COS (no backend) + โ†’ Backend stores URL only + โ†’ (Frontend handles COS SDK) +``` + +## ๐Ÿ” NO SHARING/INVITE LOGIC + +โœ… What Exists: +- Basic auth (email/password) +- User management (admins only) +- Level CRUD +- WeChat user tracking + +โŒ What's Missing: +- Share links/tokens +- Permission system (all authenticated = full admin) +- Invite workflows +- Role-based access + +## ๐Ÿ’ก COMMON TASKS + +### Add a new API endpoint +1. Create file: `app/api/resource/route.ts` +2. Follow session check pattern +3. Use `apiFetch()` from client +4. Add types in `types/index.ts` + +### Modify database schema +1. Edit `prisma/schema.prisma` +2. Run `pnpm run db:push` (dev) or `db:migrate` +3. Generate types: `pnpm run db:generate` + +### Deploy to production +1. Push changes to git +2. Run `pnpm run deploy` (builds locally + deploys via ssh) + +## ๐Ÿ“‹ QUICK STATS + +- **Framework**: Next.js 14.2.28 (App Router) +- **Auth**: Better Auth v1.2.7 +- **Database**: MySQL + Prisma v6.5.0 +- **UI**: shadcn/ui + Tailwind CSS +- **State**: TanStack React Query +- **Storage**: Tencent COS +- **Deployment**: PM2 on Linux server +- **Code Language**: TypeScript +- **Session Duration**: 7 days +- **COS Temp Credentials**: 30 minutes +- **Protected Routes**: All except /login and /api/* (handled separately) + diff --git a/app/(dashboard)/levels/page.tsx b/app/(dashboard)/levels/page.tsx index a96b82f..04549ec 100644 --- a/app/(dashboard)/levels/page.tsx +++ b/app/(dashboard)/levels/page.tsx @@ -6,16 +6,19 @@ import { Button } from '@/components/ui/button' import { Header } from '@/components/layout/header' import { LevelTable } from '@/components/levels/level-table' import { LevelDialog } from '@/components/levels/level-dialog' +import { BatchImportDialog } from '@/components/levels/batch-import-dialog' +import { DeleteConfirmDialog } from '@/components/levels/delete-confirm-dialog' import { Spinner } from '@/components/ui/spinner' import { Level, LevelFormData } from '@/types' -import { Plus } from 'lucide-react' +import { Plus, Upload } from 'lucide-react' import { apiFetch } from '@/lib/api' export default function LevelsPage() { const queryClient = useQueryClient() const [isDialogOpen, setIsDialogOpen] = useState(false) + const [isBatchOpen, setIsBatchOpen] = useState(false) const [editingLevel, setEditingLevel] = useState(null) - const [deleteConfirmId, setDeleteConfirmId] = useState(null) + const [deletingLevel, setDeletingLevel] = useState(null) // Fetch levels const { data: levels, isLoading, error } = useQuery({ @@ -79,7 +82,7 @@ export default function LevelsPage() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['levels'] }) - setDeleteConfirmId(null) + setDeletingLevel(null) }, }) @@ -94,13 +97,8 @@ export default function LevelsPage() { } const handleDelete = (id: string) => { - if (deleteConfirmId === id) { - deleteMutation.mutate(id) - } else { - setDeleteConfirmId(id) - // Reset after 3 seconds - setTimeout(() => setDeleteConfirmId(null), 3000) - } + const target = (levels || []).find((l) => l.id === id) + if (target) setDeletingLevel(target) } const handleSubmit = async (data: LevelFormData) => { @@ -147,17 +145,22 @@ export default function LevelsPage() { ๅ…ฑ {levels?.length || 0} ไธชๅ…ณๅก

- +
+ + +
@@ -166,8 +169,39 @@ export default function LevelsPage() { open={isDialogOpen} onOpenChange={setIsDialogOpen} level={editingLevel} + totalCount={levels?.length || 0} + currentIndex={ + editingLevel + ? (levels || []).findIndex((l) => l.id === editingLevel.id) + : null + } onSubmit={handleSubmit} /> + + + queryClient.invalidateQueries({ queryKey: ['levels'] }) + } + /> + + { + if (!next) setDeletingLevel(null) + }} + level={deletingLevel} + index={ + deletingLevel + ? (levels || []).findIndex((l) => l.id === deletingLevel.id) + : undefined + } + isLoading={deleteMutation.isPending} + onConfirm={() => { + if (deletingLevel) deleteMutation.mutate(deletingLevel.id) + }} + /> ) } diff --git a/app/api/levels/reorder/route.ts b/app/api/levels/reorder/route.ts deleted file mode 100644 index dabbdb6..0000000 --- a/app/api/levels/reorder/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { auth } from '@/lib/auth' - -// PUT /api/levels/reorder - Batch update sort order -export async function PUT(request: NextRequest) { - try { - const session = await auth.api.getSession({ - headers: request.headers, - }) - - if (!session) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - const { orders } = body as { orders: { id: string; sortOrder: number }[] } - - if (!Array.isArray(orders)) { - return NextResponse.json( - { error: 'orders must be an array' }, - { status: 400 } - ) - } - - // Update each level's sort order in a transaction - await prisma.$transaction( - orders.map((item) => - prisma.level.update({ - where: { id: item.id }, - data: { sortOrder: item.sortOrder }, - }) - ) - ) - - return NextResponse.json({ success: true }) - } catch (error) { - console.error('Error reordering levels:', error) - return NextResponse.json( - { error: 'Failed to reorder levels' }, - { status: 500 } - ) - } -} diff --git a/app/api/levels/route.ts b/app/api/levels/route.ts index 7209b8e..f81c446 100644 --- a/app/api/levels/route.ts +++ b/app/api/levels/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { auth } from '@/lib/auth' +import { compareSortKey, keyAfterLast, keyForPosition } from '@/lib/sort-key' import { v4 as uuidv4 } from 'uuid' // GET /api/levels - Get all levels @@ -14,10 +15,20 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const levels = await prisma.level.findMany({ - orderBy: { sortOrder: 'asc' }, + // ๆณจๆ„๏ผšไธ่ƒฝไบค็ป™ MySQL ๆŽ’ๅบใ€‚้ป˜่ฎค collation ๅคงๅฐๅ†™ไธๆ•ๆ„Ÿ๏ผŒ + // ไผšๆŠŠ fractional-indexing ็š„ Z ็ณปๅˆ— key ้”™ๆŽ’ๅˆฐ z ็ณปๅˆ—ไน‹ๅŽใ€‚ + // ๆ‹‰ๅ…จ่กจๅˆฐๅบ”็”จๅฑ‚ๆŒ‰ๅญ—่Š‚ๅบๆŽ’ใ€‚ + const rows = await prisma.level.findMany() + rows.sort((a, b) => { + const c = compareSortKey(a.sortKey, b.sortKey) + if (c !== 0) return c + if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder + return a.createdAt.getTime() - b.createdAt.getTime() }) + // ๅ“ๅบ”ๅฑ‚ๅ›žๅกซ่ฟž็ปญๆ•ดๆ•ฐ sortOrder๏ผŒไฟๆŒๅ‰็ซฏ / ๅฐ็จ‹ๅบๅฏน่ฟ™ไธชๅญ—ๆฎต็š„ๆ—ขๆœ‰ไพ่ต– + const levels = rows.map((r, i) => ({ ...r, sortOrder: i })) + return NextResponse.json(levels) } catch (error) { console.error('Error fetching levels:', error) @@ -28,7 +39,25 @@ export async function GET(request: NextRequest) { } } +/** + * ่งฃๆž position๏ผš + * - undefined / null โ†’ ่ฟ”ๅ›ž undefined๏ผŒไบค็ป™่ฐƒ็”จๆ–น่ตฐ"้ป˜่ฎค่กŒไธบ" + * - ๆ•ฐๅญ—ๅญ—็ฌฆไธฒ โ†’ ่งฃๆžๆˆๆ•ดๆ•ฐ + * - ๅ…ถๅฎƒ โ†’ ๆŠ›้”™ + * + * ๅˆๆณ•่Œƒๅ›ด็š„ๆ ก้ชŒ็”ฑ่ฐƒ็”จๆ–นๆ นๆฎๅฝ“ๅ‰ไธŠไธ‹ๆ–‡๏ผˆๅˆ›ๅปบ / ็ผ–่พ‘๏ผ‰ๅˆคๆ–ญใ€‚ + */ +function parsePosition(raw: unknown): number | undefined { + if (raw === undefined || raw === null || raw === '') return undefined + const n = typeof raw === 'number' ? raw : Number(raw) + if (!Number.isInteger(n) || n < 1) { + throw new Error('position ๅฟ…้กปๆ˜ฏ >= 1 ็š„ๆ•ดๆ•ฐ') + } + return n +} + // POST /api/levels - Create a new level +// body.position ๅฏ้€‰๏ผš1..N+1๏ผ›ไธไผ ๅˆ™่ฟฝๅŠ ๆœซๅฐพ export async function POST(request: NextRequest) { try { const session = await auth.api.getSession({ @@ -40,7 +69,17 @@ export async function POST(request: NextRequest) { } const body = await request.json() - const { image1Url, image1Description, image2Url, image2Description, answer, punchline, hint1, hint2, hint3 } = body + const { + image1Url, + image1Description, + image2Url, + image2Description, + answer, + punchline, + hint1, + hint2, + hint3, + } = body if (!image1Url || !image2Url || !answer) { return NextResponse.json( @@ -49,12 +88,29 @@ export async function POST(request: NextRequest) { ) } - // Get max sort order - const maxSortOrder = await prisma.level.aggregate({ - _max: { sortOrder: true }, - }) + let position: number | undefined + try { + position = parsePosition(body.position) + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'invalid position' }, + { status: 400 } + ) + } - const sortOrder = (maxSortOrder._max.sortOrder || 0) + 1 + let sortKey: string + if (position === undefined) { + sortKey = await keyAfterLast() + } else { + const total = await prisma.level.count() + if (position > total + 1) { + return NextResponse.json( + { error: `position ่ถ…ๅ‡บ่Œƒๅ›ด๏ผŒๅˆๆณ•ๅŒบ้—ด [1, ${total + 1}]` }, + { status: 400 } + ) + } + sortKey = await keyForPosition(position) + } const level = await prisma.level.create({ data: { @@ -68,7 +124,7 @@ export async function POST(request: NextRequest) { hint1: hint1 || null, hint2: hint2 || null, hint3: hint3 || null, - sortOrder, + sortKey, }, }) @@ -83,6 +139,7 @@ export async function POST(request: NextRequest) { } // PUT /api/levels - Update a level +// body.position ๅฏ้€‰๏ผš1..N๏ผˆN ๅซๅฝ“ๅ‰่กŒ๏ผ‰ใ€‚ไธไผ ่กจ็คบไธ็งปๅŠจไฝ็ฝฎใ€‚ export async function PUT(request: NextRequest) { try { const session = await auth.api.getSession({ @@ -94,12 +151,46 @@ export async function PUT(request: NextRequest) { } const body = await request.json() - const { id, image1Url, image1Description, image2Url, image2Description, answer, punchline, hint1, hint2, hint3 } = body + const { + id, + image1Url, + image1Description, + image2Url, + image2Description, + answer, + punchline, + hint1, + hint2, + hint3, + } = body if (!id) { return NextResponse.json({ error: 'id is required' }, { status: 400 }) } + let position: number | undefined + try { + position = parsePosition(body.position) + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'invalid position' }, + { status: 400 } + ) + } + + // ๅชๆœ‰ๅœจ position ็กฎๅฎžๅ˜ๅŒ–ๆ—ถๆ‰้‡็ฎ— sortKey๏ผŒ้ฟๅ…ๆ— ่ฐ“ๅ†™ๅ…ฅ + let newSortKey: string | undefined + if (position !== undefined) { + const total = await prisma.level.count() + if (position > total) { + return NextResponse.json( + { error: `position ่ถ…ๅ‡บ่Œƒๅ›ด๏ผŒๅˆๆณ•ๅŒบ้—ด [1, ${total}]` }, + { status: 400 } + ) + } + newSortKey = await keyForPosition(position, id) + } + const level = await prisma.level.update({ where: { id }, data: { @@ -112,6 +203,7 @@ export async function PUT(request: NextRequest) { hint1: hint1 || null, hint2: hint2 || null, hint3: hint3 || null, + ...(newSortKey ? { sortKey: newSortKey } : {}), }, }) diff --git a/app/api/wx-users/[id]/route.ts b/app/api/wx-users/[id]/route.ts index 9df77a0..6a79f82 100644 --- a/app/api/wx-users/[id]/route.ts +++ b/app/api/wx-users/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { auth } from '@/lib/auth' +import { compareSortKey } from '@/lib/sort-key' export const dynamic = 'force-dynamic' @@ -20,6 +21,8 @@ export async function GET( const { id } = await params + // levelProgress ่ฟ™้‡ŒไธๆŒ‰ sortKey ๆŽ’โ€”โ€”MySQL collation ไธๅฏ้ ใ€‚ + // ไธ‹้ข็”จ progress ็š„ Map ๆŒ‰้œ€็ป„ๅˆ๏ผŒๆœ€็ปˆ้กบๅบ็”ฑ levels ็š„ JS ๆŽ’ๅบๅ†ณๅฎšใ€‚ const user = await prisma.wxUser.findUnique({ where: { id }, select: { @@ -33,23 +36,12 @@ export async function GET( createdAt: true, updatedAt: true, levelProgress: { - orderBy: [ - { level: { sortOrder: 'asc' } }, - { completedAt: 'desc' }, - ], select: { id: true, userId: true, levelId: true, completedAt: true, timeSpent: true, - level: { - select: { - id: true, - answer: true, - sortOrder: true, - }, - }, }, }, }, @@ -59,14 +51,22 @@ export async function GET( return NextResponse.json({ error: 'User not found' }, { status: 404 }) } + // sortKey ๆŽ’ๅบๅฟ…้กปๅœจ JS ไพงๅš๏ผŒ้ฟๅ…ๅคงๅฐๅ†™ไธๆ•ๆ„Ÿ collation ๅธฆๆฅ็š„้กบๅบ้”™ไนฑ const levels = await prisma.level.findMany({ - orderBy: { sortOrder: 'asc' }, select: { id: true, answer: true, + sortKey: true, sortOrder: true, + createdAt: true, }, }) + levels.sort((a, b) => { + const c = compareSortKey(a.sortKey, b.sortKey) + if (c !== 0) return c + if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder + return a.createdAt.getTime() - b.createdAt.getTime() + }) const progressByLevelId = new Map( user.levelProgress.map((progress) => [progress.levelId, progress]) @@ -83,13 +83,14 @@ export async function GET( createdAt: user.createdAt, updatedAt: user.updatedAt, completedLevelCount: user.levelProgress.length, - assignedLevels: levels.map((level) => { + // ๆŒ‰ sortKey ๆŽ’ๅบๅŽ๏ผŒๆ•ฐ็ป„ไธ‹ๆ ‡ๅณ่ฟž็ปญ็š„ sortOrder๏ผŒๅฐ็จ‹ๅบ็ซฏๅฅ‘็บฆไธๅ˜ + assignedLevels: levels.map((level, index) => { const progress = progressByLevelId.get(level.id) return { id: level.id, answer: level.answer, - sortOrder: level.sortOrder, + sortOrder: index, completed: Boolean(progress), progressId: progress?.id || null, completedAt: progress?.completedAt || null, diff --git a/components/levels/batch-import-dialog.tsx b/components/levels/batch-import-dialog.tsx new file mode 100644 index 0000000..9f3e6c9 --- /dev/null +++ b/components/levels/batch-import-dialog.tsx @@ -0,0 +1,563 @@ +'use client' + +import { useState, useRef, useEffect, useMemo } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Spinner } from '@/components/ui/spinner' +import { FolderOpen, Trash2, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react' +import Image from 'next/image' +import { uploadToCos } from '@/lib/cos-client' +import { apiFetch } from '@/lib/api' + +interface BatchImportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +type ItemStatus = 'pending' | 'uploading' | 'creating' | 'done' | 'error' + +interface ParsedItem { + id: string // riddle_id ๆˆ–ๆ–‡ไปถๅคนๅ๏ผŒไป…็”จไบŽ React key + folderName: string + // ๆœฌๅœฐๆ–‡ไปถๅผ•็”จ + referenceFile?: File + riddleFile?: File + // ้ข„่งˆ URL๏ผˆๆœฌๅœฐ blob URL๏ผ‰ + referencePreview?: string + riddlePreview?: string + // ไปŽ metadata.json ๆๅ– + anchorText: string + answer: string + punchline: string + hint1: string + hint2: string + hint3: string + // ่งฃๆž่ญฆๅ‘Š/้”™่ฏฏไฟกๆฏ + parseError?: string + // ไธŠไผ /ๅˆ›ๅปบ่ฟ‡็จ‹็Šถๆ€ + status: ItemStatus + statusMessage?: string +} + +interface FileWithPath extends File { + webkitRelativePath: string +} + +interface RiddleMetadata { + plan?: { + riddle_id?: string + anchor_text?: string + answer?: string + hints?: string[] + riddle?: { + homophone_explanation?: string + } + } +} + +function truncate(s: string | undefined, max: number): string { + if (!s) return '' + return s.length > max ? s.slice(0, max) : s +} + +async function parseFolders(files: FileList): Promise { + // ๆŒ‰็ฌฌไธ€ๅฑ‚็›ฎๅฝ•ๅๅˆ†็ป„ + const groups = new Map() + for (let i = 0; i < files.length; i += 1) { + const f = files[i] as FileWithPath + const rel = f.webkitRelativePath + if (!rel) continue + const parts = rel.split('/') + if (parts.length < 2) continue + // ็”จๅ€’ๆ•ฐ็ฌฌไบŒๅฑ‚ไฝœไธบๅ…ณๅก็›ฎๅฝ•ๅ๏ผše.g. riddles/68f1.../metadata.json + // ไฝ†้€šๅธธ็”จๆˆท้€‰็š„ๆ น็›ฎๅฝ•ๅฐฑๆ˜ฏ riddles๏ผŒ็ฌฌไธ€ๅฑ‚ๅฐฑๆ˜ฏๅ…ณๅก็›ฎๅฝ• + // ๆˆ‘ไปฌๅ–ใ€ŒๅŒ…ๅซ metadata.json ็š„้‚ฃไธช็›ฎๅฝ•ใ€ไฝœไธบๅˆ†็ป„ key + const folderKey = parts.slice(0, -1).join('/') + if (!groups.has(folderKey)) groups.set(folderKey, []) + groups.get(folderKey)!.push(f) + } + + const items: ParsedItem[] = [] + + for (const [folderKey, groupFiles] of Array.from(groups.entries())) { + const metadata = groupFiles.find((f) => f.name === 'metadata.json') + const reference = groupFiles.find((f) => f.name === 'reference.webp') + const riddle = groupFiles.find((f) => f.name === 'riddle.webp') + // ๅชๆœ‰ๅŒๆ—ถๅญ˜ๅœจ metadata.json + reference.webp + riddle.webp ็š„็›ฎๅฝ•ๆ‰่ง†ไธบๅ…ณๅก + if (!metadata || !reference || !riddle) continue + + const folderName = folderKey.split('/').pop() || folderKey + + const item: ParsedItem = { + id: folderKey, + folderName, + referenceFile: reference, + riddleFile: riddle, + referencePreview: URL.createObjectURL(reference), + riddlePreview: URL.createObjectURL(riddle), + anchorText: '', + answer: '', + punchline: '', + hint1: '', + hint2: '', + hint3: '', + status: 'pending', + } + + try { + const text = await metadata.text() + const json = JSON.parse(text) as RiddleMetadata + const plan = json.plan || {} + item.anchorText = truncate(plan.anchor_text, 500) + item.answer = (plan.answer || '').trim() + item.punchline = (plan.riddle?.homophone_explanation || '').trim() + const hints = Array.isArray(plan.hints) ? plan.hints : [] + item.hint1 = (hints[0] || '').trim() + item.hint2 = (hints[1] || '').trim() + item.hint3 = (hints[2] || '').trim() + + if (!item.answer) { + item.parseError = 'ๆœช่งฃๆžๅˆฐ answer' + } else if (item.answer.length > 4) { + item.parseError = `็ญ”ๆกˆ่ถ…่ฟ‡ 4 ๅญ—๏ผˆ${item.answer.length}๏ผ‰` + } + } catch (e) { + item.parseError = `่งฃๆž metadata.json ๅคฑ่ดฅ: ${ + e instanceof Error ? e.message : String(e) + }` + } + + items.push(item) + } + + // ็จณๅฎšๆŽ’ๅบ๏ผšๆŒ‰ๆ–‡ไปถๅคนๅ + items.sort((a, b) => a.folderName.localeCompare(b.folderName)) + return items +} + +export function BatchImportDialog({ + open, + onOpenChange, + onSuccess, +}: BatchImportDialogProps) { + const folderInputRef = useRef(null) + const [items, setItems] = useState([]) + const [isParsing, setIsParsing] = useState(false) + const [isRunning, setIsRunning] = useState(false) + const [globalError, setGlobalError] = useState('') + + // ๅ…ณ้—ญๅŽๆธ…็†้ข„่งˆ URL ๅนถ้‡็ฝฎ + useEffect(() => { + if (!open) { + items.forEach((it) => { + if (it.referencePreview) URL.revokeObjectURL(it.referencePreview) + if (it.riddlePreview) URL.revokeObjectURL(it.riddlePreview) + }) + setItems([]) + setGlobalError('') + setIsRunning(false) + setIsParsing(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const stats = useMemo(() => { + const total = items.length + const done = items.filter((i) => i.status === 'done').length + const error = items.filter((i) => i.status === 'error').length + const invalid = items.filter((i) => i.parseError).length + return { total, done, error, invalid } + }, [items]) + + const canSubmit = + items.length > 0 && + !isRunning && + items.some((i) => i.status !== 'done' && !i.parseError) + + const handleSelectFolder = () => { + folderInputRef.current?.click() + } + + const handleFolderChange = async (e: React.ChangeEvent) => { + const files = e.target.files + if (!files || files.length === 0) return + setIsParsing(true) + setGlobalError('') + try { + const parsed = await parseFolders(files) + if (parsed.length === 0) { + setGlobalError( + 'ๆœชๅœจๆ‰€้€‰็›ฎๅฝ•ไธญๆ‰พๅˆฐไปปไฝ•ๆœ‰ๆ•ˆๅ…ณๅกใ€‚่ฏท็กฎไฟๆฏไธชๅญ็›ฎๅฝ•ๅŒๆ—ถๅŒ…ๅซ metadata.jsonใ€reference.webpใ€riddle.webp' + ) + } + setItems(parsed) + } catch (err) { + setGlobalError( + err instanceof Error ? err.message : '่งฃๆžๅคฑ่ดฅ' + ) + } finally { + setIsParsing(false) + // ๅ…่ฎธ้‡ๆ–ฐ้€‰ๆ‹ฉๅŒไธ€็›ฎๅฝ• + if (folderInputRef.current) folderInputRef.current.value = '' + } + } + + const updateItem = (id: string, patch: Partial) => { + setItems((prev) => + prev.map((it) => { + if (it.id !== id) return it + const next = { ...it, ...patch } + // ๆฏๆฌก็ผ–่พ‘ๅŽ้‡ๆ–ฐ่ฏ„ไผฐ็ญ”ๆกˆ็š„ๆ ก้ชŒ + if (patch.answer !== undefined) { + if (!next.answer) next.parseError = 'ๆœชๅกซๅ†™็ญ”ๆกˆ' + else if (next.answer.length > 4) + next.parseError = `็ญ”ๆกˆ่ถ…่ฟ‡ 4 ๅญ—๏ผˆ${next.answer.length}๏ผ‰` + else next.parseError = undefined + } + return next + }) + ) + } + + const removeItem = (id: string) => { + setItems((prev) => { + const target = prev.find((i) => i.id === id) + if (target) { + if (target.referencePreview) URL.revokeObjectURL(target.referencePreview) + if (target.riddlePreview) URL.revokeObjectURL(target.riddlePreview) + } + return prev.filter((i) => i.id !== id) + }) + } + + const handleRun = async () => { + setIsRunning(true) + setGlobalError('') + + // ไฝฟ็”จๅ‡ฝๆ•ฐๅผ setItems ๅ–ๅˆฐๆœ€ๆ–ฐๅˆ—่กจ + const snapshot = items + for (const it of snapshot) { + if (it.status === 'done') continue + if (it.parseError) continue + if (!it.referenceFile || !it.riddleFile) continue + + try { + updateItem(it.id, { status: 'uploading', statusMessage: 'ไธŠไผ ๅ›พ็‰‡โ€ฆ' }) + + const [image1Url, image2Url] = await Promise.all([ + uploadToCos(it.referenceFile, it.referenceFile.name), + uploadToCos(it.riddleFile, it.riddleFile.name), + ]) + + updateItem(it.id, { status: 'creating', statusMessage: 'ๅˆ›ๅปบๅ…ณๅกโ€ฆ' }) + + const payload = { + image1Url, + image1Description: it.anchorText || '', + image2Url, + image2Description: '', + answer: it.answer, + punchline: it.punchline || '', + hint1: it.hint1 || '', + hint2: it.hint2 || '', + hint3: it.hint3 || '', + } + + const res = await apiFetch('/api/levels', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.error || `HTTP ${res.status}`) + } + updateItem(it.id, { status: 'done', statusMessage: 'ๅทฒๅˆ›ๅปบ' }) + } catch (e) { + updateItem(it.id, { + status: 'error', + statusMessage: e instanceof Error ? e.message : 'ๅคฑ่ดฅ', + }) + } + } + + setIsRunning(false) + // ่งฆๅ‘ๅˆ—่กจๅˆทๆ–ฐ + onSuccess() + } + + return ( + + + + ๆ‰น้‡ๅฏผๅ…ฅๅ…ณๅก + + ้€‰ๆ‹ฉๅŒ…ๅซๅคšไธชๅ…ณๅกๅญๆ–‡ไปถๅคน็š„็›ฎๅฝ•ใ€‚ๆฏไธชๅญๆ–‡ไปถๅคน้กปๅซ metadata.jsonใ€ + reference.webpใ€riddle.webpใ€‚ + + + + {globalError && ( +
+ {globalError} +
+ )} + +
+ + {isParsing && ( + + ่งฃๆžไธญโ€ฆ + + )} + {items.length > 0 && ( + + ๅ…ฑ {stats.total} ไธชๅ…ณๅก ยท ๆˆๅŠŸ {stats.done} ยท ๅคฑ่ดฅ {stats.error} + {stats.invalid > 0 && ` ยท ๅพ…ไฟฎๆญฃ ${stats.invalid}`} + + )} + + {/* ้š่—็š„ๆ–‡ไปถๅคน้€‰ๆ‹ฉ input๏ผšwebkitdirectory ่ฎฉๆต่งˆๅ™จ้€‰ๆ‹ฉ็›ฎๅฝ• */} + )} + /> +
+ +
+ {items.length === 0 ? ( +
+ {isParsing ? '่งฃๆžไธญโ€ฆ' : 'ๅฐšๆœช้€‰ๆ‹ฉ็›ฎๅฝ•'} +
+ ) : ( +
+ {items.map((it, idx) => ( + updateItem(it.id, patch)} + onRemove={() => removeItem(it.id)} + /> + ))} +
+ )} +
+ + + + + +
+
+ ) +} + +interface ItemCardProps { + index: number + item: ParsedItem + disabled: boolean + onChange: (patch: Partial) => void + onRemove: () => void +} + +function ItemCard({ index, item, disabled, onChange, onRemove }: ItemCardProps) { + const statusNode = (() => { + switch (item.status) { + case 'uploading': + case 'creating': + return ( + + {item.statusMessage} + + ) + case 'done': + return ( + + ๅทฒๅˆ›ๅปบ + + ) + case 'error': + return ( + + {item.statusMessage} + + ) + default: + if (item.parseError) { + return ( + + {item.parseError} + + ) + } + return ( + ๅพ…ๅˆ›ๅปบ + ) + } + })() + + return ( +
+
+
+ + #{index} + + + {item.folderName} + + {statusNode} +
+ +
+ +
+ {/* ๅทฆไพง๏ผšๅ›พ็‰‡้ข„่งˆ */} +
+
+
+ {item.referencePreview && ( + ๅ›พ็‰‡1 + )} +
+

ๅ›พ1 ยท reference

+
+
+
+ {item.riddlePreview && ( + ๅ›พ็‰‡2 + )} +
+

ๅ›พ2 ยท riddle

+
+
+ + {/* ๅณไพง๏ผšๅฏ็ผ–่พ‘ๅญ—ๆฎต */} +
+
+ +