diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..982be77 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +Meme Studio is a homophone pun game operation platform built with Next.js 14 (App Router). It provides level configuration management for a wordplay game. + +## Commands + +```bash +pnpm run dev # Start development server (port 3001) +pnpm run build # Production build +pnpm run deploy # Build and deploy to production server (./deploy.sh) +pnpm run lint # Run ESLint + +# Database (Prisma + MySQL) +pnpm run db:generate # Generate Prisma client +pnpm run db:push # Push schema changes (dev) +pnpm run db:migrate # Create migration +pnpm run db:studio # Open Prisma Studio +pnpm run db:seed # Create/update admin user +``` + +## Architecture + +### Tech Stack +- **Framework**: Next.js 14 App Router +- **Auth**: Better Auth with Prisma adapter (email/password) +- **Database**: MySQL via Prisma ORM +- **UI**: shadcn/ui + Tailwind CSS +- **State**: TanStack Query for server state +- **Drag & Drop**: @dnd-kit/sortable + +### Key Patterns + +**Route Groups**: +- `app/(auth)/` - Login page (no sidebar) +- `app/(dashboard)/` - Protected pages with sidebar layout + +**Auth Flow**: +- `lib/auth.ts` - Server-side Better Auth config +- `lib/auth-client.ts` - Client-side auth hooks (`useSession`, `signIn`, `signOut`) +- `middleware.ts` - Cookie-based session check (cannot use Prisma in Edge Runtime) + +**API Routes**: +- `/api/auth/[...all]` - Better Auth endpoints +- `/api/levels` - CRUD for game levels +- `/api/levels/reorder` - Batch update sort order +- `/api/cos/temp-key` - Tencent COS temporary credentials + +**Database Models**: +- `Level` - Game levels with image, answer, hints, sortOrder +- `User`, `Session`, `Account`, `Verification` - Better Auth models + +### basePath & Reverse Proxy + +The app is deployed behind a reverse proxy at `/studio` path: +- `next.config.js` sets `basePath: '/studio'` +- All pages and API routes are served under `/studio/...` + +**Critical gotchas**: +- **Next.js strips basePath from `request.url` in route handlers** — Better Auth's `basePath` must be `/api/auth` (without `/studio`), not `/studio/api/auth` +- **`BETTER_AUTH_URL` must NOT contain a path** (just the origin like `http://localhost:3001`) — Better Auth's `withPath()` silently ignores the `basePath` config if the URL already has a path component +- **HTTPS production adds `__Secure-` cookie prefix** — middleware must check both `better-auth.session_token` and `__Secure-better-auth.session_token` +- **`router.push()` auto-prepends basePath** — never manually add `/studio` to paths used in client-side navigation or callbackUrl params +- **`request.nextUrl.pathname` in middleware excludes basePath** — e.g., `/levels` not `/studio/levels` +- Prisma requires `binaryTargets = ["native", "rhel-openssl-3.0.x"]` for cross-platform deployment (macOS dev → Linux server) + +### Deployment + +- **Server**: `root@119.91.211.52` at `/root/apps/meme-studio` +- **Process Manager**: PM2 (`ecosystem.config.js`) +- **Script**: `./deploy.sh` — builds locally, rsyncs files (excluding node_modules), installs deps on server, restarts PM2 +- **Standalone output**: `next.config.js` sets `output: 'standalone'` +- Server uses `npm install --production` + `npx prisma generate` (prisma is a devDependency) + +### Environment Variables + +Required in `.env`: +``` +DATABASE_URL=mysql://... +BETTER_AUTH_SECRET= # 32+ chars, generate with: openssl rand -base64 32 +BETTER_AUTH_URL= # Origin only, NO path (e.g., http://localhost:3001) +NEXT_PUBLIC_APP_URL= # Same as BETTER_AUTH_URL +NEXT_PUBLIC_BASE_PATH= # /studio +ADMIN_EMAIL= +ADMIN_PASSWORD= +COS_SECRET_ID= # Tencent Cloud COS +COS_SECRET_KEY= +COS_BUCKET= +COS_REGION= +COS_APPID= +``` + +## Important Notes + +- Middleware uses cookie check only (Prisma doesn't work in Edge Runtime) +- Password hashing must use `hashPassword` from `better-auth/crypto` for compatibility +- Session model requires `token` field with unique constraint +- **Git commit messages must be written in Chinese** + + + +# Memory Context + +# $CMEM MemeStudio 2026-05-01 9:31am GMT+8 + +No previous sessions found. + \ No newline at end of file diff --git a/components/levels/batch-import-dialog.tsx b/components/levels/batch-import-dialog.tsx index 9f3e6c9..cb23117 100644 --- a/components/levels/batch-import-dialog.tsx +++ b/components/levels/batch-import-dialog.tsx @@ -60,18 +60,133 @@ interface RiddleMetadata { anchor_text?: string answer?: string hints?: string[] + homophone_explanation?: string riddle?: { homophone_explanation?: string } } } +interface RiddleConfigItem { + riddle_id?: string + answer?: string + hints?: string[] + homophone_explanation?: string + anchor_text?: string + reference_image?: string + riddle_image?: string +} + function truncate(s: string | undefined, max: number): string { if (!s) return '' return s.length > max ? s.slice(0, max) : s } +const ANSWER_MAX_LENGTH = 8 + +function normalizePath(path: string): string { + return path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/') +} + +function joinPath(base: string, path: string): string { + const cleanPath = normalizePath(path) + if (!base) return cleanPath + return normalizePath(`${base}/${cleanPath}`) +} + +function getRelativePathWithoutRoot(file: FileWithPath): string { + const rel = normalizePath(file.webkitRelativePath) + const [, ...rest] = rel.split('/') + return rest.join('/') +} + +function buildFilePathMap(files: FileList): Map { + const fileByPath = new Map() + for (let i = 0; i < files.length; i += 1) { + const file = files[i] as FileWithPath + const rel = normalizePath(file.webkitRelativePath) + if (!rel) continue + fileByPath.set(rel, file) + const withoutRoot = getRelativePathWithoutRoot(file) + if (withoutRoot) fileByPath.set(withoutRoot, file) + } + return fileByPath +} + +function validateItem(item: ParsedItem) { + if (!item.answer) { + item.parseError = '未解析到 answer' + } else if (item.answer.length > ANSWER_MAX_LENGTH) { + item.parseError = `答案超过 ${ANSWER_MAX_LENGTH} 字(${item.answer.length})` + } else if (!item.referenceFile || !item.riddleFile) { + item.parseError = '未匹配到 reference_image 或 riddle_image 对应图片' + } +} + +async function parseConfigJson( + jsonFile: FileWithPath, + fileByPath: Map +): Promise { + const text = await jsonFile.text() + const json = JSON.parse(text) as unknown + if (!Array.isArray(json)) return [] + + const jsonPath = normalizePath(jsonFile.webkitRelativePath) + const jsonDir = jsonPath.split('/').slice(0, -1).join('/') + const items: ParsedItem[] = [] + + json.forEach((raw, index) => { + const config = raw as RiddleConfigItem + if (!config || typeof config !== 'object') return + + const referencePath = config.reference_image || '' + const riddlePath = config.riddle_image || '' + const reference = fileByPath.get(joinPath(jsonDir, referencePath)) || fileByPath.get(normalizePath(referencePath)) + const riddle = fileByPath.get(joinPath(jsonDir, riddlePath)) || fileByPath.get(normalizePath(riddlePath)) + const id = config.riddle_id || `${jsonFile.name}-${index + 1}` + const folderName = config.riddle_id || referencePath.split('/').slice(-2, -1)[0] || `第 ${index + 1} 关` + const hints = Array.isArray(config.hints) ? config.hints : [] + + const item: ParsedItem = { + id, + folderName, + referenceFile: reference, + riddleFile: riddle, + referencePreview: reference ? URL.createObjectURL(reference) : undefined, + riddlePreview: riddle ? URL.createObjectURL(riddle) : undefined, + anchorText: truncate(config.anchor_text, 500), + answer: (config.answer || '').trim(), + punchline: (config.homophone_explanation || '').trim(), + hint1: (hints[0] || '').trim(), + hint2: (hints[1] || '').trim(), + hint3: (hints[2] || '').trim(), + status: 'pending', + } + + validateItem(item) + items.push(item) + }) + + return items +} + async function parseFolders(files: FileList): Promise { + const fileByPath = buildFilePathMap(files) + const jsonFiles = Array.from(fileByPath.values()).filter( + (file, index, arr) => + file.name.toLowerCase().endsWith('.json') && arr.indexOf(file) === index + ) + for (const jsonFile of jsonFiles) { + try { + const configItems = await parseConfigJson(jsonFile, fileByPath) + if (configItems.length > 0) { + return configItems + } + } catch { + // Not the new array-based config. Fall back to legacy per-folder metadata. + } + } + // 按第一层目录名分组 const groups = new Map() for (let i = 0; i < files.length; i += 1) { @@ -121,17 +236,17 @@ async function parseFolders(files: FileList): Promise { const plan = json.plan || {} item.anchorText = truncate(plan.anchor_text, 500) item.answer = (plan.answer || '').trim() - item.punchline = (plan.riddle?.homophone_explanation || '').trim() + item.punchline = ( + plan.homophone_explanation || + 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})` - } + validateItem(item) } catch (e) { item.parseError = `解析 metadata.json 失败: ${ e instanceof Error ? e.message : String(e) @@ -198,7 +313,7 @@ export function BatchImportDialog({ const parsed = await parseFolders(files) if (parsed.length === 0) { setGlobalError( - '未在所选目录中找到任何有效关卡。请确保每个子目录同时包含 metadata.json、reference.webp、riddle.webp' + '未在所选目录中找到任何有效关卡。请选择包含 riddles.json 和 riddle_images 的目录,或旧格式的 metadata.json、reference.webp、riddle.webp 子目录' ) } setItems(parsed) @@ -221,8 +336,8 @@ export function BatchImportDialog({ // 每次编辑后重新评估答案的校验 if (patch.answer !== undefined) { if (!next.answer) next.parseError = '未填写答案' - else if (next.answer.length > 4) - next.parseError = `答案超过 4 字(${next.answer.length})` + else if (next.answer.length > ANSWER_MAX_LENGTH) + next.parseError = `答案超过 ${ANSWER_MAX_LENGTH} 字(${next.answer.length})` else next.parseError = undefined } return next @@ -303,8 +418,7 @@ export function BatchImportDialog({ 批量导入关卡 - 选择包含多个关卡子文件夹的目录。每个子文件夹须含 metadata.json、 - reference.webp、riddle.webp。 + 选择包含 riddles.json 和 riddle_images 的目录;也兼容旧格式的多个关卡子文件夹。 @@ -516,7 +630,7 @@ function ItemCard({ index, item, disabled, onChange, onRemove }: ItemCardProps) onChange({ answer: e.target.value })} - maxLength={4} + maxLength={ANSWER_MAX_LENGTH} disabled={disabled} /> diff --git a/components/levels/level-dialog.tsx b/components/levels/level-dialog.tsx index 94314b5..d9569a3 100644 --- a/components/levels/level-dialog.tsx +++ b/components/levels/level-dialog.tsx @@ -226,12 +226,12 @@ export function LevelDialog({ onChange={(e) => setFormData((prev) => ({ ...prev, answer: e.target.value })) } - placeholder="请输入答案(最多4个字)" - maxLength={4} + placeholder="请输入答案(最多8个字)" + maxLength={8} required />

- {formData.answer.length}/4 + {formData.answer.length}/8

diff --git a/package-lock.json b/package-lock.json index d37ba4a..bd876cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,21 +8,21 @@ "name": "meme-studio", "version": "0.1.0", "dependencies": { - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^4.1.3", "@prisma/client": "^6.5.0", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.1.2", "@tanstack/react-query": "^5.69.0", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.24", "bcryptjs": "^3.0.2", "better-auth": "^1.2.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cos-js-sdk-v5": "^1.10.1", "cos-nodejs-sdk-v5": "^2.14.0", + "fractional-indexing": "^3.2.0", "lucide-react": "^0.483.0", "next": "14.2.28", "qcloud-cos-sts": "^3.1.1", @@ -73,59 +73,6 @@ "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" }, - "node_modules/@dnd-kit/accessibility": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", - "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/core": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", - "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", - "license": "MIT", - "dependencies": { - "@dnd-kit/accessibility": "^3.1.1", - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/sortable": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", - "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", - "license": "MIT", - "dependencies": { - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "@dnd-kit/core": "^6.3.0", - "react": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/utilities": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", - "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, "node_modules/@emnapi/core": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", @@ -1657,6 +1604,66 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", + "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -4818,6 +4825,15 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fractional-indexing": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.2.0.tgz", + "integrity": "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==", + "license": "CC0-1.0", + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",