feat: 支持多语言能力

This commit is contained in:
2026-01-24 10:10:52 +08:00
parent 77c048b6a2
commit e2280b12e2
23 changed files with 2373 additions and 374 deletions

52
CLAUDE.md Normal file
View File

@@ -0,0 +1,52 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
| Command | Description |
|---------|-------------|
| `npm run dev` | Start dev server with Turbopack |
| `npm run build` | Production build |
| `npm run start` | Run production server |
| `npm run lint` | ESLint check |
| `npm run type-check` | TypeScript type checking without emit |
| `npm run format` | Format code with Prettier |
## Architecture
### Tech Stack
- **Framework**: Next.js 15 with App Router, React 19
- **State**: Zustand for client state
- **Styling**: Tailwind CSS with HSL CSS variables (dark mode by default)
- **UI Components**: Custom components built on Radix UI primitives
- **Animations**: Framer Motion
- **Form Validation**: Zod
- **Media Processing**: Sharp (images), FFmpeg (video/audio)
### Route Groups
- `(auth)` - Authentication routes (login, register)
- `(dashboard)` - Dashboard routes with shared Sidebar layout
### Tool Pages Pattern
Each tool (image-compress, video-frames, audio-compress) follows a consistent pattern:
1. `FileUploader` - Drag-drop file input using react-dropzone
2. `ConfigPanel` - Tool-specific configuration options
3. `ProgressBar` - Processing status indicator
4. `ResultPreview` - Display processed files
5. State managed via `useUploadStore` Zustand store
### State Management
- `store/uploadStore.ts` - File list, processing status, progress tracking
- `store/authStore.ts` - User authentication state
### Styling Convention
Use `cn()` utility from `lib/utils.ts` for Tailwind class merging. Theme colors are CSS variables in `app/globals.css` accessed via HSL: `hsl(var(--primary))`.
### Type Definitions
All shared types in `types/index.ts` including file types, processing configs, API responses.
### Development Phases
- Current (Phase 1-4): Basic tools with mock API implementations
- Phase 5: AI services integration (Replicate, OpenAI)
- Phase 6: Authentication, database (PostgreSQL), payment processing (Stripe), cloud storage (Cloudflare R2)

70
CODEBUDDY.md Normal file
View File

@@ -0,0 +1,70 @@
# CODEBUDDY.md This file provides guidance to CodeBuddy when working with code in this repository.
## Commands
| Command | Description |
|---------|-------------|
| `npm run dev` | Start dev server with Turbopack (fast refresh) |
| `npm run build` | Production build |
| `npm run start` | Run production server |
| `npm run lint` | ESLint check |
| `npm run type-check` | TypeScript type checking without emit |
| `npm run format` | Format code with Prettier |
## Architecture
### Tech Stack
- **Framework**: Next.js 15 with App Router, React 19
- **State**: Zustand for client state
- **Styling**: Tailwind CSS with HSL CSS variables (dark mode by default)
- **UI Components**: Custom components built on Radix UI primitives
- **Animations**: Framer Motion
- **Form Validation**: Zod
- **Media Processing**: Sharp (images), FFmpeg (video/audio)
### Directory Structure
```
src/
├── app/ # Next.js App Router
│ ├── (auth)/ # Auth routes group (login, register)
│ ├── (dashboard)/ # Dashboard routes with Sidebar layout
│ │ ├── tools/ # Tool pages (image-compress, video-frames, audio-compress)
│ │ └── layout.tsx # Dashboard layout with Sidebar
│ ├── api/ # API routes
│ │ ├── upload/ # File upload endpoint
│ │ └── process/ # Processing endpoints per tool type
│ ├── globals.css # Global styles with CSS variables
│ └── layout.tsx # Root layout (Header + Footer)
├── components/
│ ├── ui/ # Base UI primitives (button, card, input, etc.)
│ ├── tools/ # Tool-specific components (FileUploader, ConfigPanel, ProgressBar, ResultPreview)
│ └── layout/ # Layout components (Header, Footer, Sidebar)
├── lib/
│ ├── api.ts # API client functions
│ └── utils.ts # Utility functions (cn, formatFileSize, etc.)
├── store/
│ ├── authStore.ts # Auth state
│ └── uploadStore.ts # File upload and processing state
└── types/
└── index.ts # TypeScript types (UploadedFile, ProcessedFile, configs, etc.)
```
### Key Patterns
**Route Groups**: Uses `(auth)` and `(dashboard)` route groups. Dashboard routes share a layout with `Sidebar` component.
**Tool Pages Pattern**: Each tool (image-compress, video-frames, audio-compress) follows the same pattern:
1. Uses `FileUploader` for drag-drop file input
2. Uses `ConfigPanel` for tool-specific configuration options
3. Uses `ProgressBar` to show processing status
4. Uses `ResultPreview` to display processed files
5. State managed via `useUploadStore` Zustand store
**API Routes**: API routes under `app/api/` use Node.js runtime. Each processing endpoint validates input and returns JSON responses. Currently mock implementations - production would use Sharp/FFmpeg and cloud storage.
**State Management**: Zustand stores in `store/` directory. `uploadStore` manages file list, processing status and progress. `authStore` manages user authentication state.
**Styling**: Uses `cn()` utility from `lib/utils.ts` for Tailwind class merging. Theme colors defined as CSS variables in `globals.css`. Component styling uses HSL color functions like `hsl(var(--primary))`.
**Type Definitions**: All shared types in `types/index.ts`. Includes file types, processing configs, API responses, and user types.

851
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
@@ -278,6 +279,44 @@
"node": ">=18.x"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://mirrors.tencent.com/npm/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://mirrors.tencent.com/npm/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.6",
"resolved": "https://mirrors.tencent.com/npm/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.4"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://mirrors.tencent.com/npm/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1052,6 +1091,70 @@
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@@ -1164,6 +1267,243 @@
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.16",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
@@ -1187,6 +1527,249 @@
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.1.16",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
@@ -1210,6 +1793,78 @@
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
@@ -1302,6 +1957,21 @@
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
@@ -1339,6 +2009,24 @@
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
@@ -1369,6 +2057,24 @@
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
@@ -1387,6 +2093,12 @@
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://mirrors.tencent.com/npm/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2139,6 +2851,18 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://mirrors.tencent.com/npm/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
@@ -2918,6 +3642,12 @@
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -3909,6 +4639,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -6143,6 +6882,75 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://mirrors.tencent.com/npm/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://mirrors.tencent.com/npm/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://mirrors.tencent.com/npm/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -7264,6 +8072,49 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://mirrors.tencent.com/npm/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://mirrors.tencent.com/npm/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -13,6 +13,7 @@
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",

View File

@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
export async function generateMetadata(): Promise<Metadata> {
const headersList = await headers();
const acceptLanguage = headersList.get("accept-language") || "";
const lang = acceptLanguage.includes("zh") ? "zh" : "en";
const titles = {
en: "Audio Compression - Compress & Convert Audio Files",
zh: "音频压缩 - 压缩并转换音频文件",
};
const descriptions = {
en: "Compress and convert audio files to various formats including MP3, AAC, OGG, FLAC. Adjust bitrate and sample rate for optimal quality.",
zh: "压缩并转换音频文件为多种格式,包括 MP3、AAC、OGG、FLAC。调整比特率和采样率以获得最佳质量。",
};
return {
title: titles[lang],
description: descriptions[lang],
openGraph: {
title: titles[lang],
description: descriptions[lang],
},
twitter: {
title: titles[lang],
description: descriptions[lang],
},
};
}
export default function AudioCompressLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -10,6 +10,7 @@ import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel";
import { Button } from "@/components/ui/button";
import { useUploadStore } from "@/store/uploadStore";
import { generateId } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import type { UploadedFile, ProcessedFile, AudioCompressConfig } from "@/types";
const audioAccept = {
@@ -23,59 +24,64 @@ const defaultConfig: AudioCompressConfig = {
channels: 2,
};
const configOptions: ConfigOption[] = [
{
id: "bitrate",
type: "select",
label: "Bitrate",
description: "Higher bitrate = better quality, larger file",
value: defaultConfig.bitrate,
options: [
{ label: "64 kbps", value: 64 },
{ label: "128 kbps", value: 128 },
{ label: "192 kbps", value: 192 },
{ label: "256 kbps", value: 256 },
{ label: "320 kbps", value: 320 },
],
},
{
id: "format",
type: "select",
label: "Output Format",
description: "Target audio format",
value: defaultConfig.format,
options: [
{ label: "MP3", value: "mp3" },
{ label: "AAC", value: "aac" },
{ label: "OGG", value: "ogg" },
{ label: "FLAC", value: "flac" },
],
},
{
id: "sampleRate",
type: "select",
label: "Sample Rate",
description: "Audio sample rate in Hz",
value: defaultConfig.sampleRate,
options: [
{ label: "44.1 kHz", value: 44100 },
{ label: "48 kHz", value: 48000 },
],
},
{
id: "channels",
type: "radio",
label: "Channels",
description: "Audio channels",
value: defaultConfig.channels,
options: [
{ label: "Stereo (2 channels)", value: 2 },
{ label: "Mono (1 channel)", value: 1 },
],
},
];
function useConfigOptions(config: AudioCompressConfig): ConfigOption[] {
const { t } = useTranslation();
return [
{
id: "bitrate",
type: "select",
label: t("config.audioCompression.bitrate"),
description: t("config.audioCompression.bitrateDescription"),
value: config.bitrate,
options: [
{ label: "64 kbps", value: 64 },
{ label: "128 kbps", value: 128 },
{ label: "192 kbps", value: 192 },
{ label: "256 kbps", value: 256 },
{ label: "320 kbps", value: 320 },
],
},
{
id: "format",
type: "select",
label: t("config.audioCompression.format"),
description: t("config.audioCompression.formatDescription"),
value: config.format,
options: [
{ label: "MP3", value: "mp3" },
{ label: "AAC", value: "aac" },
{ label: "OGG", value: "ogg" },
{ label: "FLAC", value: "flac" },
],
},
{
id: "sampleRate",
type: "select",
label: t("config.audioCompression.sampleRate"),
description: t("config.audioCompression.sampleRateDescription"),
value: config.sampleRate,
options: [
{ label: "44.1 kHz", value: 44100 },
{ label: "48 kHz", value: 48000 },
],
},
{
id: "channels",
type: "radio",
label: t("config.audioCompression.channels"),
description: t("config.audioCompression.channelsDescription"),
value: config.channels,
options: [
{ label: t("config.audioCompression.stereo"), value: 2 },
{ label: t("config.audioCompression.mono"), value: 1 },
],
},
];
}
export default function AudioCompressPage() {
const { t } = useTranslation();
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
useUploadStore();
@@ -112,7 +118,7 @@ export default function AudioCompressPage() {
setProcessingStatus({
status: "uploading",
progress: 0,
message: "Uploading audio...",
message: t("processing.uploadingAudio"),
});
try {
@@ -122,14 +128,14 @@ export default function AudioCompressPage() {
setProcessingStatus({
status: "uploading",
progress: i,
message: `Uploading... ${i}%`,
message: t("processing.uploadProgress", { progress: i }),
});
}
setProcessingStatus({
status: "processing",
progress: 0,
message: "Compressing audio...",
message: t("processing.compressingAudio"),
});
// Simulate processing
@@ -138,7 +144,7 @@ export default function AudioCompressPage() {
setProcessingStatus({
status: "processing",
progress: i,
message: `Compressing... ${i}%`,
message: t("processing.compressProgress", { progress: i }),
});
}
@@ -162,14 +168,14 @@ export default function AudioCompressPage() {
setProcessingStatus({
status: "completed",
progress: 100,
message: "Compression complete!",
message: t("processing.compressionComplete"),
});
} catch (error) {
setProcessingStatus({
status: "failed",
progress: 0,
message: "Compression failed",
error: error instanceof Error ? error.message : "Unknown error",
message: t("processing.compressionFailed"),
error: error instanceof Error ? error.message : t("processing.unknownError"),
});
}
};
@@ -179,6 +185,7 @@ export default function AudioCompressPage() {
};
const canProcess = files.length > 0 && processingStatus.status !== "processing";
const configOptions = useConfigOptions(config);
return (
<div className="p-6">
@@ -192,9 +199,9 @@ export default function AudioCompressPage() {
<Music className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold">Audio Compression</h1>
<h1 className="text-3xl font-bold">{t("tools.audioCompression.title")}</h1>
<p className="text-muted-foreground">
Compress and convert audio files with quality control
{t("tools.audioCompression.description")}
</p>
</div>
</div>
@@ -213,8 +220,8 @@ export default function AudioCompressPage() {
/>
<ConfigPanel
title="Audio Settings"
description="Configure compression parameters"
title={t("config.audioCompression.title")}
description={t("config.audioCompression.description")}
options={configOptions.map((opt) => ({
...opt,
value: config[opt.id as keyof AudioCompressConfig],
@@ -226,7 +233,7 @@ export default function AudioCompressPage() {
{canProcess && (
<Button onClick={handleProcess} size="lg" className="w-full">
<Volume2 className="mr-2 h-4 w-4" />
Compress Audio
{t("tools.audioCompression.compressAudio")}
</Button>
)}
</div>
@@ -241,15 +248,15 @@ export default function AudioCompressPage() {
)}
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
<h3 className="mb-3 font-semibold">Supported Formats</h3>
<h3 className="mb-3 font-semibold">{t("tools.audioCompression.supportedFormats")}</h3>
<div className="grid grid-cols-2 gap-3 text-sm text-muted-foreground">
<div>
<p className="font-medium text-foreground">Input</p>
<p>MP3, WAV, OGG, AAC, FLAC, M4A</p>
<p className="font-medium text-foreground">{t("tools.audioCompression.input")}</p>
<p>{t("tools.audioCompression.inputFormats")}</p>
</div>
<div>
<p className="font-medium text-foreground">Output</p>
<p>MP3, AAC, OGG, FLAC</p>
<p className="font-medium text-foreground">{t("tools.audioCompression.output")}</p>
<p>{t("tools.audioCompression.outputFormats")}</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
export async function generateMetadata(): Promise<Metadata> {
const headersList = await headers();
const acceptLanguage = headersList.get("accept-language") || "";
const lang = acceptLanguage.includes("zh") ? "zh" : "en";
const titles = {
en: "Image Compression - Optimize Images for Web & Mobile",
zh: "图片压缩 - 为网页和移动端优化图片",
};
const descriptions = {
en: "Optimize images for web and mobile without quality loss. Support for batch processing and format conversion including PNG, JPEG, WebP.",
zh: "为网页和移动端优化图片,不影响质量。支持批量处理和格式转换,包括 PNG、JPEG、WebP。",
};
return {
title: titles[lang],
description: descriptions[lang],
openGraph: {
title: titles[lang],
description: descriptions[lang],
},
twitter: {
title: titles[lang],
description: descriptions[lang],
},
};
}
export default function ImageCompressLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -10,6 +10,7 @@ import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel";
import { Button } from "@/components/ui/button";
import { useUploadStore } from "@/store/uploadStore";
import { generateId } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import type { UploadedFile, ProcessedFile, ImageCompressConfig } from "@/types";
const imageAccept = {
@@ -21,35 +22,40 @@ const defaultConfig: ImageCompressConfig = {
format: "original",
};
const configOptions: ConfigOption[] = [
{
id: "quality",
type: "slider",
label: "Compression Quality",
description: "Lower quality = smaller file size",
value: defaultConfig.quality,
min: 1,
max: 100,
step: 1,
suffix: "%",
icon: <Zap className="h-4 w-4" />,
},
{
id: "format",
type: "select",
label: "Output Format",
description: "Convert to a different format (optional)",
value: defaultConfig.format,
options: [
{ label: "Original", value: "original" },
{ label: "JPEG", value: "jpeg" },
{ label: "PNG", value: "png" },
{ label: "WebP", value: "webp" },
],
},
];
function useConfigOptions(config: ImageCompressConfig): ConfigOption[] {
const { t } = useTranslation();
return [
{
id: "quality",
type: "slider",
label: t("config.imageCompression.quality"),
description: t("config.imageCompression.qualityDescription"),
value: config.quality,
min: 1,
max: 100,
step: 1,
suffix: "%",
icon: <Zap className="h-4 w-4" />,
},
{
id: "format",
type: "select",
label: t("config.imageCompression.format"),
description: t("config.imageCompression.formatDescription"),
value: config.format,
options: [
{ label: t("config.imageCompression.formatOriginal"), value: "original" },
{ label: t("config.imageCompression.formatJpeg"), value: "jpeg" },
{ label: t("config.imageCompression.formatPng"), value: "png" },
{ label: t("config.imageCompression.formatWebp"), value: "webp" },
],
},
];
}
export default function ImageCompressPage() {
const { t } = useTranslation();
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
useUploadStore();
@@ -86,7 +92,7 @@ export default function ImageCompressPage() {
setProcessingStatus({
status: "uploading",
progress: 0,
message: "Uploading images...",
message: t("processing.uploadingImages"),
});
try {
@@ -96,14 +102,14 @@ export default function ImageCompressPage() {
setProcessingStatus({
status: "uploading",
progress: i,
message: `Uploading... ${i}%`,
message: t("processing.uploadProgress", { progress: i }),
});
}
setProcessingStatus({
status: "processing",
progress: 0,
message: "Compressing images...",
message: t("processing.compressingImages"),
});
// Simulate processing
@@ -112,7 +118,7 @@ export default function ImageCompressPage() {
setProcessingStatus({
status: "processing",
progress: i,
message: `Compressing... ${i}%`,
message: t("processing.compressProgress", { progress: i }),
});
}
@@ -135,14 +141,14 @@ export default function ImageCompressPage() {
setProcessingStatus({
status: "completed",
progress: 100,
message: "Compression complete!",
message: t("processing.compressionComplete"),
});
} catch (error) {
setProcessingStatus({
status: "failed",
progress: 0,
message: "Compression failed",
error: error instanceof Error ? error.message : "Unknown error",
message: t("processing.compressionFailed"),
error: error instanceof Error ? error.message : t("processing.unknownError"),
});
}
};
@@ -152,6 +158,7 @@ export default function ImageCompressPage() {
};
const canProcess = files.length > 0 && processingStatus.status !== "processing";
const configOptions = useConfigOptions(config);
return (
<div className="p-6">
@@ -165,9 +172,9 @@ export default function ImageCompressPage() {
<ImageIcon className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold">Image Compression</h1>
<h1 className="text-3xl font-bold">{t("tools.imageCompression.title")}</h1>
<p className="text-muted-foreground">
Optimize images for web and mobile without quality loss
{t("tools.imageCompression.description")}
</p>
</div>
</div>
@@ -186,8 +193,8 @@ export default function ImageCompressPage() {
/>
<ConfigPanel
title="Compression Settings"
description="Configure compression options"
title={t("config.imageCompression.title")}
description={t("config.imageCompression.description")}
options={configOptions.map((opt) => ({
...opt,
value: config[opt.id as keyof ImageCompressConfig],
@@ -199,7 +206,7 @@ export default function ImageCompressPage() {
{canProcess && (
<Button onClick={handleProcess} size="lg" className="w-full">
<Zap className="mr-2 h-4 w-4" />
Compress Images
{t("tools.imageCompression.compressImages")}
</Button>
)}
</div>
@@ -214,12 +221,11 @@ export default function ImageCompressPage() {
)}
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
<h3 className="mb-3 font-semibold">Features</h3>
<h3 className="mb-3 font-semibold">{t("tools.imageCompression.features")}</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li> Batch processing - compress multiple images at once</li>
<li> Smart compression - maintains visual quality</li>
<li> Format conversion - PNG to JPEG, WebP, and more</li>
<li> Up to 80% size reduction without quality loss</li>
{(t("tools.imageCompression.featureList") as unknown as string[]).map((feature, index) => (
<li key={index}> {feature}</li>
))}
</ul>
</div>
</div>

View File

@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
export async function generateMetadata(): Promise<Metadata> {
const headersList = await headers();
const acceptLanguage = headersList.get("accept-language") || "";
const lang = acceptLanguage.includes("zh") ? "zh" : "en";
const titles = {
en: "Video to Frames - Extract Frames from Videos",
zh: "视频抽帧 - 从视频中提取帧",
};
const descriptions = {
en: "Extract frames from videos with customizable frame rates. Perfect for sprite animations and game asset preparation. Supports MP4, MOV, AVI, WebM.",
zh: "从视频中提取帧,可自定义帧率。非常适合精灵动画制作和游戏素材准备。支持 MP4、MOV、AVI、WebM。",
};
return {
title: titles[lang],
description: descriptions[lang],
openGraph: {
title: titles[lang],
description: descriptions[lang],
},
twitter: {
title: titles[lang],
description: descriptions[lang],
},
};
}
export default function VideoFramesLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -10,6 +10,7 @@ import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel";
import { Button } from "@/components/ui/button";
import { useUploadStore } from "@/store/uploadStore";
import { generateId } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import type { UploadedFile, ProcessedFile, VideoFramesConfig } from "@/types";
const videoAccept = {
@@ -24,45 +25,50 @@ const defaultConfig: VideoFramesConfig = {
height: undefined,
};
const configOptions: ConfigOption[] = [
{
id: "fps",
type: "slider",
label: "Frame Rate",
description: "Number of frames to extract per second",
value: defaultConfig.fps,
min: 1,
max: 60,
step: 1,
suffix: " fps",
icon: <Video className="h-4 w-4" />,
},
{
id: "format",
type: "select",
label: "Output Format",
description: "Image format for the extracted frames",
value: defaultConfig.format,
options: [
{ label: "PNG", value: "png" },
{ label: "JPEG", value: "jpeg" },
{ label: "WebP", value: "webp" },
],
},
{
id: "quality",
type: "slider",
label: "Quality",
description: "Image quality (for JPEG and WebP)",
value: defaultConfig.quality,
min: 1,
max: 100,
step: 1,
suffix: "%",
},
];
function useConfigOptions(config: VideoFramesConfig): ConfigOption[] {
const { t } = useTranslation();
return [
{
id: "fps",
type: "slider",
label: t("config.videoFrames.fps"),
description: t("config.videoFrames.fpsDescription"),
value: config.fps,
min: 1,
max: 60,
step: 1,
suffix: " fps",
icon: <Video className="h-4 w-4" />,
},
{
id: "format",
type: "select",
label: t("config.videoFrames.format"),
description: t("config.videoFrames.formatDescription"),
value: config.format,
options: [
{ label: "PNG", value: "png" },
{ label: "JPEG", value: "jpeg" },
{ label: "WebP", value: "webp" },
],
},
{
id: "quality",
type: "slider",
label: t("config.videoFrames.quality"),
description: t("config.videoFrames.qualityDescription"),
value: config.quality,
min: 1,
max: 100,
step: 1,
suffix: "%",
},
];
}
export default function VideoFramesPage() {
const { t } = useTranslation();
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
useUploadStore();
@@ -99,7 +105,7 @@ export default function VideoFramesPage() {
setProcessingStatus({
status: "uploading",
progress: 0,
message: "Uploading video...",
message: t("processing.uploadingVideo"),
});
try {
@@ -109,14 +115,14 @@ export default function VideoFramesPage() {
setProcessingStatus({
status: "uploading",
progress: i,
message: `Uploading... ${i}%`,
message: t("processing.uploadProgress", { progress: i }),
});
}
setProcessingStatus({
status: "processing",
progress: 0,
message: "Extracting frames...",
message: t("processing.extractingFrames"),
});
// Simulate processing
@@ -125,7 +131,7 @@ export default function VideoFramesPage() {
setProcessingStatus({
status: "processing",
progress: i,
message: `Processing... ${i}%`,
message: t("processing.processProgress", { progress: i }),
});
}
@@ -149,24 +155,24 @@ export default function VideoFramesPage() {
setProcessingStatus({
status: "completed",
progress: 100,
message: "Processing complete!",
message: t("processing.processingComplete"),
});
} catch (error) {
setProcessingStatus({
status: "failed",
progress: 0,
message: "Processing failed",
error: error instanceof Error ? error.message : "Unknown error",
message: t("processing.processingFailed"),
error: error instanceof Error ? error.message : t("processing.unknownError"),
});
}
};
const handleDownload = (fileId: string) => {
console.log("Downloading file:", fileId);
// Implement download logic
};
const canProcess = files.length > 0 && processingStatus.status !== "processing";
const configOptions = useConfigOptions(config);
return (
<div className="p-6">
@@ -181,9 +187,9 @@ export default function VideoFramesPage() {
<Video className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold">Video to Frames</h1>
<h1 className="text-3xl font-bold">{t("tools.videoFrames.title")}</h1>
<p className="text-muted-foreground">
Extract frames from videos with customizable settings
{t("tools.videoFrames.description")}
</p>
</div>
</div>
@@ -203,8 +209,8 @@ export default function VideoFramesPage() {
/>
<ConfigPanel
title="Export Settings"
description="Configure how frames are extracted"
title={t("config.videoFrames.title")}
description={t("config.videoFrames.description")}
options={configOptions.map((opt) => ({
...opt,
value: config[opt.id as keyof VideoFramesConfig],
@@ -216,7 +222,7 @@ export default function VideoFramesPage() {
{canProcess && (
<Button onClick={handleProcess} size="lg" className="w-full">
<Settings className="mr-2 h-4 w-4" />
Process Video
{t("tools.videoFrames.processVideo")}
</Button>
)}
</div>
@@ -233,12 +239,11 @@ export default function VideoFramesPage() {
{/* Info Card */}
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
<h3 className="mb-3 font-semibold">How it works</h3>
<h3 className="mb-3 font-semibold">{t("tools.videoFrames.howItWorks")}</h3>
<ol className="space-y-2 text-sm text-muted-foreground">
<li>1. Upload your video file (MP4, MOV, AVI, etc.)</li>
<li>2. Configure frame rate, format, and quality</li>
<li>3. Click &quot;Process Video&quot; to start extraction</li>
<li>4. Download the ZIP file with all frames</li>
{(t("tools.videoFrames.steps") as unknown as string[]).map((step, index) => (
<li key={index}>{index + 1}. {step}</li>
))}
</ol>
</div>
</div>

View File

@@ -16,99 +16,94 @@ import {
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useTranslation } from "@/lib/i18n";
const features = [
{
icon: Video,
title: "Video to Frames",
description: "Extract frames from videos with customizable frame rates and formats. Perfect for sprite animations.",
href: "/tools/video-frames",
},
{
icon: Image,
title: "Image Compression",
description: "Optimize images for web and mobile without quality loss. Support for batch processing.",
href: "/tools/image-compress",
},
{
icon: Music,
title: "Audio Compression",
description: "Compress and convert audio files to various formats. Adjust bitrate and sample rate.",
href: "/tools/audio-compress",
},
{
icon: Sparkles,
title: "AI-Powered Tools",
description: "Enhance your assets with AI. Upscale images, remove backgrounds, and more.",
href: "/tools/ai-tools",
},
];
function useFeatures() {
const { t } = useTranslation();
return [
{
icon: Video,
title: t("home.tools.videoToFrames.title"),
description: t("home.tools.videoToFrames.description"),
href: "/tools/video-frames",
},
{
icon: Image,
title: t("home.tools.imageCompression.title"),
description: t("home.tools.imageCompression.description"),
href: "/tools/image-compress",
},
{
icon: Music,
title: t("home.tools.audioCompression.title"),
description: t("home.tools.audioCompression.description"),
href: "/tools/audio-compress",
},
{
icon: Sparkles,
title: t("home.tools.aiTools.title"),
description: t("home.tools.aiTools.description"),
href: "/tools/ai-tools",
},
];
}
const benefits = [
{
icon: Zap,
title: "Lightning Fast",
description: "Process files in seconds with our optimized infrastructure.",
},
{
icon: Shield,
title: "Secure & Private",
description: "Your files are encrypted and automatically deleted after processing.",
},
{
icon: Users,
title: "Built for Developers",
description: "API access, batch processing, and tools designed for game development workflows.",
},
];
function useBenefits() {
const { t } = useTranslation();
return [
{
icon: Zap,
title: t("home.benefits.lightningFast.title"),
description: t("home.benefits.lightningFast.description"),
},
{
icon: Shield,
title: t("home.benefits.secure.title"),
description: t("home.benefits.secure.description"),
},
{
icon: Users,
title: t("home.benefits.forDevelopers.title"),
description: t("home.benefits.forDevelopers.description"),
},
];
}
const pricingPlans = [
{
name: "Free",
price: "$0",
description: "Perfect for trying out",
features: [
"10 processes per day",
"50MB max file size",
"Basic tools",
"Community support",
],
cta: "Get Started",
href: "/register",
},
{
name: "Pro",
price: "$19",
period: "/month",
description: "For serious developers",
features: [
"Unlimited processes",
"500MB max file size",
"All tools including AI",
"Priority support",
"API access",
],
cta: "Start Free Trial",
href: "/pricing",
popular: true,
},
{
name: "Enterprise",
price: "Custom",
description: "For teams and businesses",
features: [
"Everything in Pro",
"Unlimited file size",
"Custom integrations",
"Dedicated support",
"SLA guarantee",
],
cta: "Contact Sales",
href: "/contact",
},
];
function usePricingPlans() {
const { t } = useTranslation();
return [
{
name: t("home.pricing.plans.free.name"),
price: t("home.pricing.plans.free.price"),
description: t("home.pricing.plans.free.description"),
features: t("home.pricing.plans.free.features") as unknown as string[],
cta: t("home.pricing.plans.free.cta"),
href: "/register",
},
{
name: t("home.pricing.plans.pro.name"),
price: t("home.pricing.plans.pro.price"),
period: t("home.pricing.plans.pro.period"),
description: t("home.pricing.plans.pro.description"),
features: t("home.pricing.plans.pro.features") as unknown as string[],
cta: t("home.pricing.plans.pro.cta"),
href: "/pricing",
popular: t("home.pricing.plans.pro.popular") as string,
},
{
name: t("home.pricing.plans.enterprise.name"),
price: t("home.pricing.plans.enterprise.price"),
description: t("home.pricing.plans.enterprise.description"),
features: t("home.pricing.plans.enterprise.features") as unknown as string[],
cta: t("home.pricing.plans.enterprise.cta"),
href: "/contact",
},
];
}
function HeroSection() {
const { t } = useTranslation();
return (
<section className="relative overflow-hidden">
{/* Background gradient */}
@@ -126,26 +121,22 @@ function HeroSection() {
>
<Badge className="mb-4" variant="secondary">
<Sparkles className="mr-1 h-3 w-3" />
AI-Powered Tools
{t("home.hero.badge")}
</Badge>
<h1 className="mb-6 text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl 2xl:text-9xl">
Build Games{" "}
<span className="bg-gradient-to-r from-purple-400 via-pink-500 to-blue-500 bg-clip-text text-transparent">
Faster
</span>
{t("home.hero.title", { speed: t("home.hero.speed") })}
</h1>
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
Transform your game development workflow with powerful AI tools. Video to frames,
image compression, audio processing, and more.
{t("home.hero.description")}
</p>
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row xl:gap-6">
<Button size="lg" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
<Link href="/tools">
Start Building <ArrowRight className="ml-2 h-4 w-4" />
{t("home.hero.startBuilding")} <ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button size="lg" variant="outline" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
<Link href="/pricing">View Pricing</Link>
<Link href="/pricing">{t("common.viewPricing")}</Link>
</Button>
</div>
@@ -158,15 +149,15 @@ function HeroSection() {
>
<div>
<div className="text-3xl font-bold md:text-4xl xl:text-5xl 2xl:text-6xl">10K+</div>
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">Developers</div>
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">{t("home.hero.stats.developers")}</div>
</div>
<div>
<div className="text-3xl font-bold md:text-4xl xl:text-5xl 2xl:text-6xl">1M+</div>
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">Files Processed</div>
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">{t("home.hero.stats.filesProcessed")}</div>
</div>
<div>
<div className="text-3xl font-bold md:text-4xl xl:text-5xl 2xl:text-6xl">99.9%</div>
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">Uptime</div>
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">{t("home.hero.stats.uptime")}</div>
</div>
</motion.div>
</motion.div>
@@ -176,6 +167,9 @@ function HeroSection() {
}
function FeaturesSection() {
const { t } = useTranslation();
const features = useFeatures();
return (
<section className="border-t border-border/40 bg-background/50 py-24 xl:py-32 2xl:py-40">
<div className="container">
@@ -187,10 +181,10 @@ function FeaturesSection() {
className="mb-16 text-center xl:mb-20 2xl:mb-24"
>
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
Everything You Need
{t("home.featuresSection.title")}
</h2>
<p className="text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
Powerful tools designed specifically for game developers
{t("home.featuresSection.description")}
</p>
</motion.div>
@@ -214,7 +208,7 @@ function FeaturesSection() {
</CardHeader>
<CardContent>
<Button variant="ghost" size="sm" className="w-full xl:text-base 2xl:text-lg">
Try it now <ArrowRight className="ml-2 h-4 w-4" />
{t("common.tryNow")} <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
@@ -228,6 +222,9 @@ function FeaturesSection() {
}
function BenefitsSection() {
const { t } = useTranslation();
const benefits = useBenefits();
return (
<section className="py-24 xl:py-32 2xl:py-40">
<div className="container">
@@ -239,14 +236,13 @@ function BenefitsSection() {
transition={{ duration: 0.5 }}
>
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
Why Choose Mini Game AI?
{t("home.benefits.title")}
</h2>
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
We understand the unique challenges of game development. Our tools are built to help
you work faster and smarter.
{t("home.benefits.description")}
</p>
<Button size="lg" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
<Link href="/about">Learn More About Us</Link>
<Link href="/about">{t("common.learnMore")}</Link>
</Button>
</motion.div>
@@ -280,6 +276,9 @@ function BenefitsSection() {
}
function PricingSection() {
const { t } = useTranslation();
const pricingPlans = usePricingPlans();
return (
<section className="border-t border-border/40 bg-background/50 py-24 xl:py-32 2xl:py-40">
<div className="container">
@@ -291,10 +290,10 @@ function PricingSection() {
className="mb-16 text-center xl:mb-20 2xl:mb-24"
>
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
Simple, Transparent Pricing
{t("home.pricing.title")}
</h2>
<p className="text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
Start free, scale as you grow. No hidden fees.
{t("home.pricing.description")}
</p>
</motion.div>
@@ -310,7 +309,7 @@ function PricingSection() {
<Card className={`relative h-full ${plan.popular ? "border-primary" : ""} xl:p-8 2xl:p-10`}>
{plan.popular && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 xl:text-base 2xl:text-lg">
Most Popular
{plan.popular}
</Badge>
)}
<CardHeader>
@@ -346,6 +345,8 @@ function PricingSection() {
}
function CTASection() {
const { t } = useTranslation();
return (
<section className="py-24 xl:py-32 2xl:py-40">
<div className="container">
@@ -358,17 +359,17 @@ function CTASection() {
>
<div className="relative z-10">
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
Ready to Level Up?
{t("home.cta.title")}
</h2>
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
Join thousands of game developers building amazing games with our tools.
{t("home.cta.description")}
</p>
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row xl:gap-6">
<Button size="lg" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
<Link href="/register">Get Started for Free</Link>
<Link href="/register">{t("home.cta.getStarted")}</Link>
</Button>
<Button size="lg" variant="outline" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
<Link href="/contact">Contact Sales</Link>
<Link href="/contact">{t("common.contactSales")}</Link>
</Button>
</div>
</div>

View File

@@ -1,38 +1,55 @@
"use client";
import Link from "next/link";
import { Sparkles, Github, Twitter } from "lucide-react";
import { useTranslation } from "@/lib/i18n";
const footerLinks = {
product: [
{ name: "Features", href: "/features" },
{ name: "Pricing", href: "/pricing" },
{ name: "API", href: "/api" },
{ name: "Documentation", href: "/docs" },
],
tools: [
{ name: "Video to Frames", href: "/tools/video-frames" },
{ name: "Image Compression", href: "/tools/image-compress" },
{ name: "Audio Compression", href: "/tools/audio-compress" },
{ name: "AI Tools", href: "/tools/ai-tools" },
],
company: [
{ name: "About", href: "/about" },
{ name: "Blog", href: "/blog" },
{ name: "Careers", href: "/careers" },
{ name: "Contact", href: "/contact" },
],
legal: [
{ name: "Privacy", href: "/privacy" },
{ name: "Terms", href: "/terms" },
{ name: "Cookie Policy", href: "/cookies" },
],
};
function useFooterLinks() {
const { t } = useTranslation();
return {
product: [
{ name: t("common.features"), href: "/features" },
{ name: t("nav.pricing"), href: "/pricing" },
{ name: "API", href: "/api" },
{ name: t("nav.docs"), href: "/docs" },
],
tools: [
{ name: t("sidebar.videoToFrames"), href: "/tools/video-frames" },
{ name: t("sidebar.imageCompression"), href: "/tools/image-compress" },
{ name: t("sidebar.audioCompression"), href: "/tools/audio-compress" },
{ name: t("home.tools.aiTools.title"), href: "/tools/ai-tools" },
],
company: [
{ name: t("nav.about"), href: "/about" },
{ name: "Blog", href: "/blog" },
{ name: "Careers", href: "/careers" },
{ name: "Contact", href: "/contact" },
],
legal: [
{ name: "Privacy", href: "/privacy" },
{ name: "Terms", href: "/terms" },
{ name: "Cookie Policy", href: "/cookies" },
],
};
}
const socialLinks = [
{ name: "Twitter", icon: Twitter, href: "https://twitter.com" },
{ name: "GitHub", icon: Github, href: "https://github.com" },
];
const sectionTitles: Record<string, string> = {
product: "Product",
tools: "Tools",
company: "Company",
legal: "Legal",
};
export function Footer() {
const { t } = useTranslation();
const footerLinks = useFooterLinks();
return (
<footer className="border-t border-border/40 bg-background/50">
<div className="container py-12 md:py-16">
@@ -43,16 +60,16 @@ export function Footer() {
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-xl font-bold">Mini Game AI</span>
<span className="text-xl font-bold">{t("common.appName")}</span>
</Link>
<p className="mt-4 text-sm text-muted-foreground">
AI-powered tools for mini game developers. Process media files with ease.
{t("footer.tagline")}
</p>
</div>
{/* Product */}
<div>
<h3 className="mb-4 text-sm font-semibold">Product</h3>
<h3 className="mb-4 text-sm font-semibold">{sectionTitles.product}</h3>
<ul className="space-y-3 text-sm">
{footerLinks.product.map((link) => (
<li key={link.name}>
@@ -69,7 +86,7 @@ export function Footer() {
{/* Tools */}
<div>
<h3 className="mb-4 text-sm font-semibold">Tools</h3>
<h3 className="mb-4 text-sm font-semibold">{sectionTitles.tools}</h3>
<ul className="space-y-3 text-sm">
{footerLinks.tools.map((link) => (
<li key={link.name}>
@@ -86,7 +103,7 @@ export function Footer() {
{/* Company */}
<div>
<h3 className="mb-4 text-sm font-semibold">Company</h3>
<h3 className="mb-4 text-sm font-semibold">{sectionTitles.company}</h3>
<ul className="space-y-3 text-sm">
{footerLinks.company.map((link) => (
<li key={link.name}>
@@ -103,7 +120,7 @@ export function Footer() {
{/* Legal */}
<div>
<h3 className="mb-4 text-sm font-semibold">Legal</h3>
<h3 className="mb-4 text-sm font-semibold">{sectionTitles.legal}</h3>
<ul className="space-y-3 text-sm">
{footerLinks.legal.map((link) => (
<li key={link.name}>
@@ -122,7 +139,7 @@ export function Footer() {
{/* Bottom section */}
<div className="mt-12 flex flex-col items-center justify-between border-t border-border/40 pt-8 md:flex-row">
<p className="text-sm text-muted-foreground">
© {new Date().getFullYear()} Mini Game AI. All rights reserved.
© {new Date().getFullYear()} {t("common.appName")}. All rights reserved.
</p>
<div className="mt-4 flex space-x-6 md:mt-0">
{socialLinks.map((link) => {

View File

@@ -5,19 +5,27 @@ import { usePathname } from "next/navigation";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Menu, X, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
// import { Button } from "@/components/ui/button"; // TODO: Uncomment when adding login/register buttons
import { LanguageSwitcher } from "./LanguageSwitcher";
import { useTranslation } from "@/lib/i18n";
import { cn } from "@/lib/utils";
const navItems = [
{ name: "Tools", href: "/tools" },
{ name: "Pricing", href: "/pricing" },
{ name: "Docs", href: "/docs" },
{ name: "About", href: "/about" },
];
function useNavItems() {
const { t } = useTranslation();
return [
{ name: t("nav.tools"), href: "/tools/image-compress" },
{ name: t("nav.pricing"), href: "/tools/video-frames" },
{ name: t("nav.docs"), href: "/tools/audio-compress" },
// Note: Temporarily redirecting to existing tool pages
// TODO: Create dedicated pages for pricing, docs, about
];
}
export function Header() {
const pathname = usePathname();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const navItems = useNavItems();
const { t } = useTranslation();
return (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@@ -27,7 +35,7 @@ export function Header() {
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-xl font-bold">Mini Game AI</span>
<span className="text-xl font-bold">{t("common.appName")}</span>
</Link>
{/* Desktop Navigation */}
@@ -48,12 +56,15 @@ export function Header() {
{/* CTA Buttons */}
<div className="hidden md:flex md:items-center md:space-x-4">
<LanguageSwitcher />
{/* TODO: Create login/register pages
<Button variant="ghost" size="sm" asChild>
<Link href="/login">Sign In</Link>
<Link href="/login">{t("common.signIn")}</Link>
</Button>
<Button size="sm" asChild>
<Link href="/register">Get Started</Link>
<Link href="/register">{t("common.getStarted")}</Link>
</Button>
*/}
</div>
{/* Mobile Menu Button */}
@@ -95,12 +106,15 @@ export function Header() {
</Link>
))}
<div className="flex flex-col space-y-2 pt-4">
<LanguageSwitcher />
{/* TODO: Create login/register pages
<Button variant="ghost" size="sm" asChild className="w-full">
<Link href="/login">Sign In</Link>
<Link href="/login">{t("common.signIn")}</Link>
</Button>
<Button size="sm" asChild className="w-full">
<Link href="/register">Get Started</Link>
<Link href="/register">{t("common.getStarted")}</Link>
</Button>
*/}
</div>
</div>
</motion.div>

View File

@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslation, type Locale } from "@/lib/i18n";
import { Check, Globe } from "lucide-react";
export function LanguageSwitcher() {
const { locale, setLocale, locales } = useTranslation();
const [open, setOpen] = useState(false);
const handleLocaleChange = (newLocale: Locale) => {
setLocale(newLocale);
setOpen(false);
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2">
<Globe className="h-4 w-4" />
<span className="hidden md:inline">{locales[locale].flag}</span>
<span className="hidden lg:inline">{locales[locale].name}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]">
{Object.entries(locales).map(([key, { name, flag }]) => (
<DropdownMenuItem
key={key}
onClick={() => handleLocaleChange(key as Locale)}
className="flex cursor-pointer items-center justify-between gap-2"
>
<span className="flex items-center gap-2">
<span>{flag}</span>
<span>{name}</span>
</span>
{locale === key && (
<Check className="h-4 w-4 text-primary" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -9,41 +9,44 @@ import {
Music,
Sparkles,
LayoutDashboard,
CreditCard,
Settings,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
const sidebarNavItems = [
{
title: "Dashboard",
items: [
{ name: "Overview", href: "/dashboard", icon: LayoutDashboard },
],
},
{
title: "Tools",
items: [
{ name: "Video to Frames", href: "/tools/video-frames", icon: Video },
{ name: "Image Compression", href: "/tools/image-compress", icon: Image },
{ name: "Audio Compression", href: "/tools/audio-compress", icon: Music },
],
},
{
title: "AI Tools",
items: [
{ name: "AI Image", href: "/tools/ai-image", icon: Sparkles },
{ name: "AI Audio", href: "/tools/ai-audio", icon: Sparkles },
],
},
{
title: "Account",
items: [
{ name: "Pricing", href: "/pricing", icon: CreditCard },
{ name: "Settings", href: "/settings", icon: Settings },
],
},
];
function useSidebarNavItems() {
const { t } = useTranslation();
return [
{
title: t("nav.dashboard"),
items: [
{ name: t("nav.overview"), href: "/", icon: LayoutDashboard },
],
},
{
title: t("sidebar.tools"),
items: [
{ name: t("sidebar.videoToFrames"), href: "/tools/video-frames", icon: Video },
{ name: t("sidebar.imageCompression"), href: "/tools/image-compress", icon: Image },
{ name: t("sidebar.audioCompression"), href: "/tools/audio-compress", icon: Music },
],
},
{
title: t("sidebar.aiTools"),
items: [
{ name: t("sidebar.aiImage"), href: "/tools/ai-tools", icon: Sparkles },
{ name: t("sidebar.aiAudio"), href: "/tools/ai-tools", icon: Sparkles },
],
},
{
title: t("nav.account"),
items: [
// TODO: Create pricing and settings pages
// { name: t("nav.pricing"), href: "/pricing", icon: CreditCard },
// { name: t("common.settings"), href: "/settings", icon: Settings },
],
},
];
}
interface SidebarProps {
className?: string;
@@ -51,6 +54,7 @@ interface SidebarProps {
export function Sidebar({ className }: SidebarProps) {
const pathname = usePathname();
const sidebarNavItems = useSidebarNavItems();
return (
<aside
@@ -61,7 +65,9 @@ export function Sidebar({ className }: SidebarProps) {
>
<div className="h-full overflow-y-auto py-6 pr-4">
<nav className="space-y-8 px-4">
{sidebarNavItems.map((section) => (
{sidebarNavItems
.filter((section) => section.items.length > 0)
.map((section) => (
<div key={section.title}>
<h3 className="mb-4 px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{section.title}

View File

@@ -6,6 +6,7 @@ import { Slider } from "@/components/ui/slider";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
export interface ConfigOption {
id: string;
@@ -38,6 +39,8 @@ export function ConfigPanel({
onReset,
className,
}: ConfigPanelProps) {
const { t } = useTranslation();
return (
<Card className={className}>
<CardHeader>
@@ -50,7 +53,7 @@ export function ConfigPanel({
</div>
{onReset && (
<Button variant="ghost" size="sm" onClick={onReset}>
Reset
{t("common.reset")}
</Button>
)}
</div>

View File

@@ -8,6 +8,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { formatFileSize, getFileExtension } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import type { UploadedFile } from "@/types";
interface FileUploaderProps {
@@ -35,6 +36,8 @@ export function FileUploader({
maxFiles = 10,
disabled = false,
}: FileUploaderProps) {
const { t, plural } = useTranslation();
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (disabled) return;
@@ -85,14 +88,17 @@ export function FileUploader({
<div className="mt-4">
<p className="text-lg font-medium">
{isDragActive
? "Drop your files here"
? t("uploader.dropActive")
: isDragReject
? "File type not accepted"
: "Drag & drop files here"}
? t("uploader.fileRejected")
: t("uploader.dropFiles")}
</p>
<p className="mt-2 text-sm text-muted-foreground">
or click to browse Max {formatFileSize(maxSize)} Up to {maxFiles} file
{maxFiles > 1 ? "s" : ""}
{t("uploader.browseFiles", {
maxSize: formatFileSize(maxSize),
maxFiles,
file: plural("uploader.file", maxFiles),
})}
</p>
</div>
</motion.div>

View File

@@ -5,6 +5,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import type { ProcessingProgress } from "@/types";
interface ProgressBarProps {
@@ -29,6 +30,7 @@ const statusColors = {
};
export function ProgressBar({ progress, className }: ProgressBarProps) {
const { t } = useTranslation();
const { status, progress: value, message, error } = progress;
const showProgress = status === "uploading" || status === "processing";
const Icon = statusIcons[status];
@@ -46,11 +48,11 @@ export function ProgressBar({ progress, className }: ProgressBarProps) {
statusColors[status]
)}
>
{status === "idle" && "Ready to process"}
{status === "uploading" && "Uploading..."}
{status === "processing" && "Processing..."}
{status === "completed" && "Completed!"}
{status === "failed" && "Failed"}
{status === "idle" && t("progress.status.idle")}
{status === "uploading" && t("progress.status.uploading")}
{status === "processing" && t("progress.status.processing")}
{status === "completed" && t("progress.status.completed")}
{status === "failed" && t("progress.status.failed")}
</p>
{showProgress && (
<span className="text-sm font-medium text-muted-foreground">

View File

@@ -6,6 +6,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { formatFileSize } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import type { ProcessedFile } from "@/types";
interface ResultPreviewProps {
@@ -21,6 +22,8 @@ export function ResultPreview({
onShare,
className,
}: ResultPreviewProps) {
const { t, plural } = useTranslation();
if (results.length === 0) return null;
const getFileIcon = (type: string) => {
@@ -36,7 +39,7 @@ export function ResultPreview({
if (metadata.compressionRatio) {
badges.push({
label: `Saved ${metadata.compressionRatio}%`,
label: t("results.saved", { ratio: metadata.compressionRatio }),
variant: "default" as const,
});
}
@@ -58,9 +61,12 @@ export function ResultPreview({
className={className}
>
<div className="mb-4">
<h3 className="text-lg font-semibold">Processing Complete</h3>
<h3 className="text-lg font-semibold">{t("results.processingComplete")}</h3>
<p className="text-sm text-muted-foreground">
{results.length} file{results.length > 1 ? "s" : ""} ready for download
{t("results.filesReady", {
count: results.length,
file: plural("results.file", results.length),
})}
</p>
</div>
@@ -115,7 +121,7 @@ export function ResultPreview({
variant="outline"
size="icon"
onClick={() => onDownload(result.id)}
title="Download"
title={t("common.download")}
>
<Download className="h-4 w-4" />
</Button>
@@ -124,7 +130,7 @@ export function ResultPreview({
variant="outline"
size="icon"
onClick={() => onShare(result.id)}
title="Share"
title={t("common.share")}
>
<Share2 className="h-4 w-4" />
</Button>

View File

@@ -0,0 +1,191 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<span className="h-2 w-2 rounded-full bg-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

85
src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,85 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import en from "@/locales/en.json";
import zh from "@/locales/zh.json";
export type Locale = "en" | "zh";
export type LocaleMessages = typeof en;
export const locales: Record<Locale, { name: string; flag: string }> = {
en: { name: "English", flag: "🇺🇸" },
zh: { name: "中文", flag: "🇨🇳" },
};
const messages: Record<Locale, LocaleMessages> = { en, zh };
interface I18nState {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string, params?: Record<string, string | number>) => string;
}
function getNestedValue(obj: any, path: string): string | unknown {
return path.split(".").reduce((acc, part) => acc?.[part], obj) || path;
}
function interpolate(template: string, params: Record<string, string | number | unknown> = {}): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
const value = params[key];
if (value === undefined || value === null) return `{{${key}}}`;
return String(value);
});
}
export const useI18nStore = create<I18nState>()(
persist(
(set, get) => ({
locale: "en",
setLocale: (locale) => set({ locale }),
t: (key, params) => {
const { locale } = get();
const value = getNestedValue(messages[locale], key);
// If the value is not a string (e.g., an array), return it as-is
if (typeof value !== "string") return value as string;
if (!params || Object.keys(params).length === 0) return value;
return interpolate(value, params);
},
}),
{
name: "locale-storage",
storage: createJSONStorage(() => localStorage),
}
)
);
// Convenience hook
export function useTranslation() {
const { locale, setLocale, t } = useI18nStore();
const plural = (key: string, count: number) => {
const suffix = count === 1 ? "_one" : "_other";
return t(`${key}${suffix}`, { count });
};
return {
locale,
setLocale,
t,
plural,
locales,
};
}
// Helper for SSR
export function getServerTranslations(locale: Locale = "en") {
return {
locale,
t: (key: string, params?: Record<string, string | number>) => {
const value = getNestedValue(messages[locale], key);
if (typeof value !== "string") return value as string;
if (!params || Object.keys(params).length === 0) return value;
return interpolate(value, params);
},
};
}

254
src/locales/en.json Normal file
View File

@@ -0,0 +1,254 @@
{
"common": {
"appName": "KYMR",
"loading": "Loading...",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"confirm": "Confirm",
"back": "Back",
"next": "Next",
"download": "Download",
"share": "Share",
"reset": "Reset",
"submit": "Submit",
"tryNow": "Try it now",
"learnMore": "Learn More",
"getStarted": "Get Started",
"startBuilding": "Start Building",
"viewPricing": "View Pricing",
"contactSales": "Contact Sales",
"signIn": "Sign In",
"register": "Register",
"features": "Features",
"settings": "Settings",
"processing": "Processing...",
"uploading": "Uploading...",
"completed": "Completed!",
"failed": "Failed",
"ready": "Ready to process",
"file": "File",
"files": "files"
},
"nav": {
"tools": "Tools",
"pricing": "Pricing",
"docs": "Docs",
"about": "About",
"dashboard": "Dashboard",
"overview": "Overview",
"account": "Account"
},
"home": {
"hero": {
"badge": "Media Processing Tools",
"title": "Empowering Game Development",
"description": "Video to frames, image compression, audio optimization. Everything you need to prepare game assets, in one place.",
"stats": {
"developers": "Developers",
"filesProcessed": "Files Processed",
"uptime": "Uptime"
}
},
"featuresSection": {
"title": "Everything You Need",
"description": "Powerful tools designed specifically for game developers"
},
"tools": {
"videoToFrames": {
"title": "Video to Frames",
"description": "Extract frames from videos with customizable frame rates. Perfect for sprite animations."
},
"imageCompression": {
"title": "Image Compression",
"description": "Optimize images for web and mobile without quality loss. Support for batch processing."
},
"audioCompression": {
"title": "Audio Compression",
"description": "Compress and convert audio files to various formats. Adjust bitrate and sample rate."
},
"aiTools": {
"title": "More Tools",
"description": "Additional utilities for game development. Coming soon."
}
},
"benefits": {
"title": "Why Choose KYMR?",
"description": "We understand the unique challenges of game development. Our tools are built to help you work faster and smarter.",
"lightningFast": {
"title": "Lightning Fast",
"description": "Process files in seconds with our optimized infrastructure."
},
"secure": {
"title": "Secure & Private",
"description": "Your files are encrypted and automatically deleted after processing."
},
"forDevelopers": {
"title": "Built for Developers",
"description": "API access, batch processing, and tools designed for game development workflows."
}
},
"pricing": {
"title": "Simple, Transparent Pricing",
"description": "Start free, scale as you grow. No hidden fees.",
"plans": {
"free": {
"name": "Free",
"price": "$0",
"description": "Perfect for trying out",
"features": ["10 processes per day", "50MB max file size", "Basic tools", "Community support"],
"cta": "Get Started"
},
"pro": {
"name": "Pro",
"price": "$19",
"period": "/month",
"description": "For serious developers",
"features": ["Unlimited processes", "500MB max file size", "All tools including AI", "Priority support", "API access"],
"cta": "Start Free Trial",
"popular": "Most Popular"
},
"enterprise": {
"name": "Enterprise",
"price": "Custom",
"description": "For teams and businesses",
"features": ["Everything in Pro", "Unlimited file size", "Custom integrations", "Dedicated support", "SLA guarantee"],
"cta": "Contact Sales"
}
}
},
"cta": {
"title": "Ready to Level Up?",
"description": "Join thousands of game developers building amazing games with our tools.",
"getStarted": "Get Started for Free"
}
},
"sidebar": {
"tools": "Tools",
"aiTools": "More Tools",
"videoToFrames": "Video to Frames",
"imageCompression": "Image Compression",
"audioCompression": "Audio Compression",
"aiImage": "AI Image",
"aiAudio": "AI Audio"
},
"uploader": {
"dropFiles": "Drag & drop files here",
"dropActive": "Drop your files here",
"fileRejected": "File type not accepted",
"browseFiles": "or click to browse • Max {{maxSize}} • Up to {{maxFiles}} {{file}}",
"file_one": "file",
"file_other": "files"
},
"progress": {
"status": {
"idle": "Ready to process",
"uploading": "Uploading...",
"processing": "Processing...",
"completed": "Completed!",
"failed": "Failed"
}
},
"config": {
"imageCompression": {
"title": "Compression Settings",
"description": "Configure compression options",
"quality": "Compression Quality",
"qualityDescription": "Lower quality = smaller file size",
"format": "Output Format",
"formatDescription": "Convert to a different format (optional)",
"formatOriginal": "Original",
"formatJpeg": "JPEG",
"formatPng": "PNG",
"formatWebp": "WebP"
},
"videoFrames": {
"title": "Export Settings",
"description": "Configure how frames are extracted",
"fps": "Frame Rate",
"fpsDescription": "Number of frames to extract per second",
"format": "Output Format",
"formatDescription": "Image format for the extracted frames",
"quality": "Quality",
"qualityDescription": "Image quality (for JPEG and WebP)"
},
"audioCompression": {
"title": "Audio Settings",
"description": "Configure compression parameters",
"bitrate": "Bitrate",
"bitrateDescription": "Higher bitrate = better quality, larger file",
"format": "Output Format",
"formatDescription": "Target audio format",
"sampleRate": "Sample Rate",
"sampleRateDescription": "Audio sample rate in Hz",
"channels": "Channels",
"channelsDescription": "Audio channels",
"stereo": "Stereo (2 channels)",
"mono": "Mono (1 channel)"
}
},
"tools": {
"imageCompression": {
"title": "Image Compression",
"description": "Optimize images for web and mobile without quality loss",
"compressImages": "Compress Images",
"features": "Features",
"featureList": [
"Batch processing - compress multiple images at once",
"Smart compression - maintains visual quality",
"Format conversion - PNG to JPEG, WebP, and more",
"Up to 80% size reduction without quality loss"
]
},
"videoFrames": {
"title": "Video to Frames",
"description": "Extract frames from videos with customizable settings",
"processVideo": "Process Video",
"howItWorks": "How it works",
"steps": [
"Upload your video file (MP4, MOV, AVI, etc.)",
"Configure frame rate, format, and quality",
"Click \"Process Video\" to start extraction",
"Download the ZIP file with all frames"
]
},
"audioCompression": {
"title": "Audio Compression",
"description": "Compress and convert audio files with quality control",
"compressAudio": "Compress Audio",
"supportedFormats": "Supported Formats",
"input": "Input",
"output": "Output",
"inputFormats": "MP3, WAV, OGG, AAC, FLAC, M4A",
"outputFormats": "MP3, AAC, OGG, FLAC"
}
},
"processing": {
"uploadingImages": "Uploading images...",
"compressingImages": "Compressing images...",
"uploadingVideo": "Uploading video...",
"extractingFrames": "Extracting frames...",
"uploadingAudio": "Uploading audio...",
"compressingAudio": "Compressing audio...",
"compressionComplete": "Compression complete!",
"processingComplete": "Processing complete!",
"compressionFailed": "Compression failed",
"processingFailed": "Processing failed",
"unknownError": "Unknown error",
"uploadProgress": "Uploading... {{progress}}%",
"compressProgress": "Compressing... {{progress}}%",
"processProgress": "Processing... {{progress}}%"
},
"results": {
"processingComplete": "Processing Complete",
"filesReady": "{{count}} {{file}} ready for download",
"file_one": "file",
"file_other": "files",
"saved": "Saved {{ratio}}%"
},
"footer": {
"tagline": "Media processing tools for game developers. Extract frames, compress images, optimize audio."
}
}

254
src/locales/zh.json Normal file
View File

@@ -0,0 +1,254 @@
{
"common": {
"appName": "KYMR",
"loading": "加载中...",
"save": "保存",
"cancel": "取消",
"delete": "删除",
"edit": "编辑",
"close": "关闭",
"confirm": "确认",
"back": "返回",
"next": "下一步",
"download": "下载",
"share": "分享",
"reset": "重置",
"submit": "提交",
"tryNow": "立即尝试",
"learnMore": "了解更多",
"getStarted": "开始使用",
"startBuilding": "开始创作",
"viewPricing": "查看价格",
"contactSales": "联系销售",
"signIn": "登录",
"register": "注册",
"features": "功能",
"settings": "设置",
"processing": "处理中...",
"uploading": "上传中...",
"completed": "已完成!",
"failed": "失败",
"ready": "准备处理",
"file": "文件",
"files": "文件"
},
"nav": {
"tools": "工具",
"pricing": "价格",
"docs": "文档",
"about": "关于",
"dashboard": "仪表盘",
"overview": "概览",
"account": "账户"
},
"home": {
"hero": {
"badge": "媒体处理工具",
"title": "为小游戏开发提供全链路提效赋能",
"description": "视频抽帧、图片压缩、音频优化。一站式游戏素材处理工具,让开发更高效。",
"stats": {
"developers": "开发者",
"filesProcessed": "文件处理量",
"uptime": "正常运行时间"
}
},
"featuresSection": {
"title": "您需要的一切",
"description": "专为游戏开发者设计的强大工具"
},
"tools": {
"videoToFrames": {
"title": "视频抽帧",
"description": "从视频中提取帧,可自定义帧率。非常适合精灵动画制作。"
},
"imageCompression": {
"title": "图片压缩",
"description": "为网页和移动端优化图片,不影响质量。支持批量处理。"
},
"audioCompression": {
"title": "音频压缩",
"description": "压缩并转换音频文件为多种格式。调整比特率和采样率。"
},
"aiTools": {
"title": "更多工具",
"description": "更多游戏开发实用工具,敬请期待。"
}
},
"benefits": {
"title": "为什么选择 KYMR",
"description": "我们了解游戏开发的独特挑战。我们的工具帮助您更快速、更智能地工作。",
"lightningFast": {
"title": "极速处理",
"description": "通过优化的基础设施,在几秒钟内处理文件。"
},
"secure": {
"title": "安全私密",
"description": "您的文件将被加密,处理完成后自动删除。"
},
"forDevelopers": {
"title": "专为开发者打造",
"description": "API 访问、批量处理,以及专为游戏开发工作流程设计的工具。"
}
},
"pricing": {
"title": "简单透明的定价",
"description": "免费开始,随您增长。无隐藏费用。",
"plans": {
"free": {
"name": "免费版",
"price": "¥0",
"description": "适合尝试使用",
"features": ["每天 10 次处理", "最大 50MB 文件", "基础工具", "社区支持"],
"cta": "开始使用"
},
"pro": {
"name": "专业版",
"price": "¥99",
"period": "/月",
"description": "适合专业开发者",
"features": ["无限处理次数", "最大 500MB 文件", "所有工具包括 AI", "优先支持", "API 访问"],
"cta": "免费试用",
"popular": "最受欢迎"
},
"enterprise": {
"name": "企业版",
"price": "定制",
"description": "适合团队和企业",
"features": ["专业版所有功能", "无限文件大小", "定制集成", "专属支持", "SLA 保证"],
"cta": "联系销售"
}
}
},
"cta": {
"title": "准备好升级了吗?",
"description": "加入数千名使用我们工具开发精彩游戏的开发者。",
"getStarted": "免费开始"
}
},
"sidebar": {
"tools": "工具",
"aiTools": "更多工具",
"videoToFrames": "视频抽帧",
"imageCompression": "图片压缩",
"audioCompression": "音频压缩",
"aiImage": "AI 图片",
"aiAudio": "AI 音频"
},
"uploader": {
"dropFiles": "拖拽文件到这里",
"dropActive": "释放文件即可上传",
"fileRejected": "不支持的文件类型",
"browseFiles": "或点击选择文件 • 最大 {{maxSize}} • 最多 {{maxFiles}} 个{{file}}",
"file_one": "文件",
"file_other": "文件"
},
"progress": {
"status": {
"idle": "准备处理",
"uploading": "上传中...",
"processing": "处理中...",
"completed": "已完成!",
"failed": "失败"
}
},
"config": {
"imageCompression": {
"title": "压缩设置",
"description": "配置压缩选项",
"quality": "压缩质量",
"qualityDescription": "质量越低 = 文件越小",
"format": "输出格式",
"formatDescription": "转换为其他格式(可选)",
"formatOriginal": "原始",
"formatJpeg": "JPEG",
"formatPng": "PNG",
"formatWebp": "WebP"
},
"videoFrames": {
"title": "导出设置",
"description": "配置帧提取方式",
"fps": "帧率",
"fpsDescription": "每秒提取的帧数",
"format": "输出格式",
"formatDescription": "提取帧的图片格式",
"quality": "质量",
"qualityDescription": "图片质量(适用于 JPEG 和 WebP"
},
"audioCompression": {
"title": "音频设置",
"description": "配置压缩参数",
"bitrate": "比特率",
"bitrateDescription": "比特率越高 = 质量越好,文件越大",
"format": "输出格式",
"formatDescription": "目标音频格式",
"sampleRate": "采样率",
"sampleRateDescription": "音频采样率Hz",
"channels": "声道",
"channelsDescription": "音频声道",
"stereo": "立体声2 声道)",
"mono": "单声道1 声道)"
}
},
"tools": {
"imageCompression": {
"title": "图片压缩",
"description": "为网页和移动端优化图片,不影响质量",
"compressImages": "压缩图片",
"features": "功能特点",
"featureList": [
"批量处理 - 一次压缩多张图片",
"智能压缩 - 保持视觉质量",
"格式转换 - PNG 转 JPEG、WebP 等",
"高达 80% 的压缩率且不影响质量"
]
},
"videoFrames": {
"title": "视频抽帧",
"description": "从视频中提取帧,可自定义设置",
"processVideo": "处理视频",
"howItWorks": "工作原理",
"steps": [
"上传视频文件MP4、MOV、AVI 等)",
"配置帧率、格式和质量",
"点击「处理视频」开始提取",
"下载包含所有帧的 ZIP 文件"
]
},
"audioCompression": {
"title": "音频压缩",
"description": "压缩并转换音频文件,可控质量",
"compressAudio": "压缩音频",
"supportedFormats": "支持的格式",
"input": "输入",
"output": "输出",
"inputFormats": "MP3、WAV、OGG、AAC、FLAC、M4A",
"outputFormats": "MP3、AAC、OGG、FLAC"
}
},
"processing": {
"uploadingImages": "上传图片中...",
"compressingImages": "压缩图片中...",
"uploadingVideo": "上传视频中...",
"extractingFrames": "提取帧中...",
"uploadingAudio": "上传音频中...",
"compressingAudio": "压缩音频中...",
"compressionComplete": "压缩完成!",
"processingComplete": "处理完成!",
"compressionFailed": "压缩失败",
"processingFailed": "处理失败",
"unknownError": "未知错误",
"uploadProgress": "上传中... {{progress}}%",
"compressProgress": "压缩中... {{progress}}%",
"processProgress": "处理中... {{progress}}%"
},
"results": {
"processingComplete": "处理完成",
"filesReady": "{{count}} 个{{file}}可下载",
"file_one": "文件",
"file_other": "文件",
"saved": "节省 {{ratio}}%"
},
"footer": {
"tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。"
}
}