feat: 完成navbar隐藏优化和侧边栏冻结功能

- 优化navbar滚动隐藏逻辑,更敏感响应
- 添加返回顶部按钮,固定在右下角
- 实现profile页面侧边栏真正冻结效果
- 修复首页滑动指示器位置
- 优化整体布局确保首屏内容完整显示
This commit is contained in:
Wuying Created Local Users
2025-12-04 20:05:13 +08:00
parent 570e864e06
commit 5f90f48a1c
25 changed files with 7493 additions and 118 deletions

2
.gitignore vendored
View File

@@ -39,3 +39,5 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/src/generated/prisma

943
API文档.md Normal file
View File

@@ -0,0 +1,943 @@
# CarrotSkin 后端 API 文档
## 概述
本文档总结了 CarrotSkin 后端 API主要关注前端需要的接口不包括 Yggdrasil 相关接口(除了更换 Yggdrasil 密码)。
## 基础信息
- **基础URL**: `/api/v1`
- **认证方式**: JWT Bearer Token
- **数据格式**: JSON
- **字符编码**: UTF-8
## 通用响应格式
所有API响应都遵循以下格式
```json
{
"code": 200,
"message": "操作成功",
"data": {
// 具体数据内容
}
}
```
分页响应格式:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"list": [],
"total": 100,
"page": 1,
"page_size": 20,
"total_pages": 5
}
}
```
## 认证相关 API
### 1. 用户注册
- **URL**: `POST /api/v1/auth/register`
- **认证**: 无需认证
- **请求参数**:
```json
{
"username": "newuser", // 用户名3-50字符
"email": "user@example.com", // 邮箱地址
"password": "password123", // 密码6-128字符
"verification_code": "123456", // 邮箱验证码6位数字
"avatar": "https://example.com/avatar.png" // 可选头像URL
}
```
- **响应数据**:
```json
{
"code": 200,
"message": "注册成功",
"data": {
"token": "jwt_token_here",
"user_info": {
"id": 1,
"username": "newuser",
"email": "user@example.com",
"avatar": "https://example.com/avatar.png",
"points": 0,
"role": "user",
"status": 1,
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
}
}
```
### 2. 用户登录
- **URL**: `POST /api/v1/auth/login`
- **认证**: 无需认证
- **请求参数**:
```json
{
"username": "testuser", // 用户名或邮箱
"password": "password123" // 密码
}
```
- **响应数据**:
```json
{
"code": 200,
"message": "登录成功",
"data": {
"token": "jwt_token_here",
"user_info": {
"id": 1,
"username": "testuser",
"email": "test@example.com",
"avatar": "https://example.com/avatar.png",
"points": 100,
"role": "user",
"status": 1,
"last_login_at": "2025-10-01T12:00:00Z",
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
}
}
```
### 3. 发送验证码
- **URL**: `POST /api/v1/auth/send-code`
- **认证**: 无需认证
- **请求参数**:
```json
{
"email": "user@example.com", // 邮箱地址
"type": "register" // 类型: register/reset_password/change_email
}
```
- **响应数据**:
```json
{
"code": 200,
"message": "验证码已发送,请查收邮件",
"data": {
"message": "验证码已发送,请查收邮件"
}
}
```
### 4. 重置密码
- **URL**: `POST /api/v1/auth/reset-password`
- **认证**: 无需认证
- **请求参数**:
```json
{
"email": "user@example.com", // 邮箱地址
"verification_code": "123456", // 邮箱验证码
"new_password": "newpassword123" // 新密码
}
```
- **响应数据**:
```json
{
"code": 200,
"message": "密码重置成功",
"data": {
"message": "密码重置成功"
}
}
```
## 用户相关 API
### 1. 获取用户信息
- **URL**: `GET /api/v1/user/profile`
- **认证**: 需要JWT认证
- **请求参数**: 无
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"id": 1,
"username": "testuser",
"email": "test@example.com",
"avatar": "https://example.com/avatar.png",
"points": 100,
"role": "user",
"status": 1,
"last_login_at": "2025-10-01T12:00:00Z",
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
}
```
### 2. 更新用户信息
- **URL**: `PUT /api/v1/user/profile`
- **认证**: 需要JWT认证
- **请求参数**:
```json
{
"avatar": "https://example.com/new-avatar.png", // 可选新头像URL
"old_password": "oldpassword123", // 可选,修改密码时需要
"new_password": "newpassword123" // 可选,新密码
}
```
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"id": 1,
"username": "testuser",
"email": "test@example.com",
"avatar": "https://example.com/new-avatar.png",
"points": 100,
"role": "user",
"status": 1,
"last_login_at": "2025-10-01T12:00:00Z",
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
}
```
### 3. 生成头像上传URL
- **URL**: `POST /api/v1/user/avatar/upload-url`
- **认证**: 需要JWT认证
- **请求参数**:
```json
{
"file_name": "avatar.png" // 文件名
}
```
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"post_url": "https://rustfs.example.com/avatars",
"form_data": {
"key": "user_1/xxx.png",
"policy": "base64_policy",
"x-amz-signature": "signature"
},
"avatar_url": "https://rustfs.example.com/avatars/user_1/xxx.png",
"expires_in": 900
}
}
```
### 4. 更新头像URL
- **URL**: `PUT /api/v1/user/avatar`
- **认证**: 需要JWT认证
- **请求参数**:
- Query参数: `avatar_url` - 头像URL
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"id": 1,
"username": "testuser",
"email": "test@example.com",
"avatar": "https://example.com/new-avatar.png",
"points": 100,
"role": "user",
"status": 1,
"last_login_at": "2025-10-01T12:00:00Z",
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
}
```
### 5. 更换邮箱
- **URL**: `POST /api/v1/user/change-email`
- **认证**: 需要JWT认证
- **请求参数**:
```json
{
"new_email": "newemail@example.com", // 新邮箱地址
"verification_code": "123456" // 邮箱验证码
}
```
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"id": 1,
"username": "testuser",
"email": "newemail@example.com",
"avatar": "https://example.com/avatar.png",
"points": 100,
"role": "user",
"status": 1,
"last_login_at": "2025-10-01T12:00:00Z",
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
}
```
### 6. 重置Yggdrasil密码
- **URL**: `POST /api/v1/user/yggdrasil-password/reset`
- **认证**: 需要JWT认证
- **请求参数**: 无
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"password": "new_yggdrasil_password"
}
}
```
## 材质相关 API
### 1. 搜索材质
- **URL**: `GET /api/v1/texture`
- **认证**: 无需认证
- **请求参数**:
- Query参数:
- `keyword`: 搜索关键词
- `type`: 材质类型 (SKIN/CAPE)
- `public_only`: 是否只搜索公开材质 (true/false)
- `page`: 页码默认1
- `page_size`: 每页数量默认20
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"list": [
{
"id": 1,
"uploader_id": 1,
"name": "My Skin",
"description": "A cool skin",
"type": "SKIN",
"url": "https://rustfs.example.com/textures/xxx.png",
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 2048,
"is_public": true,
"download_count": 100,
"favorite_count": 50,
"is_slim": false,
"status": 1,
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
],
"total": 100,
"page": 1,
"page_size": 20,
"total_pages": 5
}
}
```
### 2. 获取材质详情
- **URL**: `GET /api/v1/texture/{id}`
- **认证**: 无需认证
- **请求参数**:
- 路径参数: `id` - 材质ID
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"id": 1,
"uploader_id": 1,
"name": "My Skin",
"description": "A cool skin",
"type": "SKIN",
"url": "https://rustfs.example.com/textures/xxx.png",
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 2048,
"is_public": true,
"download_count": 100,
"favorite_count": 50,
"is_slim": false,
"status": 1,
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
}
```
### 3. 直接上传材质文件(推荐)
- **URL**: `POST /api/v1/texture/upload`
- **认证**: 需要JWT认证
- **Content-Type**: `multipart/form-data`
- **请求参数**:
- `file`: 材质文件PNG格式1KB-10MB
- `name`: 材质名称必填1-100字符
- `description`: 材质描述可选最多500字符
- `type`: 材质类型可选默认SKIN可选值SKIN/CAPE
- `is_public`: 是否公开可选默认falsetrue/false
- `is_slim`: 是否为细臂模型可选默认falsetrue/false
- **说明**:
- 后端会自动计算文件的SHA256哈希值
- 如果已存在相同哈希的材质会复用已存在的文件URL不重复上传
- 允许多次上传相同哈希的材质(包括同一用户),每次都会创建新的数据库记录
- 文件存储路径格式:`{type}/{hash[:2]}/{hash[2:4]}/{hash}.png`
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"id": 1,
"uploader_id": 1,
"name": "My Cool Skin",
"description": "A very cool skin",
"type": "SKIN",
"url": "https://rustfs.example.com/textures/skin/e3/b0/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.png",
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 2048,
"is_public": true,
"download_count": 0,
"favorite_count": 0,
"is_slim": false,
"status": 1,
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
}
```
### 4. 生成材质上传URL兼容接口
- **URL**: `POST /api/v1/texture/upload-url`
- **认证**: 需要JWT认证
- **请求参数**:
```json
{
"file_name": "skin.png", // 文件名
"texture_type": "SKIN" // 材质类型: SKIN/CAPE
}
```
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"post_url": "https://rustfs.example.com/textures",
"form_data": {
"key": "user_1/skin/xxx.png",
"policy": "base64_policy",
"x-amz-signature": "signature"
},
"texture_url": "https://rustfs.example.com/textures/user_1/skin/xxx.png",
"expires_in": 900
}
}
```
### 5. 创建材质记录配合预签名URL使用
- **URL**: `POST /api/v1/texture`
- **认证**: 需要JWT认证
- **请求参数**:
```json
{
"name": "My Cool Skin", // 材质名称1-100字符
"description": "A very cool skin", // 描述最多500字符
"type": "SKIN", // 材质类型: SKIN/CAPE
"url": "https://rustfs.example.com/textures/user_1/skin/xxx.png", // 材质URL
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", // SHA256哈希
"size": 2048, // 文件大小(字节)
"is_public": true, // 是否公开
"is_slim": false // 是否为细臂模型(Alex)
}
```
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"id": 1,
"uploader_id": 1,
"name": "My Cool Skin",
"description": "A very cool skin",
"type": "SKIN",
"url": "https://rustfs.example.com/textures/user_1/skin/xxx.png",
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 2048,
"is_public": true,
"download_count": 0,
"favorite_count": 0,
"is_slim": false,
"status": 1,
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
}
```
### 6. 更新材质
- **URL**: `PUT /api/v1/texture/{id}`
- **认证**: 需要JWT认证
- **请求参数**:
- 路径参数: `id` - 材质ID
- 请求体:
```json
{
"name": "Updated Skin Name", // 可选,新名称
"description": "Updated description", // 可选,新描述
"is_public": false // 可选,是否公开
}
```
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"id": 1,
"uploader_id": 1,
"name": "Updated Skin Name",
"description": "Updated description",
"type": "SKIN",
"url": "https://rustfs.example.com/textures/user_1/skin/xxx.png",
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 2048,
"is_public": false,
"download_count": 100,
"favorite_count": 50,
"is_slim": false,
"status": 1,
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
}
```
### 7. 删除材质
- **URL**: `DELETE /api/v1/texture/{id}`
- **认证**: 需要JWT认证
- **请求参数**:
- 路径参数: `id` - 材质ID
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": null
}
```
### 8. 切换收藏状态
- **URL**: `POST /api/v1/texture/{id}/favorite`
- **认证**: 需要JWT认证
- **请求参数**:
- 路径参数: `id` - 材质ID
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"is_favorited": true
}
}
```
### 9. 获取用户上传的材质列表
- **URL**: `GET /api/v1/texture/my`
- **认证**: 需要JWT认证
- **请求参数**:
- Query参数:
- `page`: 页码默认1
- `page_size`: 每页数量默认20
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"list": [
{
"id": 1,
"uploader_id": 1,
"name": "My Skin",
"description": "A cool skin",
"type": "SKIN",
"url": "https://rustfs.example.com/textures/xxx.png",
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 2048,
"is_public": true,
"download_count": 100,
"favorite_count": 50,
"is_slim": false,
"status": 1,
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
],
"total": 50,
"page": 1,
"page_size": 20,
"total_pages": 3
}
}
```
### 10. 获取用户收藏的材质列表
- **URL**: `GET /api/v1/texture/favorites`
- **认证**: 需要JWT认证
- **请求参数**:
- Query参数:
- `page`: 页码默认1
- `page_size`: 每页数量默认20
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"list": [
{
"id": 1,
"uploader_id": 2,
"name": "Cool Skin",
"description": "A very cool skin",
"type": "SKIN",
"url": "https://rustfs.example.com/textures/xxx.png",
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 2048,
"is_public": true,
"download_count": 100,
"favorite_count": 50,
"is_slim": false,
"status": 1,
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
],
"total": 30,
"page": 1,
"page_size": 20,
"total_pages": 2
}
}
```
## 档案相关 API
### 1. 创建档案
- **URL**: `POST /api/v1/profile`
- **认证**: 需要JWT认证
- **请求参数**:
```json
{
"name": "PlayerName" // 角色名1-16字符
}
```
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"user_id": 1,
"name": "PlayerName",
"skin_id": null,
"cape_id": null,
"is_active": false,
"last_used_at": null,
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
}
```
### 2. 获取档案列表
- **URL**: `GET /api/v1/profile`
- **认证**: 需要JWT认证
- **请求参数**: 无
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": [
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"user_id": 1,
"name": "PlayerName",
"skin_id": 1,
"cape_id": 2,
"is_active": true,
"last_used_at": "2025-10-01T12:00:00Z",
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
]
}
```
### 3. 获取档案详情
- **URL**: `GET /api/v1/profile/{uuid}`
- **认证**: 无需认证
- **请求参数**:
- 路径参数: `uuid` - 档案UUID
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"user_id": 1,
"name": "PlayerName",
"skin_id": 1,
"cape_id": 2,
"is_active": true,
"last_used_at": "2025-10-01T12:00:00Z",
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
}
```
### 4. 更新档案
- **URL**: `PUT /api/v1/profile/{uuid}`
- **认证**: 需要JWT认证
- **请求参数**:
- 路径参数: `uuid` - 档案UUID
- 请求体:
```json
{
"name": "NewPlayerName", // 可选,新角色名
"skin_id": 1, // 可选皮肤ID
"cape_id": 2 // 可选披风ID
}
```
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"user_id": 1,
"name": "NewPlayerName",
"skin_id": 1,
"cape_id": 2,
"is_active": true,
"last_used_at": "2025-10-01T12:00:00Z",
"created_at": "2025-10-01T10:00:00Z",
"updated_at": "2025-10-01T10:00:00Z"
}
}
```
### 5. 删除档案
- **URL**: `DELETE /api/v1/profile/{uuid}`
- **认证**: 需要JWT认证
- **请求参数**:
- 路径参数: `uuid` - 档案UUID
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"message": "删除成功"
}
}
```
### 6. 设置活跃档案
- **URL**: `POST /api/v1/profile/{uuid}/activate`
- **认证**: 需要JWT认证
- **请求参数**:
- 路径参数: `uuid` - 档案UUID
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"message": "设置成功"
}
}
```
## 验证码相关 API
### 1. 生成验证码
- **URL**: `GET /api/v1/captcha/generate`
- **认证**: 无需认证
- **请求参数**: 无
- **响应数据**:
```json
{
"code": 200,
"data": {
"masterImage": "base64_encoded_master_image",
"tileImage": "base64_encoded_tile_image",
"captchaId": "captcha_id_here",
"y": 100
}
}
```
### 2. 验证验证码
- **URL**: `POST /api/v1/captcha/verify`
- **认证**: 无需认证
- **请求参数**:
```json
{
"captchaId": "captcha_id_here",
"dx": 150 // 滑动距离
}
```
- **响应数据**:
```json
{
"code": 200,
"msg": "验证成功"
}
```
## 系统相关 API
### 1. 获取系统配置
- **URL**: `GET /api/v1/system/config`
- **认证**: 无需认证
- **请求参数**: 无
- **响应数据**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"site_name": "CarrotSkin",
"site_description": "A Minecraft Skin Station",
"registration_enabled": true,
"max_textures_per_user": 100,
"max_profiles_per_user": 5
}
}
```
## CustomSkin API
### 1. 获取玩家信息
- **URL**: `GET /api/v1/csl/{username}`
- **认证**: 无需认证
- **请求参数**:
- 路径参数: `username` - 玩家用户名
- **响应数据**:
```json
{
"username": "PlayerName",
"textures": {
"default": "skin_hash_here",
"slim": "skin_hash_here",
"cape": "cape_hash_here",
"elytra": "cape_hash_here"
}
}
```
或简化格式:
```json
{
"username": "PlayerName",
"skin": "skin_hash_here"
}
```
### 2. 获取资源文件
- **URL**: `GET /api/v1/csl/textures/{hash}`
- **认证**: 无需认证
- **请求参数**:
- 路径参数: `hash` - 资源哈希值
- **响应数据**: 二进制文件内容
## 健康检查
### 1. 健康检查
- **URL**: `GET /health`
- **认证**: 无需认证
- **请求参数**: 无
- **响应数据**:
```json
{
"status": "ok"
}
```
## 错误码说明
| 错误码 | 说明 |
|--------|------|
| 200 | 操作成功 |
| 400 | 请求参数错误 |
| 401 | 未认证或认证失败 |
| 403 | 无权限操作 |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
## 认证说明
需要JWT认证的API需要在请求头中添加
```
Authorization: Bearer {jwt_token}
```
JWT Token在用户登录或注册成功后返回有效期内可用于访问需要认证的API。

131
README.md
View File

@@ -1,36 +1,125 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # 🥕 CarrotSkin
## Getting Started 新一代现代化Minecraft Yggdrasil皮肤站为创作者打造的专业皮肤管理平台。
First, run the development server: ## ✨ 项目特色
### 🎨 现代化设计
- **清新橘色主题**:温暖的橙色渐变配色方案,营造舒适的视觉体验
- **玻璃态效果**使用backdrop-filter实现现代化的毛玻璃效果
- **流畅动画**基于Framer Motion的丝滑动画和交互效果
- **响应式布局**:完美适配桌面端、平板和移动设备
### 🔧 技术栈
- **Next.js 16**最新的React框架支持App Router
- **TypeScript**:类型安全的开发体验
- **Tailwind CSS v4**现代化的CSS框架支持自定义主题
- **Framer Motion**:专业级动画库
- **Heroicons**:精美的图标系统
### 🚀 核心功能
#### Yggdrasil API支持
- 完整的Minecraft Yggdrasil认证系统
- 安全可靠的API接口
- 支持第三方启动器集成
#### 皮肤管理
- 无限皮肤存储空间
- 3D皮肤预览功能
- 多角色管理系统
- 皮肤版本控制
#### 社区功能
- 皮肤分享和发现
- 用户关注和互动
- 皮肤收藏和点赞
- 评论和评价系统
#### 现代化体验
- 拖拽上传皮肤
- 实时预览效果
- 批量管理操作
- 智能搜索筛选
## 🎯 页面结构
### 主页 (/)
- 现代化英雄区域展示
- Yggdrasil特色功能介绍
- 统计数据展示
- 行动召唤区域
### 皮肤库 (/skins)
- 网格化皮肤展示
- 高级搜索和筛选
- 分类标签系统
- 排序和分页功能
### 个人中心 (/profile)
- 用户信息管理
- 角色和皮肤管理
- 账户设置
- API密钥管理
## 🛠️ 开发环境
### 安装依赖
```bash ```bash
npm run dev npm install
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. ### 启动开发服务器
```bash
npm run dev
```
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ### 构建生产版本
```bash
npm run build
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ## 🎨 设计亮点
## Learn More ### 视觉层次
- 使用不同深浅的橙色创建视觉层次
- 渐变背景增强深度感
- 阴影效果营造立体感
To learn more about Next.js, take a look at the following resources: ### 交互体验
- 鼠标跟随效果
- 滚动视差动画
- 悬停状态反馈
- 加载动画效果
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ### 现代化元素
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - 圆角卡片设计
- 渐变按钮效果
- 玻璃态导航栏
- 响应式断点优化
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ## 🔮 未来规划
## Deploy on Vercel ### 即将推出
- 3D皮肤编辑器
- 皮肤模板系统
- 高级统计分析
- 移动端APP
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. ### 长期目标
- AI皮肤生成
- 皮肤交易市场
- 创作者激励计划
- 国际化支持
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ## 🤝 贡献指南
欢迎提交Issue和Pull Request来帮助我们改进项目
## 📄 许可证
MIT License - 详见 [LICENSE](LICENSE) 文件
---
**CarrotSkin** - 让Minecraft皮肤管理变得简单而优雅 🥕

1586
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,20 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@prisma/client": "^7.1.0",
"@types/three": "^0.181.0",
"framer-motion": "^12.23.25",
"lucide-react": "^0.555.0",
"next": "16.0.7", "next": "16.0.7",
"next-auth": "^4.24.13",
"prisma": "^7.1.0",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0" "react-dom": "19.2.0",
"skinview3d": "^3.4.1",
"three": "^0.181.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View File

@@ -1,7 +1,5 @@
const config = { export default {
plugins: { plugins: {
"@tailwindcss/postcss": {}, '@tailwindcss/postcss': {},
}, },
}; }
export default config;

14
prisma.config.ts Normal file
View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});

14
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,14 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}

11
src/app/auth/layout.tsx Normal file
View File

@@ -0,0 +1,11 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen">
{children}
</div>
);
}

732
src/app/auth/page.tsx Normal file
View File

@@ -0,0 +1,732 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { EyeIcon, EyeSlashIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
import { useAuth } from '@/contexts/AuthContext';
import { errorManager } from '@/components/ErrorNotification';
export default function AuthPage() {
const [isLoginMode, setIsLoginMode] = useState(true);
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: '',
verificationCode: '',
rememberMe: false,
agreeToTerms: false
});
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(false);
const [authError, setAuthError] = useState('');
const [isSendingCode, setIsSendingCode] = useState(false);
const [codeTimer, setCodeTimer] = useState(0);
const { login, register } = useAuth();
const router = useRouter();
useEffect(() => {
let interval: NodeJS.Timeout;
if (codeTimer > 0) {
interval = setInterval(() => {
setCodeTimer(prev => prev - 1);
}, 1000);
}
return () => clearInterval(interval);
}, [codeTimer]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }));
}
if (authError) {
setAuthError('');
}
};
const validateLoginForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.username.trim()) {
newErrors.username = '请输入用户名或邮箱';
}
if (!formData.password) {
newErrors.password = '请输入密码';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const validateRegisterForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.username.trim()) {
newErrors.username = '用户名不能为空';
} else if (formData.username.length < 3) {
newErrors.username = '用户名至少需要3个字符';
} else if (formData.username.length > 50) {
newErrors.username = '用户名不能超过50个字符';
}
if (!formData.email.trim()) {
newErrors.email = '邮箱不能为空';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = '请输入有效的邮箱地址';
}
if (!formData.password) {
newErrors.password = '密码不能为空';
} else if (formData.password.length < 6) {
newErrors.password = '密码至少需要6个字符';
} else if (formData.password.length > 128) {
newErrors.password = '密码不能超过128个字符';
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = '两次输入的密码不一致';
}
if (!formData.verificationCode) {
newErrors.verificationCode = '请输入验证码';
} else if (!/^\d{6}$/.test(formData.verificationCode)) {
newErrors.verificationCode = '验证码应为6位数字';
}
if (!formData.agreeToTerms) {
newErrors.agreeToTerms = '请同意服务条款';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const getPasswordStrength = () => {
const password = formData.password;
if (password.length === 0) return { strength: 0, label: '', color: 'bg-gray-200' };
if (password.length < 6) return { strength: 1, label: '弱', color: 'bg-red-500' };
if (password.length < 10) return { strength: 2, label: '中等', color: 'bg-yellow-500' };
if (password.length >= 15) return { strength: 4, label: '很强', color: 'bg-green-500' };
return { strength: 3, label: '强', color: 'bg-blue-500' };
};
const passwordStrength = getPasswordStrength();
const handleSendCode = async () => {
if (!formData.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
setErrors(prev => ({ ...prev, email: '请输入有效的邮箱地址' }));
return;
}
setIsSendingCode(true);
try {
const response = await fetch('/api/v1/auth/send-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: formData.email,
type: 'register'
}),
});
const data = await response.json();
if (data.code === 200) {
setCodeTimer(60);
errorManager.showSuccess('验证码已发送到您的邮箱');
} else {
setErrors(prev => ({ ...prev, email: data.message || '发送验证码失败' }));
errorManager.showError(data.message || '发送验证码失败');
}
} catch (error) {
setErrors(prev => ({ ...prev, email: '发送验证码失败,请稍后重试' }));
errorManager.showError('发送验证码失败,请稍后重试');
} finally {
setIsSendingCode(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isLoginMode) {
if (!validateLoginForm()) return;
setIsLoading(true);
setAuthError('');
try {
await login(formData.username, formData.password);
if (formData.rememberMe) {
localStorage.setItem('rememberMe', 'true');
} else {
localStorage.removeItem('rememberMe');
}
errorManager.showSuccess('登录成功!');
router.push('/');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '登录失败,请检查用户名和密码';
setAuthError(errorMessage);
errorManager.showError(errorMessage);
} finally {
setIsLoading(false);
}
} else {
if (!validateRegisterForm()) return;
setIsLoading(true);
setAuthError('');
try {
await register(formData.username, formData.email, formData.password, formData.verificationCode);
errorManager.showSuccess('注册成功欢迎加入CarrotSkin');
router.push('/');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '注册失败,请稍后重试';
setAuthError(errorMessage);
errorManager.showError(errorMessage);
} finally {
setIsLoading(false);
}
}
};
const switchMode = () => {
setIsLoginMode(!isLoginMode);
setAuthError('');
setErrors({});
setFormData({
username: '',
email: '',
password: '',
confirmPassword: '',
verificationCode: '',
rememberMe: false,
agreeToTerms: false
});
};
return (
<div className="min-h-screen flex">
{/* Left Side - Orange Section */}
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-orange-500 via-orange-400 to-amber-500 flex-col justify-center items-center text-white p-12">
<motion.div
className="max-w-md"
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
>
<motion.div
className="w-24 h-24 bg-white/20 rounded-2xl flex items-center justify-center mb-8 backdrop-blur-sm border border-white/30"
whileHover={{ scale: 1.05, rotate: 5 }}
transition={{ type: 'spring', stiffness: 300 }}
>
<span className="text-4xl font-black">CS</span>
</motion.div>
<motion.h1
className="text-5xl font-black mb-6 leading-tight"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.6 }}
>
{isLoginMode ? '欢迎回来' : '加入我们'}
</motion.h1>
<motion.p
className="text-2xl mb-12 text-white/90 leading-relaxed"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.6 }}
>
{isLoginMode ? '继续你的创作之旅' : '开始你的创作之旅'}
</motion.p>
<motion.div
className="space-y-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.6 }}
>
<div className="flex items-center space-x-4">
<div className="w-8 h-8 bg-white/25 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
<span className="text-xl font-medium"></span>
</div>
<div className="flex items-center space-x-4">
<div className="w-8 h-8 bg-white/25 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
<span className="text-xl font-medium"></span>
</div>
<div className="flex items-center space-x-4">
<div className="w-8 h-8 bg-white/25 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
<span className="text-xl font-medium"></span>
</div>
</motion.div>
<motion.div
className="mt-16"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6, duration: 0.6 }}
>
<p className="text-lg text-white/80">
<span className="font-bold text-white text-2xl">10,000</span>
</p>
</motion.div>
</motion.div>
</div>
{/* Right Side - White Section */}
<div className="w-full lg:w-1/2 bg-white dark:bg-gray-900 flex flex-col justify-center items-center p-4 lg:p-8">
<motion.div
className="w-full max-w-md"
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
>
{/* Back to Home Button */}
<motion.div
className="mb-6"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.6 }}
>
<Link
href="/"
className="inline-flex items-center text-gray-500 dark:text-gray-400 hover:text-orange-500 dark:hover:text-orange-400 transition-colors duration-200"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</Link>
</motion.div>
{/* Header */}
<motion.div
className="mb-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.6 }}
>
<h2 className="text-4xl font-black text-gray-900 dark:text-white mb-2">
{isLoginMode ? '登录账户' : '创建账户'}
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400">
{isLoginMode ? '登录您的CarrotSkin账户' : '加入我们,开始创作'}
</p>
</motion.div>
{authError && (
<motion.div
className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
>
<p className="text-red-600 dark:text-red-400 text-sm">{authError}</p>
</motion.div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Username */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{isLoginMode ? '用户名或邮箱' : '用户名'}
{!isLoginMode && ' (3-50个字符)'}
</label>
<div className="relative">
<input
type="text"
name="username"
value={formData.username}
onChange={handleInputChange}
className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-2 rounded-xl transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent focus:bg-white dark:focus:bg-gray-700 ${
errors.username ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
}`}
placeholder={isLoginMode ? "请输入用户名或邮箱" : "请输入用户名"}
disabled={isLoading}
/>
{!isLoginMode && formData.username && !errors.username && (
<CheckCircleIcon className="absolute right-3 top-3.5 w-5 h-5 text-green-500" />
)}
{errors.username && (
<XCircleIcon className="absolute right-3 top-3.5 w-5 h-5 text-red-500" />
)}
</div>
{errors.username && (
<p className="mt-1 text-sm text-red-500">{errors.username}</p>
)}
</motion.div>
{/* Email - Only for registration */}
<AnimatePresence>
{!isLoginMode && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
</label>
<div className="relative">
<input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-2 rounded-xl transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent focus:bg-white dark:focus:bg-gray-700 ${
errors.email ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
}`}
placeholder="请输入邮箱地址"
disabled={isLoading}
/>
{formData.email && !errors.email && (
<CheckCircleIcon className="absolute right-3 top-3.5 w-5 h-5 text-green-500" />
)}
{errors.email && (
<XCircleIcon className="absolute right-3 top-3.5 w-5 h-5 text-red-500" />
)}
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-500">{errors.email}</p>
)}
</motion.div>
)}
</AnimatePresence>
{/* Password */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{!isLoginMode && ' (6-128个字符)'}
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
name="password"
value={formData.password}
onChange={handleInputChange}
className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-2 rounded-xl transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent pr-12 focus:bg-white dark:focus:bg-gray-700 ${
errors.password ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
}`}
placeholder="请输入密码"
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-3.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
{showPassword ? <EyeSlashIcon className="w-5 h-5" /> : <EyeIcon className="w-5 h-5" />}
</button>
</div>
{!isLoginMode && formData.password && (
<div className="mt-2">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-600 dark:text-gray-400"></span>
<span className={`font-medium ${passwordStrength.color.replace('bg-', 'text-')}`}>
{passwordStrength.label}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${passwordStrength.color}`}
style={{ width: `${passwordStrength.strength * 25}%` }}
/>
</div>
</div>
)}
{errors.password && (
<p className="mt-1 text-sm text-red-500">{errors.password}</p>
)}
</motion.div>
{/* Confirm Password - Only for registration */}
<AnimatePresence>
{!isLoginMode && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-2 rounded-xl transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent pr-12 focus:bg-white dark:focus:bg-gray-700 ${
errors.confirmPassword ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
}`}
placeholder="请再次输入密码"
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-3.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
{showConfirmPassword ? <EyeSlashIcon className="w-5 h-5" /> : <EyeIcon className="w-5 h-5" />}
</button>
</div>
{formData.confirmPassword && formData.password === formData.confirmPassword && !errors.confirmPassword && (
<CheckCircleIcon className="mt-1 w-5 h-5 text-green-500" />
)}
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-500">{errors.confirmPassword}</p>
)}
</motion.div>
)}
</AnimatePresence>
{/* Verification Code - Only for registration */}
<AnimatePresence>
{!isLoginMode && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
</label>
<div className="flex space-x-3">
<div className="flex-1 relative">
<input
type="text"
name="verificationCode"
value={formData.verificationCode}
onChange={handleInputChange}
className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-2 rounded-xl transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent focus:bg-white dark:focus:bg-gray-700 ${
errors.verificationCode ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
}`}
placeholder="请输入6位验证码"
disabled={isLoading}
maxLength={6}
/>
{errors.verificationCode && (
<p className="mt-1 text-sm text-red-500">{errors.verificationCode}</p>
)}
</div>
<button
type="button"
onClick={handleSendCode}
disabled={isSendingCode || codeTimer > 0 || !formData.email || !!errors.email}
className="px-6 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 disabled:from-gray-400 disabled:to-gray-500 text-white font-medium rounded-xl transition-all duration-200 whitespace-nowrap"
>
{codeTimer > 0 ? `${codeTimer}秒后重试` : (isSendingCode ? '发送中...' : '发送验证码')}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Remember Me - Only for login */}
<AnimatePresence>
{isLoginMode && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="flex items-center justify-between"
>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
name="rememberMe"
checked={formData.rememberMe}
onChange={handleInputChange}
className="w-4 h-4 text-orange-500 border-2 border-gray-300 rounded focus:ring-orange-500 focus:ring-2"
disabled={isLoading}
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
</span>
</label>
<Link href="/forgot-password" className="text-sm text-orange-500 hover:text-orange-600 transition-colors">
</Link>
</motion.div>
)}
</AnimatePresence>
{/* Terms Agreement - Only for registration */}
<AnimatePresence>
{!isLoginMode && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<label className="flex items-start space-x-3 cursor-pointer">
<input
type="checkbox"
name="agreeToTerms"
checked={formData.agreeToTerms}
onChange={handleInputChange}
className="mt-1 w-4 h-4 text-orange-500 border-2 border-gray-300 rounded focus:ring-orange-500 focus:ring-2"
disabled={isLoading}
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
<Link href="/terms" className="text-orange-500 hover:text-orange-600 underline ml-1">
</Link>
<Link href="/privacy" className="text-orange-500 hover:text-orange-600 underline ml-1">
</Link>
</span>
</label>
{errors.agreeToTerms && (
<p className="mt-1 text-sm text-red-500">{errors.agreeToTerms}</p>
)}
</motion.div>
)}
</AnimatePresence>
{/* Submit Button */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<motion.button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 disabled:from-gray-400 disabled:to-gray-500 text-white font-semibold py-4 px-6 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span>{isLoginMode ? '登录中...' : '注册中...'}</span>
</div>
) : (
isLoginMode ? '登录' : '创建账户'
)}
</motion.button>
</motion.div>
</form>
{/* Social Login */}
<motion.div
className="mt-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white dark:bg-gray-900 text-gray-500 dark:text-gray-400">
</span>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-4">
<motion.button
type="button"
className="flex items-center justify-center px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-all duration-200 font-medium text-gray-700 dark:text-gray-300 text-sm"
disabled={isLoading}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
</svg>
GitHub
</motion.button>
<motion.button
type="button"
className="flex items-center justify-center px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-all duration-200 font-medium text-gray-700 dark:text-gray-300 text-sm"
disabled={isLoading}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M11.5 2.5H9.5V8H11.5V2.5Z" />
<path d="M11.5 12H9.5V17.5H11.5V12Z" />
<path d="M6 7H2V17H6V7Z" />
<path d="M18 7H14V17H18V7Z" />
</svg>
Microsoft
</motion.button>
</div>
</motion.div>
{/* Mode Switch */}
<motion.div
className="text-center mt-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
>
<p className="text-sm text-gray-600 dark:text-gray-400">
{isLoginMode ? '还没有账户?' : '已有账户?'}
<button
type="button"
onClick={switchMode}
className="text-orange-500 hover:text-orange-600 font-bold ml-1 transition-colors duration-200"
>
{isLoginMode ? '立即注册' : '立即登录'}
</button>
</p>
</motion.div>
</motion.div>
</div>
</div>
);
}

View File

@@ -1,15 +1,13 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import "tailwindcss"; @import "tailwindcss";
@tailwind base;
@tailwind components;
@tailwind utilities;
:root { :root {
--background: #ffffff; --background: #ffffff;
--foreground: #171717; --foreground: #171717;
} --navbar-height: 64px; /* 与pt-16对应 */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -20,7 +18,90 @@
} }
body { body {
background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; background: var(--background);
font-family: 'Inter', Arial, Helvetica, sans-serif;
}
/* Custom utility classes */
.text-balance {
text-wrap: balance;
}
/* Custom component classes */
.btn-carrot {
background-color: #f97316;
color: white;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.btn-carrot:hover {
background-color: #ea580c;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.btn-carrot-outline {
border: 2px solid #f97316;
color: #f97316;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.2s;
}
.btn-carrot-outline:hover {
background-color: #f97316;
color: white;
}
.card-minecraft {
background-color: white;
border: 2px solid #fed7aa;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
}
.card-minecraft:hover {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
.card-minecraft {
background-color: #1f2937;
border-color: #c2410c;
}
}
.text-gradient {
background: linear-gradient(to right, #fb923c, #f97316);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.bg-gradient-carrot {
background: linear-gradient(to bottom right, #fb923c, #f97316, #ea580c);
}
/* 现代布局解决方案 */
@layer utilities {
/* 全屏减去navbar高度 */
.h-screen-nav {
height: calc(100vh - var(--navbar-height));
}
/* 侧栏最大高度,确保底部按钮可见 */
.sidebar-max-height {
max-height: calc(100vh - var(--navbar-height) - 120px);
}
/* 首页hero section专用高度 */
.min-h-screen-nav {
min-height: calc(100vh - var(--navbar-height));
}
} }

View File

@@ -1,20 +1,28 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Navbar from "@/components/Navbar";
import { AuthProvider } from "@/contexts/AuthContext";
import { MainContent } from "@/components/MainContent";
import { ErrorNotificationContainer } from "@/components/ErrorNotification";
import ScrollToTop from "@/components/ScrollToTop";
const geistSans = Geist({ const inter = Inter({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"], subsets: ["latin"],
weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
display: 'swap',
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "CarrotSkin - 现代化Minecraft Yggdrasil皮肤站",
description: "Generated by create next app", description: "新一代Minecraft Yggdrasil皮肤站为创作者打造的现代化皮肤管理平台",
keywords: "Minecraft, 皮肤站, Yggdrasil, CarrotSkin, 我的世界, 皮肤管理",
authors: [{ name: "CarrotSkin Team" }],
openGraph: {
title: "CarrotSkin - 现代化Minecraft Yggdrasil皮肤站",
description: "新一代Minecraft Yggdrasil皮肤站为创作者打造的现代化皮肤管理平台",
type: "website",
},
}; };
export default function RootLayout({ export default function RootLayout({
@@ -23,11 +31,14 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="zh-CN">
<body <body className={inter.className}>
className={`${geistSans.variable} ${geistMono.variable} antialiased`} <AuthProvider>
> <Navbar />
{children} <MainContent>{children}</MainContent>
<ErrorNotificationContainer />
<ScrollToTop />
</AuthProvider>
</body> </body>
</html> </html>
); );

108
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,108 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { HomeIcon, ArrowLeftIcon } from '@heroicons/react/24/outline';
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 via-white to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
<motion.div
className="text-center px-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
>
{/* 404 数字 */}
<motion.div
className="mb-8"
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2, duration: 0.6, type: 'spring', stiffness: 200 }}
>
<h1 className="text-9xl font-black bg-gradient-to-r from-orange-400 via-orange-500 to-amber-500 bg-clip-text text-transparent mb-4">
404
</h1>
<div className="w-24 h-1 bg-gradient-to-r from-orange-400 to-amber-500 mx-auto rounded-full" />
</motion.div>
{/* 错误信息 */}
<motion.div
className="mb-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.6 }}
>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-md mx-auto leading-relaxed">
访
</p>
</motion.div>
{/* Minecraft 风格的装饰 */}
<motion.div
className="mb-8 flex justify-center"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.6, duration: 0.6 }}
>
<div className="relative">
<div className="w-16 h-16 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg flex items-center justify-center transform rotate-12 shadow-lg">
<span className="text-2xl font-bold text-white">?</span>
</div>
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center animate-pulse">
<span className="text-white text-xs font-bold">!</span>
</div>
</div>
</motion.div>
{/* 操作按钮 */}
<motion.div
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8, duration: 0.6 }}
>
<Link
href="/"
className="inline-flex items-center px-6 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
>
<HomeIcon className="w-5 h-5 mr-2" />
</Link>
<button
onClick={() => window.history.back()}
className="inline-flex items-center px-6 py-3 border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white font-semibold rounded-xl transition-all duration-200"
>
<ArrowLeftIcon className="w-5 h-5 mr-2" />
</button>
</motion.div>
{/* 额外的帮助信息 */}
<motion.div
className="mt-8 text-sm text-gray-500 dark:text-gray-400"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1, duration: 0.6 }}
>
<p>
<Link href="/contact" className="text-orange-500 hover:text-orange-600 underline">
</Link>
</p>
</motion.div>
</motion.div>
{/* 背景装饰 */}
<div className="fixed inset-0 -z-10 overflow-hidden">
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-orange-200/20 dark:bg-orange-900/20 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-amber-200/20 dark:bg-amber-900/20 rounded-full blur-3xl" />
</div>
</div>
);
}

View File

@@ -1,65 +1,304 @@
import Image from "next/image"; 'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { motion, useScroll, useTransform } from 'framer-motion';
import {
ArrowRightIcon,
ShieldCheckIcon,
CloudArrowUpIcon,
ShareIcon,
CubeIcon,
UserGroupIcon,
SparklesIcon,
RocketLaunchIcon
} from '@heroicons/react/24/outline';
export default function Home() { export default function Home() {
const { scrollYProgress } = useScroll();
const opacity = useTransform(scrollYProgress, [0, 0.3], [1, 0]);
const scale = useTransform(scrollYProgress, [0, 0.3], [1, 0.8]);
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const [isHovered, setIsHovered] = useState(false);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMousePosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
const features = [
{
icon: ShieldCheckIcon,
title: "Yggdrasil认证",
description: "完整的Minecraft Yggdrasil API支持安全可靠的用户认证系统",
color: "from-amber-400 to-orange-500"
},
{
icon: CloudArrowUpIcon,
title: "云端存储",
description: "无限皮肤存储空间,自动备份,随时随地访问你的皮肤库",
color: "from-orange-400 to-red-500"
},
{
icon: ShareIcon,
title: "社区分享",
description: "与全球玩家分享创作,发现灵感,建立你的粉丝群体",
color: "from-red-400 to-pink-500"
},
{
icon: CubeIcon,
title: "3D预览",
description: "实时3D皮肤预览360度旋转查看支持多种渲染模式",
color: "from-pink-400 to-purple-500"
}
];
const stats = [
{ number: "50K+", label: "注册用户" },
{ number: "200K+", label: "皮肤上传" },
{ number: "1M+", label: "月活用户" },
{ number: "99.9%", label: "服务可用性" }
];
return ( return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-orange-50 to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> {/* Animated Background */}
<Image <div className="fixed inset-0 overflow-hidden pointer-events-none">
className="dark:invert" <div className="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-orange-400/20 to-amber-400/20 rounded-full blur-3xl animate-pulse"></div>
src="/next.svg" <div className="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-tr from-pink-400/20 to-orange-400/20 rounded-full blur-3xl animate-pulse delay-1000"></div>
alt="Next.js logo" <div
width={100} className="absolute w-32 h-32 bg-gradient-to-r from-orange-400/30 to-amber-400/30 rounded-full blur-2xl transition-all duration-300 ease-out"
height={20} style={{
priority left: mousePosition.x - 64,
top: mousePosition.y - 64,
transform: isHovered ? 'scale(1.5)' : 'scale(1)'
}}
/> />
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> </div>
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file. {/* Hero Section */}
<motion.section
style={{ opacity, scale }}
className="relative min-h-screen flex items-center justify-center px-4 sm:px-6 lg:px-8 overflow-hidden"
>
<div className="relative z-10 max-w-7xl mx-auto text-center">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease: "easeOut" }}
>
{/* Logo Animation */}
<motion.div
className="mb-8 flex justify-center"
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ duration: 1, type: "spring", stiffness: 100 }}
>
<div className="relative">
<div className="w-24 h-24 bg-gradient-to-br from-orange-400 via-amber-500 to-orange-600 rounded-3xl flex items-center justify-center shadow-2xl">
<span className="text-4xl font-bold text-white">CS</span>
</div>
<motion.div
className="absolute -inset-2 bg-gradient-to-br from-orange-400/30 to-amber-500/30 rounded-3xl blur-lg"
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 2, repeat: Infinity }}
/>
</div>
</motion.div>
<h1 className="text-6xl md:text-8xl font-black text-gray-900 dark:text-white mb-6 tracking-tight">
<span className="bg-gradient-to-r from-orange-500 via-amber-500 to-orange-600 bg-clip-text text-transparent">
CarrotSkin
</span>
</h1> </h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "} <motion.p
<a className="text-xl md:text-2xl text-gray-600 dark:text-gray-300 mb-8 max-w-3xl mx-auto font-light leading-relaxed"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" initial={{ opacity: 0, y: 20 }}
className="font-medium text-zinc-950 dark:text-zinc-50" animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.6 }}
> >
Templates <span className="font-semibold text-orange-500">Minecraft Yggdrasil</span>
</a>{" "} <br className="hidden sm:block" />
or the{" "}
<a </motion.p>
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50" <motion.div
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5, duration: 0.6 }}
> >
Learning <Link
</a>{" "} href="/register"
center. className="group relative overflow-hidden bg-gradient-to-r from-orange-500 to-amber-500 text-white font-semibold py-4 px-8 rounded-2xl transition-all duration-300 hover:shadow-2xl hover:shadow-orange-500/25"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<span className="relative z-10 flex items-center space-x-2">
<span></span>
<ArrowRightIcon className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</span>
<motion.div
className="absolute inset-0 bg-gradient-to-r from-amber-500 to-orange-500"
initial={{ x: '-100%' }}
whileHover={{ x: 0 }}
transition={{ duration: 0.3 }}
/>
</Link>
<Link
href="/skins"
className="group border-2 border-orange-200 dark:border-orange-700 text-orange-600 dark:text-orange-400 font-semibold py-4 px-8 rounded-2xl transition-all duration-300 hover:bg-orange-500 hover:text-white hover:border-orange-500"
>
<span className="flex items-center space-x-2">
<span></span>
<SparklesIcon className="w-5 h-5 group-hover:rotate-12 transition-transform" />
</span>
</Link>
</motion.div>
</motion.div>
</div>
{/* Scroll Indicator */}
<motion.div
className="absolute bottom-12 left-1/2 transform -translate-x-1/2"
animate={{ y: [0, 10, 0] }}
transition={{ duration: 2, repeat: Infinity }}
>
<div className="w-6 h-10 border-2 border-orange-400 rounded-full flex justify-center">
<motion.div
className="w-1 h-3 bg-orange-400 rounded-full mt-2"
animate={{ y: [0, 16, 0] }}
transition={{ duration: 2, repeat: Infinity }}
/>
</div>
</motion.div>
</motion.section>
{/* Stats Section */}
<section className="py-20 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{stats.map((stat, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1, duration: 0.6 }}
viewport={{ once: true }}
className="text-center group"
>
<div className="text-4xl md:text-5xl font-black bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent mb-2">
{stat.number}
</div>
<div className="text-gray-600 dark:text-gray-400 font-medium">
{stat.label}
</div>
<div className="h-1 w-16 bg-gradient-to-r from-orange-400 to-amber-400 mx-auto mt-3 rounded-full group-hover:w-24 transition-all duration-300"></div>
</motion.div>
))}
</div>
</div>
</section>
{/* Features Section */}
<section className="py-20 px-4 sm:px-6 lg:px-8 relative">
<div className="max-w-7xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
viewport={{ once: true }}
className="text-center mb-16"
>
<h2 className="text-4xl md:text-5xl font-black text-gray-900 dark:text-white mb-6">
<span className="bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent"> </span>
</h2>
<p className="text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto font-light">
</p>
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{features.map((feature, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: index % 2 === 0 ? -50 : 50 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1, duration: 0.6 }}
viewport={{ once: true }}
className="group relative"
>
<div className="bg-white/70 dark:bg-gray-800/70 backdrop-blur-lg rounded-3xl p-8 shadow-xl hover:shadow-2xl transition-all duration-300 border border-white/20 dark:border-gray-700/50">
<div className={`w-16 h-16 bg-gradient-to-br ${feature.color} rounded-2xl flex items-center justify-center mb-6 shadow-lg group-hover:scale-110 transition-transform duration-300`}>
<feature.icon className="w-8 h-8 text-white" />
</div>
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
{feature.title}
</h3>
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
{feature.description}
</p> </p>
</div> </div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> </motion.div>
<a ))}
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div> </div>
</main> </div>
</section>
{/* CTA Section */}
<section className="py-20 px-4 sm:px-6 lg:px-8 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-orange-500 via-amber-500 to-orange-600">
<div className="absolute inset-0 bg-black/10"></div>
</div>
<motion.div
className="relative z-10 max-w-4xl mx-auto text-center"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
viewport={{ once: true }}
>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
className="w-32 h-32 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-8"
>
<RocketLaunchIcon className="w-16 h-16 text-white/80" />
</motion.div>
<h2 className="text-4xl md:text-5xl font-black text-white mb-6">
</h2>
<p className="text-xl text-white/90 mb-8 max-w-2xl mx-auto font-light">
CarrotSkinMinecraft皮肤管理平台
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/register"
className="group bg-white text-orange-600 hover:bg-gray-100 font-bold py-4 px-8 rounded-2xl transition-all duration-300 inline-flex items-center space-x-2 shadow-2xl"
>
<span></span>
<ArrowRightIcon className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
<Link
href="/api"
className="border-2 border-white/30 text-white hover:bg-white/10 font-bold py-4 px-8 rounded-2xl transition-all duration-300 inline-flex items-center space-x-2"
>
<span>API文档</span>
<UserGroupIcon className="w-5 h-5" />
</Link>
</div>
</motion.div>
</section>
</div> </div>
); );
} }

1359
src/app/profile/page.tsx Normal file

File diff suppressed because it is too large Load Diff

462
src/app/skins/page.tsx Normal file
View File

@@ -0,0 +1,462 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { MagnifyingGlassIcon, EyeIcon, HeartIcon, ArrowDownTrayIcon, SparklesIcon, FunnelIcon, ArrowsUpDownIcon } from '@heroicons/react/24/outline';
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid';
import SkinViewer from '@/components/SkinViewer';
import { searchTextures, toggleFavorite, type Texture } from '@/lib/api';
import { useAuth } from '@/contexts/AuthContext';
export default function SkinsPage() {
const [textures, setTextures] = useState<Texture[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [textureType, setTextureType] = useState<'SKIN' | 'CAPE' | 'ALL'>('ALL');
const [sortBy, setSortBy] = useState('最新');
const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [favoritedIds, setFavoritedIds] = useState<Set<number>>(new Set());
const { isAuthenticated } = useAuth();
const sortOptions = ['最新', '最热', '最多下载'];
const pageSize = 20;
// 加载材质数据
const loadTextures = useCallback(async () => {
setIsLoading(true);
try {
console.log('开始加载材质数据,参数:', { searchTerm, textureType, sortBy, page, pageSize });
const response = await searchTextures({
keyword: searchTerm || undefined,
type: textureType !== 'ALL' ? textureType : undefined,
public_only: true,
page,
page_size: pageSize,
});
console.log('API响应数据:', response);
console.log('API响应code:', response.code);
console.log('API响应data:', response.data);
if (response.code === 200 && response.data) {
// 安全地处理数据,避免未定义错误
const textureList = response.data.list || [];
const totalCount = response.data.total || 0;
const totalPagesCount = response.data.total_pages || 1;
console.log('解析后的数据:', { textureList, totalCount, totalPagesCount });
console.log('材质列表长度:', textureList.length);
if (textureList.length > 0) {
console.log('第一个材质数据:', textureList[0]);
console.log('第一个材质URL:', textureList[0].url);
}
let sortedList = [...textureList];
// 客户端排序
if (sortedList.length > 0) {
switch (sortBy) {
case '最热':
sortedList = sortedList.sort((a, b) => (b.favorite_count || 0) - (a.favorite_count || 0));
break;
case '最多下载':
sortedList = sortedList.sort((a, b) => (b.download_count || 0) - (a.download_count || 0));
break;
default: // 最新
sortedList = sortedList.sort((a, b) => {
const dateA = new Date(a.created_at || 0).getTime();
const dateB = new Date(b.created_at || 0).getTime();
return dateB - dateA;
});
}
}
setTextures(sortedList);
setTotal(totalCount);
setTotalPages(totalPagesCount);
console.log('设置状态后的数据:', { sortedListLength: sortedList.length, totalCount, totalPagesCount });
} else {
// API返回错误状态
console.warn('API返回错误:', response.message);
console.warn('API完整响应:', response);
setTextures([]);
setTotal(0);
setTotalPages(1);
}
} catch (error) {
console.error('加载材质失败:', error);
// 发生网络或其他错误时,显示空状态
setTextures([]);
setTotal(0);
setTotalPages(1);
} finally {
setIsLoading(false);
console.log('加载完成isLoading设置为false');
}
}, [searchTerm, textureType, sortBy, page]);
useEffect(() => {
loadTextures();
}, [loadTextures]);
// 处理收藏
const handleFavorite = async (id: number) => {
if (!isAuthenticated) {
alert('请先登录');
return;
}
try {
const response = await toggleFavorite(id);
if (response.code === 200) {
setFavoritedIds(prev => {
const newSet = new Set(prev);
if (response.data.is_favorited) {
newSet.add(id);
} else {
newSet.delete(id);
}
return newSet;
});
// 更新本地数据
setTextures(prev => prev.map(texture =>
texture.id === id
? {
...texture,
favorite_count: response.data.is_favorited
? texture.favorite_count + 1
: Math.max(0, texture.favorite_count - 1)
}
: texture
));
}
} catch (error) {
console.error('收藏操作失败:', error);
alert('操作失败,请稍后重试');
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-orange-50 to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
{/* Animated Background - 保持背景但简化 */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-orange-400/10 to-amber-400/10 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-tr from-pink-400/10 to-orange-400/10 rounded-full blur-3xl animate-pulse delay-1000"></div>
</div>
<div className="relative z-0 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* 简化的头部区域 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="mb-8"
>
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-2">
</h1>
<p className="text-gray-600 dark:text-gray-400">
Minecraft皮肤与披风
</p>
</motion.div>
{/* 重新设计的搜索区域 - 更紧凑专业 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.5 }}
className="bg-white/60 dark:bg-gray-800/60 backdrop-blur-md rounded-2xl shadow-lg p-6 mb-6 border border-white/10 dark:border-gray-700/30"
>
<div className="flex flex-col lg:flex-row gap-4 items-end">
{/* 搜索框 - 更紧凑 */}
<div className="flex-1">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="搜索皮肤、披风或作者..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setPage(1);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
loadTextures();
}
}}
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-orange-500 bg-white/80 dark:bg-gray-700/80 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500"
/>
</div>
</div>
{/* 类型筛选 - 更紧凑 */}
<div className="lg:w-48">
<div className="relative">
<FunnelIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<select
value={textureType}
onChange={(e) => {
setTextureType(e.target.value as 'SKIN' | 'CAPE' | 'ALL');
setPage(1);
}}
className="w-full pl-10 pr-8 py-2.5 border border-gray-200 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-orange-500 bg-white/80 dark:bg-gray-700/80 text-gray-900 dark:text-white transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 appearance-none"
>
<option value="ALL"></option>
<option value="SKIN"></option>
<option value="CAPE"></option>
</select>
</div>
</div>
{/* 排序 - 更紧凑 */}
<div className="lg:w-48">
<div className="relative">
<ArrowsUpDownIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<select
value={sortBy}
onChange={(e) => {
setSortBy(e.target.value);
setPage(1);
}}
className="w-full pl-10 pr-8 py-2.5 border border-gray-200 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-orange-500 bg-white/80 dark:bg-gray-700/80 text-gray-900 dark:text-white transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 appearance-none"
>
{sortOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
</div>
{/* 搜索按钮 - 更简洁 */}
<motion.button
onClick={loadTextures}
className="px-6 py-2.5 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-medium rounded-xl transition-all duration-200 shadow-md hover:shadow-lg"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
</motion.button>
</div>
</motion.div>
{/* 结果统计 - 更简洁 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="mb-6 flex justify-between items-center"
>
<p className="text-gray-600 dark:text-gray-400">
<span className="font-semibold text-orange-500">{total}</span>
</p>
{totalPages > 1 && (
<div className="flex gap-2">
<motion.button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200"
whileHover={{ scale: page === 1 ? 1 : 1.05 }}
whileTap={{ scale: page === 1 ? 1 : 0.95 }}
>
</motion.button>
<span className="px-4 py-2 text-gray-600 dark:text-gray-400">
{page} / {totalPages}
</span>
<motion.button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200"
whileHover={{ scale: page === totalPages ? 1 : 1.05 }}
whileTap={{ scale: page === totalPages ? 1 : 0.95 }}
>
</motion.button>
</div>
)}
</motion.div>
{/* Loading State - 保持但简化 */}
<AnimatePresence>
{isLoading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
>
{Array.from({ length: 8 }).map((_, i) => (
<motion.div
key={i}
className="animate-pulse"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
<div className="bg-gray-200 dark:bg-gray-700 rounded-xl aspect-square mb-3"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
</motion.div>
))}
</motion.div>
)}
</AnimatePresence>
{/* Textures Grid - 保持卡片设计但简化 */}
<AnimatePresence>
{!isLoading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
>
{textures.map((texture, index) => {
const isFavorited = favoritedIds.has(texture.id);
return (
<motion.div
key={texture.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="group relative"
>
<div className="bg-white/60 dark:bg-gray-800/60 backdrop-blur-md rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 border border-white/10 dark:border-gray-700/30 overflow-hidden">
{/* 3D Skin Preview */}
<div className="aspect-square bg-gradient-to-br from-orange-50 to-amber-50 dark:from-gray-700 dark:to-gray-600 relative overflow-hidden group flex items-center justify-center">
{texture.type === 'SKIN' ? (
<SkinViewer
skinUrl={texture.url}
isSlim={texture.is_slim}
width={400}
height={400}
className="w-full h-full transition-transform duration-300 group-hover:scale-105"
autoRotate={false}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<motion.div
className="w-24 h-24 mx-auto mb-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg flex items-center justify-center"
whileHover={{ scale: 1.1, rotate: 5 }}
transition={{ type: 'spring', stiffness: 300 }}
>
<span className="text-xl">🧥</span>
</motion.div>
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium"></p>
</div>
</div>
)}
{/* 标签 */}
<div className="absolute top-3 right-3 flex gap-1.5">
<motion.span
className={`px-2 py-1 text-white text-xs rounded-full font-medium backdrop-blur-sm ${
texture.type === 'SKIN' ? 'bg-blue-500/80' : 'bg-purple-500/80'
}`}
whileHover={{ scale: 1.05 }}
>
{texture.type === 'SKIN' ? '皮肤' : '披风'}
</motion.span>
{texture.is_slim && (
<motion.span
className="px-2 py-1 bg-pink-500/80 text-white text-xs rounded-full font-medium backdrop-blur-sm"
whileHover={{ scale: 1.05 }}
>
</motion.span>
)}
</div>
</div>
{/* Texture Info */}
<div className="p-4">
<h3 className="font-semibold text-gray-900 dark:text-white mb-1 truncate">{texture.name}</h3>
{texture.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2 leading-relaxed">
{texture.description}
</p>
)}
{/* Stats */}
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400 mb-4">
<div className="flex items-center space-x-3">
<motion.span
className="flex items-center space-x-1"
whileHover={{ scale: 1.05 }}
>
<HeartIcon className="w-4 h-4 text-red-400" />
<span className="font-medium">{texture.favorite_count}</span>
</motion.span>
<motion.span
className="flex items-center space-x-1"
whileHover={{ scale: 1.05 }}
>
<ArrowDownTrayIcon className="w-4 h-4 text-blue-400" />
<span className="font-medium">{texture.download_count}</span>
</motion.span>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<motion.button
onClick={() => window.open(texture.url, '_blank')}
className="flex-1 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200 font-medium shadow-md hover:shadow-lg"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
</motion.button>
<motion.button
onClick={() => handleFavorite(texture.id)}
className={`px-3 py-2 border rounded-lg transition-all duration-200 font-medium ${
isFavorited
? 'bg-gradient-to-r from-red-500 to-pink-500 border-transparent text-white shadow-md'
: 'border-orange-500 text-orange-500 hover:bg-gradient-to-r hover:from-orange-500 hover:to-orange-600 hover:text-white hover:border-transparent hover:shadow-md'
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{isFavorited ? (
<HeartIconSolid className="w-4 h-4" />
) : (
<HeartIcon className="w-4 h-4" />
)}
</motion.button>
</div>
</div>
</div>
</motion.div>
);
})}
</motion.div>
)}
</AnimatePresence>
{/* Empty State - 简化 */}
<AnimatePresence>
{!isLoading && textures.length === 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="text-center py-16"
>
<div className="w-20 h-20 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4">
<MagnifyingGlassIcon className="w-10 h-10 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2"></h3>
<p className="text-gray-600 dark:text-gray-400"></p>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -0,0 +1,194 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { XMarkIcon, ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from '@heroicons/react/24/outline';
export type ErrorType = 'error' | 'warning' | 'success' | 'info';
interface ErrorNotificationProps {
message: string;
type?: ErrorType;
duration?: number;
onClose?: () => void;
}
export function ErrorNotification({ message, type = 'error', duration = 5000, onClose }: ErrorNotificationProps) {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
if (duration > 0) {
const timer = setTimeout(() => {
setIsVisible(false);
onClose?.();
}, duration);
return () => clearTimeout(timer);
}
}, [duration, onClose]);
const handleClose = () => {
setIsVisible(false);
onClose?.();
};
const getIcon = () => {
switch (type) {
case 'error':
return <ExclamationTriangleIcon className="w-5 h-5" />;
case 'warning':
return <ExclamationTriangleIcon className="w-5 h-5" />;
case 'success':
return <CheckCircleIcon className="w-5 h-5" />;
case 'info':
return <InformationCircleIcon className="w-5 h-5" />;
}
};
const getStyles = () => {
switch (type) {
case 'error':
return {
bg: 'bg-red-50 dark:bg-red-900/20',
border: 'border-red-200 dark:border-red-800',
text: 'text-red-800 dark:text-red-200',
icon: 'text-red-500',
close: 'text-red-400 hover:text-red-600 dark:text-red-300 dark:hover:text-red-100'
};
case 'warning':
return {
bg: 'bg-yellow-50 dark:bg-yellow-900/20',
border: 'border-yellow-200 dark:border-yellow-800',
text: 'text-yellow-800 dark:text-yellow-200',
icon: 'text-yellow-500',
close: 'text-yellow-400 hover:text-yellow-600 dark:text-yellow-300 dark:hover:text-yellow-100'
};
case 'success':
return {
bg: 'bg-green-50 dark:bg-green-900/20',
border: 'border-green-200 dark:border-green-800',
text: 'text-green-800 dark:text-green-200',
icon: 'text-green-500',
close: 'text-green-400 hover:text-green-600 dark:text-green-300 dark:hover:text-green-100'
};
case 'info':
return {
bg: 'bg-blue-50 dark:bg-blue-900/20',
border: 'border-blue-200 dark:border-blue-800',
text: 'text-blue-800 dark:text-blue-200',
icon: 'text-blue-500',
close: 'text-blue-400 hover:text-blue-600 dark:text-blue-300 dark:hover:text-blue-100'
};
}
};
const styles = getStyles();
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.9 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
className={`fixed top-4 right-4 z-50 max-w-sm w-full ${styles.bg} ${styles.border} border rounded-xl shadow-lg backdrop-blur-sm`}
>
<div className="flex items-start p-4">
<div className={`flex-shrink-0 ${styles.icon} mr-3 mt-0.5`}>
{getIcon()}
</div>
<div className="flex-1">
<p className={`text-sm font-medium ${styles.text}`}>
{message}
</p>
</div>
<button
onClick={handleClose}
className={`flex-shrink-0 ml-3 ${styles.close} transition-colors`}
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
// 全局错误管理器
class ErrorManager {
private static instance: ErrorManager;
private listeners: Array<(notification: ErrorNotificationProps & { id: string }) => void> = [];
static getInstance(): ErrorManager {
if (!ErrorManager.instance) {
ErrorManager.instance = new ErrorManager();
}
return ErrorManager.instance;
}
showError(message: string, duration?: number) {
this.showNotification(message, 'error', duration);
}
showWarning(message: string, duration?: number) {
this.showNotification(message, 'warning', duration);
}
showSuccess(message: string, duration?: number) {
this.showNotification(message, 'success', duration);
}
showInfo(message: string, duration?: number) {
this.showNotification(message, 'info', duration);
}
private showNotification(message: string, type: ErrorType, duration?: number) {
const notification = {
id: Math.random().toString(36).substr(2, 9),
message,
type,
duration: duration ?? (type === 'error' ? 5000 : 3000)
};
this.listeners.forEach(listener => listener(notification));
}
subscribe(listener: (notification: ErrorNotificationProps & { id: string }) => void) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
}
export const errorManager = ErrorManager.getInstance();
// 错误提示容器组件
export function ErrorNotificationContainer() {
const [notifications, setNotifications] = useState<Array<ErrorNotificationProps & { id: string }>>([]);
useEffect(() => {
const unsubscribe = errorManager.subscribe((notification) => {
setNotifications(prev => [...prev, notification]);
});
return unsubscribe;
}, []);
const removeNotification = (id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id));
};
return (
<>
{notifications.map((notification) => (
<ErrorNotification
key={notification.id}
{...notification}
onClose={() => removeNotification(notification.id)}
/>
))}
</>
);
}

View File

@@ -0,0 +1,279 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import {
HomeIcon,
ArrowLeftIcon,
ExclamationTriangleIcon,
XCircleIcon,
ClockIcon,
ServerIcon,
WifiIcon
} from '@heroicons/react/24/outline';
export interface ErrorPageProps {
code?: number;
title?: string;
message?: string;
description?: string;
type?: '404' | '500' | '403' | 'network' | 'timeout' | 'maintenance' | 'custom';
actions?: {
primary?: {
label: string;
href?: string;
onClick?: () => void;
};
secondary?: {
label: string;
href?: string;
onClick?: () => void;
};
};
showContact?: boolean;
}
const errorConfigs = {
'404': {
icon: <XCircleIcon className="w-16 h-16" />,
title: '页面不见了',
message: '抱歉,我们找不到您要访问的页面。',
description: '它可能已被移动、删除,或者您输入的链接不正确。'
},
'500': {
icon: <ServerIcon className="w-16 h-16" />,
title: '服务器错误',
message: '抱歉,服务器遇到了一些问题。',
description: '我们的团队正在努力解决这个问题,请稍后再试。'
},
'403': {
icon: <ExclamationTriangleIcon className="w-16 h-16" />,
title: '访问被拒绝',
message: '抱歉,您没有权限访问此页面。',
description: '请检查您的账户权限或联系管理员。'
},
'network': {
icon: <WifiIcon className="w-16 h-16" />,
title: '网络连接错误',
message: '无法连接到服务器。',
description: '请检查您的网络连接,然后重试。'
},
'timeout': {
icon: <ClockIcon className="w-16 h-16" />,
title: '请求超时',
message: '请求处理时间过长。',
description: '请刷新页面或稍后再试。'
},
'maintenance': {
icon: <ServerIcon className="w-16 h-16" />,
title: '系统维护中',
message: '我们正在进行系统维护。',
description: '请稍后再试,我们会尽快恢复服务。'
}
};
export function ErrorPage({
code,
title,
message,
description,
type = 'custom',
actions,
showContact = true
}: ErrorPageProps) {
const config = errorConfigs[type] || {};
const displayTitle = title || config.title || '出错了';
const displayMessage = message || config.message || '发生了一些错误';
const displayDescription = description || config.description || '';
const defaultActions = {
primary: {
label: '返回主页',
href: '/'
},
secondary: {
label: '返回上页',
onClick: () => window.history.back()
}
};
const finalActions = { ...defaultActions, ...actions };
const getIconColor = () => {
switch (type) {
case '404': return 'text-orange-500';
case '500': return 'text-red-500';
case '403': return 'text-yellow-500';
case 'network': return 'text-blue-500';
case 'timeout': return 'text-purple-500';
case 'maintenance': return 'text-gray-500';
default: return 'text-orange-500';
}
};
const getCodeColor = () => {
switch (type) {
case '404': return 'from-orange-400 via-orange-500 to-amber-500';
case '500': return 'from-red-400 via-red-500 to-pink-500';
case '403': return 'from-yellow-400 via-yellow-500 to-orange-500';
case 'network': return 'from-blue-400 via-blue-500 to-cyan-500';
case 'timeout': return 'from-purple-400 via-purple-500 to-pink-500';
case 'maintenance': return 'from-gray-400 via-gray-500 to-slate-500';
default: return 'from-orange-400 via-orange-500 to-amber-500';
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 via-white to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
<motion.div
className="text-center px-4 max-w-2xl mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
>
{/* 错误代码 */}
{code && (
<motion.div
className="mb-8"
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2, duration: 0.6, type: 'spring', stiffness: 200 }}
>
<h1 className={`text-9xl font-black bg-gradient-to-r ${getCodeColor()} bg-clip-text text-transparent mb-4`}>
{code}
</h1>
<div className={`w-24 h-1 bg-gradient-to-r ${getCodeColor()} mx-auto rounded-full`} />
</motion.div>
)}
{/* 图标 */}
<motion.div
className="mb-8 flex justify-center"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.4, duration: 0.6 }}
>
<div className={`${getIconColor()}`}>
{config.icon || <ExclamationTriangleIcon className="w-16 h-16" />}
</div>
</motion.div>
{/* 错误信息 */}
<motion.div
className="mb-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6, duration: 0.6 }}
>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
{displayTitle}
</h2>
<p className="text-xl text-gray-700 dark:text-gray-300 mb-2">
{displayMessage}
</p>
{displayDescription && (
<p className="text-lg text-gray-600 dark:text-gray-400 leading-relaxed">
{displayDescription}
</p>
)}
</motion.div>
{/* 操作按钮 */}
<motion.div
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8, duration: 0.6 }}
>
{finalActions.primary && (
finalActions.primary.href ? (
<Link
href={finalActions.primary.href}
className="inline-flex items-center px-6 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
>
<HomeIcon className="w-5 h-5 mr-2" />
{finalActions.primary.label}
</Link>
) : (
<button
onClick={finalActions.primary.onClick}
className="inline-flex items-center px-6 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
>
<HomeIcon className="w-5 h-5 mr-2" />
{finalActions.primary.label}
</button>
)
)}
{finalActions.secondary && (
finalActions.secondary.href ? (
<Link
href={finalActions.secondary.href}
className="inline-flex items-center px-6 py-3 border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white font-semibold rounded-xl transition-all duration-200"
>
<ArrowLeftIcon className="w-5 h-5 mr-2" />
{finalActions.secondary.label}
</Link>
) : (
<button
onClick={finalActions.secondary.onClick}
className="inline-flex items-center px-6 py-3 border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white font-semibold rounded-xl transition-all duration-200"
>
<ArrowLeftIcon className="w-5 h-5 mr-2" />
{finalActions.secondary.label}
</button>
)
)}
</motion.div>
{/* 联系信息 */}
{showContact && (
<motion.div
className="mt-8 text-sm text-gray-500 dark:text-gray-400"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1, duration: 0.6 }}
>
<p>
<Link href="/contact" className="text-orange-500 hover:text-orange-600 underline mx-1">
</Link>
</p>
</motion.div>
)}
</motion.div>
{/* 背景装饰 */}
<div className="fixed inset-0 -z-10 overflow-hidden">
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-orange-200/20 dark:bg-orange-900/20 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-amber-200/20 dark:bg-amber-900/20 rounded-full blur-3xl" />
</div>
</div>
);
}
// 预设的错误页面组件
export function NotFoundPage() {
return <ErrorPage type="404" code={404} />;
}
export function ServerErrorPage() {
return <ErrorPage type="500" code={500} />;
}
export function ForbiddenPage() {
return <ErrorPage type="403" code={403} />;
}
export function NetworkErrorPage() {
return <ErrorPage type="network" />;
}
export function TimeoutErrorPage() {
return <ErrorPage type="timeout" />;
}
export function MaintenancePage() {
return <ErrorPage type="maintenance" />;
}

View File

@@ -0,0 +1,19 @@
'use client';
import { usePathname } from 'next/navigation';
export function MainContent({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isAuthPage = pathname === '/auth';
const isHomePage = pathname === '/';
return (
<main className={`
min-h-screen bg-gradient-to-br from-slate-50 via-orange-50 to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900
${isAuthPage || isHomePage ? '' : 'pt-16'}
`}>
{children}
</main>
);
}

387
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,387 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useRouter, usePathname } from 'next/navigation';
import { Bars3Icon, XMarkIcon, UserCircleIcon } from '@heroicons/react/24/outline';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '@/contexts/AuthContext';
export default function Navbar() {
const [isOpen, setIsOpen] = useState(false);
const [isHidden, setIsHidden] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const [showScrollTop, setShowScrollTop] = useState(false);
const [navbarHeight, setNavbarHeight] = useState(0);
const navbarRef = useRef<HTMLElement>(null);
const { user, isAuthenticated, logout } = useAuth();
const router = useRouter();
const pathname = usePathname();
// 在auth页面隐藏navbar
const isAuthPage = pathname === '/auth';
// 检测navbar高度并设置CSS自定义属性
useEffect(() => {
const updateHeight = () => {
if (navbarRef.current) {
const height = navbarRef.current.offsetHeight;
setNavbarHeight(height);
document.documentElement.style.setProperty('--navbar-height', `${height}px`);
}
};
updateHeight();
window.addEventListener('resize', updateHeight);
return () => window.removeEventListener('resize', updateHeight);
}, []);
useEffect(() => {
let lastScrollY = 0;
let ticking = false;
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
const currentScrollY = window.scrollY;
// 检测是否滚动到顶部
setIsScrolled(currentScrollY > 20);
// 显示返回顶部按钮滚动超过300px
setShowScrollTop(currentScrollY > 300);
// 更敏感的隐藏逻辑:只要往下滚动就隐藏,不管滚动多少
if (!isAuthPage && currentScrollY > lastScrollY && currentScrollY > 10) {
setIsHidden(true);
} else if (currentScrollY < lastScrollY) { // 往上滚动就显示
setIsHidden(false);
}
lastScrollY = currentScrollY;
ticking = false;
});
ticking = true;
}
};
if (!isAuthPage) {
window.addEventListener('scroll', handleScroll, { passive: true });
}
return () => window.removeEventListener('scroll', handleScroll);
}, [isAuthPage]);
const handleLogout = () => {
logout();
router.push('/');
setIsOpen(false);
};
const handleLinkClick = () => {
setIsOpen(false);
};
// 在auth页面不渲染navbar
if (isAuthPage) {
return null;
}
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};
return (
<motion.nav
ref={navbarRef}
initial={{ y: 0 }}
animate={{ y: isHidden ? -100 : 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="fixed top-0 left-0 right-0 z-50 transition-all duration-300 bg-white/80 dark:bg-gray-800/80 backdrop-blur-lg border-b border-gray-200/50 dark:border-gray-700/50"
style={{ willChange: 'transform' }}
>
<div className={`
max-w-7xl mx-auto px-4 sm:px-6 lg:px-8
${isScrolled ? 'py-3' : 'py-4'}
transition-all duration-300
`}>
<div className="flex justify-between items-center">
{/* Logo */}
<motion.div
className="flex items-center"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Link href="/" className="flex items-center space-x-3 group">
<motion.div
className="w-10 h-10 bg-gradient-to-br from-orange-400 via-orange-500 to-orange-600 rounded-xl flex items-center justify-center shadow-lg"
whileHover={{ rotate: 5, scale: 1.05 }}
transition={{ type: 'spring', stiffness: 300 }}
>
<span className="text-white font-bold text-lg">C</span>
</motion.div>
<motion.span
className="text-2xl font-black bg-gradient-to-r from-orange-400 to-orange-600 bg-clip-text text-transparent"
whileHover={{ scale: 1.02 }}
>
CarrotSkin
</motion.span>
</Link>
</motion.div>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-6">
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Link
href="/"
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 font-medium relative group px-3 py-2 rounded-lg hover:bg-orange-500/10 dark:hover:bg-orange-400/10"
>
<motion.span
className="absolute -bottom-0.5 left-3 right-3 h-0.5 bg-gradient-to-r from-orange-400 to-orange-600"
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.2 }}
/>
</Link>
</motion.div>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Link
href="/skins"
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 font-medium relative group px-3 py-2 rounded-lg hover:bg-orange-500/10 dark:hover:bg-orange-400/10"
>
<motion.span
className="absolute -bottom-0.5 left-3 right-3 h-0.5 bg-gradient-to-r from-orange-400 to-orange-600"
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.2 }}
/>
</Link>
</motion.div>
{/* 用户头像框 - 类似知乎和哔哩哔哩的设计 */}
{isAuthenticated ? (
<div className="flex items-center space-x-4">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<Link
href="/profile"
className="flex items-center space-x-3 group"
onClick={handleLinkClick}
>
{user?.avatar ? (
<motion.div
className="relative"
whileHover={{ scale: 1.1 }}
>
<img
src={user.avatar}
alt={user.username}
className="w-9 h-9 rounded-full border-2 border-orange-500/30 group-hover:border-orange-500 transition-all duration-200 shadow-md"
/>
<motion.div
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
initial={{ opacity: 0 }}
whileHover={{ opacity: 1 }}
transition={{ duration: 0.2 }}
/>
</motion.div>
) : (
<motion.div
className="relative"
whileHover={{ scale: 1.1 }}
>
<UserCircleIcon className="w-9 h-9 text-gray-400 group-hover:text-orange-500 transition-all duration-200" />
<motion.div
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
initial={{ opacity: 0 }}
whileHover={{ opacity: 1 }}
transition={{ duration: 0.2 }}
/>
</motion.div>
)}
<motion.span
className="text-gray-700 dark:text-gray-300 group-hover:text-orange-500 dark:group-hover:text-orange-400 transition-all duration-200 font-medium"
whileHover={{ scale: 1.02 }}
>
{user?.username}
</motion.span>
</Link>
</motion.div>
<motion.button
onClick={handleLogout}
className="relative overflow-hidden border-2 border-orange-500 text-orange-500 hover:text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 group"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<motion.span
className="absolute inset-0 w-0 bg-gradient-to-r from-orange-400 to-orange-600 transition-all duration-300 group-hover:w-full"
initial={{ width: 0 }}
whileHover={{ width: '100%' }}
/>
<span className="relative z-10">退</span>
</motion.button>
</div>
) : (
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Link
href="/auth"
className="flex items-center space-x-2 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 group"
>
<motion.div
className="relative"
whileHover={{ scale: 1.1 }}
>
<UserCircleIcon className="w-7 h-7 text-gray-400 group-hover:text-orange-500 transition-all duration-200" />
<motion.div
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
initial={{ opacity: 0 }}
whileHover={{ opacity: 1 }}
transition={{ duration: 0.2 }}
/>
</motion.div>
<span className="font-medium"></span>
</Link>
</motion.div>
)}
</div>
{/* Mobile menu button */}
<div className="md:hidden flex items-center">
<motion.button
onClick={() => setIsOpen(!isOpen)}
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-colors duration-200 p-2"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
{isOpen ? <XMarkIcon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />}
</motion.button>
</div>
</div>
</div>
{/* Mobile Navigation */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="md:hidden bg-white/95 dark:bg-gray-900/95 backdrop-blur-md border-t border-gray-200/50 dark:border-gray-700/50"
>
<div className="px-4 py-4 space-y-1">
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.05 }}
>
<Link
href="/"
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
onClick={handleLinkClick}
>
</Link>
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
>
<Link
href="/skins"
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
onClick={handleLinkClick}
>
</Link>
</motion.div>
{isAuthenticated ? (
<>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
>
<Link
href="/profile"
className="block px-4 py-3 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200"
onClick={handleLinkClick}
>
<div className="flex items-center space-x-3">
{user?.avatar ? (
<img
src={user.avatar}
alt={user.username}
className="w-8 h-8 rounded-full border-2 border-orange-500/30"
/>
) : (
<UserCircleIcon className="w-8 h-8 text-gray-400" />
)}
<span className="text-gray-700 dark:text-gray-300 font-medium">{user?.username}</span>
</div>
</Link>
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.25 }}
>
<button
onClick={handleLogout}
className="block w-full text-left px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
>
退
</button>
</motion.div>
</>
) : (
<>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
>
<Link
href="/auth"
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
onClick={handleLinkClick}
>
</Link>
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.25 }}
>
<Link
href="/auth"
className="block px-4 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl font-medium text-center"
onClick={handleLinkClick}
>
</Link>
</motion.div>
</>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.nav>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
export default function ScrollToTop() {
const [showScrollTop, setShowScrollTop] = useState(false);
useEffect(() => {
let ticking = false;
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
const currentScrollY = window.scrollY;
// 显示返回顶部按钮滚动超过300px
setShowScrollTop(currentScrollY > 300);
ticking = false;
});
ticking = true;
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
};
return (
<AnimatePresence>
{showScrollTop && (
<motion.button
initial={{ opacity: 0, scale: 0.8, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: 20 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
onClick={scrollToTop}
className="fixed bottom-6 right-6 w-12 h-12 bg-gradient-to-br from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center z-[100] group"
whileHover={{ scale: 1.1, y: -2 }}
whileTap={{ scale: 0.9 }}
>
<svg
className="w-5 h-5 transition-transform duration-200 group-hover:-translate-y-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
</motion.button>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,218 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { SkinViewer as SkinViewer3D, WalkingAnimation, FunctionAnimation } from 'skinview3d';
interface SkinViewerProps {
skinUrl: string;
capeUrl?: string;
isSlim?: boolean;
width?: number;
height?: number;
className?: string;
autoRotate?: boolean;
walking?: boolean;
}
export default function SkinViewer({
skinUrl,
capeUrl,
isSlim = false,
width = 300,
height = 300,
className = '',
autoRotate = true,
walking = false,
}: SkinViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const viewerRef = useRef<SkinViewer3D | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
// 预加载皮肤图片以检查是否可访问
useEffect(() => {
if (!skinUrl) return;
setIsLoading(true);
setHasError(false);
setImageLoaded(false);
const img = new Image();
img.crossOrigin = 'anonymous'; // 尝试跨域访问
img.onload = () => {
console.log('皮肤图片加载成功:', skinUrl);
setImageLoaded(true);
setIsLoading(false);
};
img.onerror = (error) => {
console.error('皮肤图片加载失败:', skinUrl, error);
setHasError(true);
setIsLoading(false);
};
// 开始加载图片
img.src = skinUrl;
return () => {
// 清理
img.onload = null;
img.onerror = null;
};
}, [skinUrl]);
// 初始化3D查看器
useEffect(() => {
if (!canvasRef.current || !imageLoaded || hasError) return;
try {
console.log('初始化3D皮肤查看器:', { skinUrl, isSlim, width, height });
// 使用canvas的实际尺寸参考blessingskin
const canvas = canvasRef.current;
const viewer = new SkinViewer3D({
canvas: canvas,
width: canvas.clientWidth || width,
height: canvas.clientHeight || height,
skin: skinUrl,
cape: capeUrl,
model: isSlim ? 'slim' : 'default',
zoom: 1.0, // 使用blessingskin的zoom方式
});
viewerRef.current = viewer;
// 设置背景和控制选项 - 参考blessingskin
viewer.background = null; // 透明背景
viewer.autoRotate = false; // 禁用自动旋转
// 禁用所有交互控制
viewer.controls.enableRotate = false; // 禁用旋转控制
viewer.controls.enableZoom = false; // 禁用缩放
viewer.controls.enablePan = false; // 禁用平移
console.log('3D皮肤查看器初始化成功');
} catch (error) {
console.error('3D皮肤查看器初始化失败:', error);
setHasError(true);
}
// 清理函数
return () => {
if (viewerRef.current) {
try {
viewerRef.current.dispose();
viewerRef.current = null;
console.log('3D皮肤查看器已清理');
} catch (error) {
console.error('清理3D皮肤查看器失败:', error);
}
}
};
}, [skinUrl, capeUrl, isSlim, width, height, autoRotate, walking, imageLoaded, hasError]);
// 当皮肤URL改变时更新
useEffect(() => {
if (viewerRef.current && skinUrl && imageLoaded) {
try {
console.log('更新皮肤URL:', skinUrl);
viewerRef.current.loadSkin(skinUrl);
} catch (error) {
console.error('更新皮肤失败:', error);
}
}
}, [skinUrl, imageLoaded]);
// 监听容器尺寸变化并调整viewer大小 - 参考blessingskin
useEffect(() => {
if (viewerRef.current && canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
viewerRef.current.setSize(rect.width, rect.height);
}
}, [imageLoaded]); // 当图片加载完成后调整尺寸
// 当披风URL改变时更新
useEffect(() => {
if (viewerRef.current && capeUrl && imageLoaded) {
try {
console.log('更新披风URL:', capeUrl);
viewerRef.current.loadCape(capeUrl);
} catch (error) {
console.error('更新披风失败:', error);
}
} else if (viewerRef.current && !capeUrl && imageLoaded) {
try {
viewerRef.current.loadCape(null);
} catch (error) {
console.error('移除披风失败:', error);
}
}
}, [capeUrl, imageLoaded]);
// 当模型类型改变时更新
useEffect(() => {
if (viewerRef.current && skinUrl && imageLoaded) {
try {
console.log('更新模型类型:', isSlim ? 'slim' : 'default');
viewerRef.current.loadSkin(skinUrl, { model: isSlim ? 'slim' : 'default' });
} catch (error) {
console.error('更新模型失败:', error);
}
}
}, [isSlim, skinUrl, imageLoaded]);
// 错误状态显示
if (hasError) {
return (
<div
className={`${className} flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 dark:from-red-900/20 dark:to-orange-900/20 rounded-xl border-2 border-dashed border-red-300 dark:border-red-700`}
style={{ width: width, height: height }}
>
<div className="text-center p-4">
<div className="text-4xl mb-2"></div>
<div className="text-sm text-red-600 dark:text-red-400 font-medium mb-1">
</div>
<div className="text-xs text-red-500 dark:text-red-500">
访
</div>
</div>
</div>
);
}
// 加载状态显示
if (isLoading) {
return (
<div
className={`${className} flex items-center justify-center bg-gradient-to-br from-orange-50 to-amber-50 dark:from-gray-700 dark:to-gray-600 rounded-xl`}
style={{ width: width, height: height }}
>
<div className="text-center">
<div className="animate-spin w-8 h-8 border-3 border-orange-500 border-t-transparent rounded-full mx-auto mb-3"></div>
<div className="text-sm text-orange-600 dark:text-orange-400 font-medium">
...
</div>
</div>
</div>
);
}
return (
<canvas
ref={canvasRef}
className={className}
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%'
}}
/>
);
}

View File

@@ -0,0 +1,223 @@
'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface User {
id: number;
username: string;
email: string;
avatar?: string;
points: number;
role: string;
status: number;
last_login_at?: string;
created_at: string;
updated_at: string;
totalSkins?: number;
totalDownloads?: number;
}
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (username: string, password: string) => Promise<void>;
register: (username: string, email: string, password: string, verificationCode: string) => Promise<void>;
logout: () => void;
updateUser: (userData: Partial<User>) => void;
refreshUser: () => Promise<void>;
}
const API_BASE_URL = 'http://localhost:8080/api/v1';
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Check if user is logged in on mount
useEffect(() => {
const checkAuthStatus = async () => {
const token = localStorage.getItem('authToken');
if (token) {
await refreshUser();
} else {
setIsLoading(false);
}
};
checkAuthStatus();
}, []);
const setAuthToken = (token: string) => {
localStorage.setItem('authToken', token);
};
const getAuthHeaders = () => {
const token = localStorage.getItem('authToken');
return {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
};
};
const login = async (username: string, password: string) => {
setIsLoading(true);
try {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password,
}),
});
const result = await response.json();
if (result.code === 200) {
const { token, user_info } = result.data;
setAuthToken(token);
const userData: User = {
id: user_info.id,
username: user_info.username,
email: user_info.email,
avatar: user_info.avatar,
points: user_info.points,
role: user_info.role,
status: user_info.status,
last_login_at: user_info.last_login_at,
created_at: user_info.created_at,
updated_at: user_info.updated_at,
};
setUser(userData);
} else {
throw new Error(result.message || '登录失败,请检查用户名和密码');
}
} catch (error) {
throw error instanceof Error ? error : new Error('登录失败,请检查用户名和密码');
} finally {
setIsLoading(false);
}
};
const register = async (username: string, email: string, password: string, verificationCode: string) => {
setIsLoading(true);
try {
const response = await fetch(`${API_BASE_URL}/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
email,
password,
verification_code: verificationCode,
}),
});
const result = await response.json();
if (result.code === 200) {
const { token, user_info } = result.data;
setAuthToken(token);
const userData: User = {
id: user_info.id,
username: user_info.username,
email: user_info.email,
avatar: user_info.avatar,
points: user_info.points,
role: user_info.role,
status: user_info.status,
created_at: user_info.created_at,
updated_at: user_info.updated_at,
};
setUser(userData);
} else {
throw new Error(result.message || '注册失败,请稍后重试');
}
} catch (error) {
throw error instanceof Error ? error : new Error('注册失败,请稍后重试');
} finally {
setIsLoading(false);
}
};
const refreshUser = async () => {
try {
const response = await fetch(`${API_BASE_URL}/user/profile`, {
method: 'GET',
headers: getAuthHeaders(),
});
const result = await response.json();
if (result.code === 200) {
const user_info = result.data;
const userData: User = {
id: user_info.id,
username: user_info.username,
email: user_info.email,
avatar: user_info.avatar,
points: user_info.points,
role: user_info.role,
status: user_info.status,
last_login_at: user_info.last_login_at,
created_at: user_info.created_at,
updated_at: user_info.updated_at,
};
setUser(userData);
} else {
// Token invalid or expired
logout();
}
} catch (error) {
console.error('Failed to refresh user:', error);
logout();
} finally {
setIsLoading(false);
}
};
const logout = () => {
setUser(null);
localStorage.removeItem('authToken');
};
const updateUser = (userData: Partial<User>) => {
if (user) {
setUser({ ...user, ...userData });
}
};
const value: AuthContextType = {
user,
isLoading,
isAuthenticated: !!user,
login,
register,
logout,
updateUser,
refreshUser,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

303
src/lib/api.ts Normal file
View File

@@ -0,0 +1,303 @@
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080/api/v1';
export interface Texture {
id: number;
uploader_id: number;
name: string;
description?: string;
type: 'SKIN' | 'CAPE';
url: string;
hash: string;
size: number;
is_public: boolean;
download_count: number;
favorite_count: number;
is_slim: boolean;
status: number;
created_at: string;
updated_at: string;
}
export interface Profile {
uuid: string;
user_id: number;
name: string;
skin_id?: number;
cape_id?: number;
is_active: boolean;
last_used_at?: string;
created_at: string;
updated_at: string;
}
export interface PaginatedResponse<T> {
list: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
// 获取认证头
function getAuthHeaders(): HeadersInit {
const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null;
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// 搜索材质
export async function searchTextures(params: {
keyword?: string;
type?: 'SKIN' | 'CAPE';
public_only?: boolean;
page?: number;
page_size?: number;
}): Promise<ApiResponse<PaginatedResponse<Texture>>> {
const queryParams = new URLSearchParams();
if (params.keyword) queryParams.append('keyword', params.keyword);
if (params.type) queryParams.append('type', params.type);
if (params.public_only !== undefined) queryParams.append('public_only', String(params.public_only));
if (params.page) queryParams.append('page', String(params.page));
if (params.page_size) queryParams.append('page_size', String(params.page_size));
const response = await fetch(`${API_BASE_URL}/texture?${queryParams.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return response.json();
}
// 获取材质详情
export async function getTexture(id: number): Promise<ApiResponse<Texture>> {
const response = await fetch(`${API_BASE_URL}/texture/${id}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return response.json();
}
// 切换收藏状态
export async function toggleFavorite(id: number): Promise<ApiResponse<{ is_favorited: boolean }>> {
const response = await fetch(`${API_BASE_URL}/texture/${id}/favorite`, {
method: 'POST',
headers: getAuthHeaders(),
});
return response.json();
}
// 获取用户上传的材质列表
export async function getMyTextures(params: {
page?: number;
page_size?: number;
}): Promise<ApiResponse<PaginatedResponse<Texture>>> {
const queryParams = new URLSearchParams();
if (params.page) queryParams.append('page', String(params.page));
if (params.page_size) queryParams.append('page_size', String(params.page_size));
const response = await fetch(`${API_BASE_URL}/texture/my?${queryParams.toString()}`, {
method: 'GET',
headers: getAuthHeaders(),
});
return response.json();
}
// 获取用户收藏的材质列表
export async function getFavoriteTextures(params: {
page?: number;
page_size?: number;
}): Promise<ApiResponse<PaginatedResponse<Texture>>> {
const queryParams = new URLSearchParams();
if (params.page) queryParams.append('page', String(params.page));
if (params.page_size) queryParams.append('page_size', String(params.page_size));
const response = await fetch(`${API_BASE_URL}/texture/favorites?${queryParams.toString()}`, {
method: 'GET',
headers: getAuthHeaders(),
});
return response.json();
}
// 获取用户档案列表
export async function getProfiles(): Promise<ApiResponse<Profile[]>> {
const response = await fetch(`${API_BASE_URL}/profile`, {
method: 'GET',
headers: getAuthHeaders(),
});
return response.json();
}
// 创建档案
export async function createProfile(name: string): Promise<ApiResponse<Profile>> {
const response = await fetch(`${API_BASE_URL}/profile`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ name }),
});
return response.json();
}
// 更新档案
export async function updateProfile(uuid: string, data: {
name?: string;
skin_id?: number;
cape_id?: number;
}): Promise<ApiResponse<Profile>> {
const response = await fetch(`${API_BASE_URL}/profile/${uuid}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
return response.json();
}
// 删除档案
export async function deleteProfile(uuid: string): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE_URL}/profile/${uuid}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
return response.json();
}
// 设置活跃档案
export async function setActiveProfile(uuid: string): Promise<ApiResponse<{ message: string }>> {
const response = await fetch(`${API_BASE_URL}/profile/${uuid}/activate`, {
method: 'POST',
headers: getAuthHeaders(),
});
return response.json();
}
// 获取用户信息
export async function getUserProfile(): Promise<ApiResponse<{
id: number;
username: string;
email: string;
avatar?: string;
points: number;
role: string;
status: number;
last_login_at?: string;
created_at: string;
updated_at: string;
}>> {
const response = await fetch(`${API_BASE_URL}/user/profile`, {
method: 'GET',
headers: getAuthHeaders(),
});
return response.json();
}
// 更新用户信息
export async function updateUserProfile(data: {
avatar?: string;
old_password?: string;
new_password?: string;
}): Promise<ApiResponse<{
id: number;
username: string;
email: string;
avatar?: string;
points: number;
role: string;
status: number;
last_login_at?: string;
created_at: string;
updated_at: string;
}>> {
const response = await fetch(`${API_BASE_URL}/user/profile`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
return response.json();
}
// 直接上传皮肤文件
export async function uploadTexture(file: File, data: {
name: string;
description?: string;
type?: 'SKIN' | 'CAPE';
is_public?: boolean;
is_slim?: boolean;
}): Promise<ApiResponse<Texture>> {
const formData = new FormData();
formData.append('file', file);
formData.append('name', data.name);
if (data.description) formData.append('description', data.description);
if (data.type) formData.append('type', data.type);
if (data.is_public !== undefined) formData.append('is_public', String(data.is_public));
if (data.is_slim !== undefined) formData.append('is_slim', String(data.is_slim));
const response = await fetch(`${API_BASE_URL}/texture/upload`, {
method: 'POST',
headers: {
...(typeof window !== 'undefined' ? { Authorization: `Bearer ${localStorage.getItem('authToken')}` } : {}),
},
body: formData,
});
return response.json();
}
// 生成头像上传URL
export async function generateAvatarUploadUrl(fileName: string): Promise<ApiResponse<{
post_url: string;
form_data: Record<string, string>;
avatar_url: string;
expires_in: number;
}>> {
const response = await fetch(`${API_BASE_URL}/user/avatar/upload-url`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ file_name: fileName }),
});
return response.json();
}
// 更新头像URL
export async function updateAvatarUrl(avatarUrl: string): Promise<ApiResponse<{
id: number;
username: string;
email: string;
avatar: string;
points: number;
role: string;
status: number;
last_login_at?: string;
created_at: string;
updated_at: string;
}>> {
const response = await fetch(`${API_BASE_URL}/user/avatar?avatar_url=${encodeURIComponent(avatarUrl)}`, {
method: 'PUT',
headers: getAuthHeaders(),
});
return response.json();
}

51
tailwind.config.ts Normal file
View File

@@ -0,0 +1,51 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
orange: {
50: '#fff7ed',
100: '#ffedd5',
200: '#fed7aa',
300: '#fdba74',
400: '#fb923c',
500: '#f97316',
600: '#ea580c',
700: '#c2410c',
800: '#9a3412',
900: '#7c2d12',
950: '#431407',
},
minecraft: {
grass: '#7cbd6a',
dirt: '#8b4513',
stone: '#808080',
wood: '#8b6914',
diamond: '#4fc3f7',
}
},
fontFamily: {
'minecraft': ['Minecraft', 'monospace'],
'sans': ['Inter', 'system-ui', 'sans-serif'],
},
animation: {
'float': 'float 3s ease-in-out infinite',
'pulse-slow': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
float: {
'0%, 100%': { transform: 'translateY(0px)' },
'50%': { transform: 'translateY(-10px)' },
}
}
},
},
plugins: [],
};
export default config;