feat: Banner UI美化 & 新增文章/公告/图库/媒体管理等功能
- Banner: Ken Burns缩放动效、左右导航箭头、进度条指示器、hover暂停、暗角遮罩、shimmer按钮动画 - 新增文章管理(CRUD)与公开文章页 - 新增Banner/Gallery图片管理API - 新增媒体管理页面 - 新增更新日志页面 - 新增页面访问追踪 - 新增Markdown渲染组件 - .gitignore排除.cursor目录 Made-with: Cursor
3
.gitignore
vendored
@@ -48,3 +48,6 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
|
||||||
|
# cursor ide
|
||||||
|
.cursor/
|
||||||
|
|||||||
641
API.md
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
# Nanami Web API 文档
|
||||||
|
|
||||||
|
Base URL: `https://nui.rucky.cn`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [公开接口](#公开接口)
|
||||||
|
- [服务器时间](#服务器时间)
|
||||||
|
- [检查更新](#检查更新)
|
||||||
|
- [启动器最新版本](#启动器最新版本)
|
||||||
|
- [软件版本下载](#软件版本下载)
|
||||||
|
- [插件列表](#插件列表)
|
||||||
|
- [插件详情](#插件详情)
|
||||||
|
- [插件版本下载](#插件版本下载)
|
||||||
|
- [Banner 列表](#banner-列表)
|
||||||
|
- [画廊图片列表](#画廊图片列表)
|
||||||
|
- [管理接口(需认证)](#管理接口需认证)
|
||||||
|
- [软件管理](#软件管理)
|
||||||
|
- [软件版本管理](#软件版本管理)
|
||||||
|
- [插件管理](#插件管理)
|
||||||
|
- [插件版本发布](#插件版本发布)
|
||||||
|
- [Banner 管理](#banner-管理)
|
||||||
|
- [画廊管理](#画廊管理)
|
||||||
|
- [文件上传](#文件上传)
|
||||||
|
- [修改密码](#修改密码)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 公开接口
|
||||||
|
|
||||||
|
### 服务器时间
|
||||||
|
|
||||||
|
获取服务器当前时间。
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/server-time
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": 1710748800000,
|
||||||
|
"iso": "2026-03-18T08:00:00.000Z",
|
||||||
|
"timezone": "Asia/Shanghai"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `timestamp` | `number` | Unix 毫秒时间戳 |
|
||||||
|
| `iso` | `string` | ISO 8601 格式时间 |
|
||||||
|
| `timezone` | `string` | 服务器时区 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 检查更新
|
||||||
|
|
||||||
|
客户端检查软件是否有新版本可用。
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/software/check-update?slug={slug}&versionCode={versionCode}
|
||||||
|
```
|
||||||
|
|
||||||
|
**查询参数:**
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `slug` | 是 | 软件标识符,如 `nanami-launcher` |
|
||||||
|
| `versionCode` | 否 | 当前客户端版本号(整数),默认 `0` |
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hasUpdate": true,
|
||||||
|
"forceUpdate": false,
|
||||||
|
"latest": {
|
||||||
|
"version": "0.8.15",
|
||||||
|
"versionCode": 815,
|
||||||
|
"changelog": "修复了若干问题...",
|
||||||
|
"downloadUrl": "https://cdn.example.com/launcher-setup.exe",
|
||||||
|
"fileSize": 52428800,
|
||||||
|
"minVersion": "0.7.0",
|
||||||
|
"createdAt": "2026-03-15T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `hasUpdate` | `boolean` | 是否有新版本 |
|
||||||
|
| `forceUpdate` | `boolean` | 是否需要强制更新 |
|
||||||
|
| `latest.version` | `string` | 最新版本号 |
|
||||||
|
| `latest.versionCode` | `number` | 最新版本编码 |
|
||||||
|
| `latest.changelog` | `string` | 更新日志 |
|
||||||
|
| `latest.downloadUrl` | `string` | 下载地址 |
|
||||||
|
| `latest.fileSize` | `number` | 文件大小(字节) |
|
||||||
|
| `latest.minVersion` | `string\|null` | 最低兼容版本 |
|
||||||
|
| `latest.createdAt` | `string` | 发布时间 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 启动器最新版本
|
||||||
|
|
||||||
|
获取启动器最新版本信息或直接下载。
|
||||||
|
|
||||||
|
#### 获取信息
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/software/latest?info=1
|
||||||
|
```
|
||||||
|
|
||||||
|
添加 `&track=1` 可同时记录一次下载计数。
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"available": true,
|
||||||
|
"version": "0.8.15",
|
||||||
|
"versionCode": 815,
|
||||||
|
"changelog": "更新内容...",
|
||||||
|
"fileSize": 52428800,
|
||||||
|
"createdAt": "2026-03-15T10:00:00.000Z",
|
||||||
|
"downloadUrl": "https://cdn.example.com/launcher-setup.exe",
|
||||||
|
"downloadType": "url"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当无可用版本时返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "available": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 直接下载
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/software/latest
|
||||||
|
```
|
||||||
|
|
||||||
|
- 外部链接类型:302 重定向到外部 URL
|
||||||
|
- 本地文件类型:返回文件流
|
||||||
|
- 自动记录下载次数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 软件版本下载
|
||||||
|
|
||||||
|
按版本 ID 下载特定版本。
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/software/download/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**路径参数:**
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `id` | 软件版本 ID |
|
||||||
|
|
||||||
|
- 外部链接类型:302 重定向
|
||||||
|
- 本地文件类型:返回文件流(`application/octet-stream`)
|
||||||
|
- 自动记录下载次数
|
||||||
|
|
||||||
|
**错误响应:**
|
||||||
|
|
||||||
|
| 状态码 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `404` | 版本不存在或文件不存在 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 插件列表
|
||||||
|
|
||||||
|
获取已发布的插件列表。
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/addons
|
||||||
|
```
|
||||||
|
|
||||||
|
**查询参数:**
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `category` | 否 | 按分类筛选 |
|
||||||
|
| `search` | 否 | 按名称/简介搜索(模糊匹配) |
|
||||||
|
| `published` | 否 | 默认 `true`,设为 `false` 查询所有 |
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "clxx...",
|
||||||
|
"name": "插件名",
|
||||||
|
"slug": "addon-slug",
|
||||||
|
"summary": "插件简介",
|
||||||
|
"description": "详细描述...",
|
||||||
|
"iconUrl": "/uploads/icon.png",
|
||||||
|
"category": "ui",
|
||||||
|
"published": true,
|
||||||
|
"totalDownloads": 1024,
|
||||||
|
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-03-01T00:00:00.000Z",
|
||||||
|
"releases": [{ "...最新版本..." }],
|
||||||
|
"_count": { "releases": 5 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 插件详情
|
||||||
|
|
||||||
|
获取单个插件详细信息,包含所有版本和截图。
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/addons/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**路径参数:** `id` 可以是插件 ID 或 slug。
|
||||||
|
|
||||||
|
**响应:** 插件完整信息,含 `releases`(按时间倒序)和 `screenshots`(按排序序号)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 插件版本下载
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/download/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**路径参数:**
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `id` | Release 版本 ID |
|
||||||
|
|
||||||
|
- 自动增加 Release 和 Addon 的下载计数
|
||||||
|
- 外部链接类型:302 重定向
|
||||||
|
- 本地文件类型:返回文件流
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Banner 列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/banners
|
||||||
|
```
|
||||||
|
|
||||||
|
**查询参数:**
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `enabled` | 设为 `1` 仅返回已启用的 Banner |
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "clxx...",
|
||||||
|
"imageUrl": "/banners/banner_1.png",
|
||||||
|
"sortOrder": 0,
|
||||||
|
"enabled": true,
|
||||||
|
"createdAt": "2026-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 画廊图片列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/gallery
|
||||||
|
```
|
||||||
|
|
||||||
|
**查询参数:**
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `enabled` | 设为 `1` 仅返回已启用的图片 |
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "clxx...",
|
||||||
|
"imageUrl": "/views/view_1.png",
|
||||||
|
"title": "主界面",
|
||||||
|
"sortOrder": 0,
|
||||||
|
"enabled": true,
|
||||||
|
"createdAt": "2026-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 管理接口(需认证)
|
||||||
|
|
||||||
|
以下接口需要管理员登录后的 Session 认证。未认证请求返回 `401 Unauthorized`。
|
||||||
|
|
||||||
|
### 软件管理
|
||||||
|
|
||||||
|
#### 获取软件列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/software
|
||||||
|
```
|
||||||
|
|
||||||
|
返回所有软件及其最新版本和版本数量。
|
||||||
|
|
||||||
|
#### 创建软件
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/software
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Nanami Launcher",
|
||||||
|
"slug": "nanami-launcher",
|
||||||
|
"description": "启动器描述"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `name` | 是 | 软件名称 |
|
||||||
|
| `slug` | 是 | 唯一标识符 |
|
||||||
|
| `description` | 否 | 描述 |
|
||||||
|
|
||||||
|
#### 获取软件详情
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/software/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
`id` 可以是软件 ID 或 slug,返回软件信息及所有版本。
|
||||||
|
|
||||||
|
#### 更新软件
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/software/{id}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "新名称",
|
||||||
|
"slug": "new-slug",
|
||||||
|
"description": "新描述"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 删除软件
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/software/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 软件版本管理
|
||||||
|
|
||||||
|
#### 创建新版本
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/software/{id}/versions
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"versionCode": 100,
|
||||||
|
"changelog": "首次发布",
|
||||||
|
"downloadType": "url",
|
||||||
|
"externalUrl": "https://cdn.example.com/file.exe",
|
||||||
|
"fileSize": 52428800,
|
||||||
|
"forceUpdate": false,
|
||||||
|
"minVersion": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 类型 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `version` | 是 | `string` | 版本号 |
|
||||||
|
| `versionCode` | 是 | `number` | 版本编码(用于比较) |
|
||||||
|
| `changelog` | 否 | `string` | 更新日志 |
|
||||||
|
| `downloadType` | 否 | `string` | `"local"` 或 `"url"`,默认 `"local"` |
|
||||||
|
| `filePath` | 否 | `string` | 本地文件路径 |
|
||||||
|
| `externalUrl` | 否 | `string` | 外部下载链接 |
|
||||||
|
| `fileSize` | 否 | `number` | 文件大小(字节) |
|
||||||
|
| `forceUpdate` | 否 | `boolean` | 是否强制更新 |
|
||||||
|
| `minVersion` | 否 | `string` | 最低兼容版本 |
|
||||||
|
|
||||||
|
新版本自动设为 `isLatest: true`,之前的最新版本会被取消。
|
||||||
|
|
||||||
|
#### 更新版本
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/software/versions/{versionId}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"version": "1.0.1",
|
||||||
|
"changelog": "修复问题",
|
||||||
|
"isLatest": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
所有字段均为可选,仅更新传入的字段。设置 `isLatest: true` 时自动取消其他版本的最新标记。
|
||||||
|
|
||||||
|
#### 删除版本
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/software/versions/{versionId}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 插件管理
|
||||||
|
|
||||||
|
#### 创建插件
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/addons
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "插件名",
|
||||||
|
"slug": "addon-slug",
|
||||||
|
"summary": "简介",
|
||||||
|
"description": "详细描述",
|
||||||
|
"iconUrl": "/uploads/icon.png",
|
||||||
|
"category": "ui"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `name` | 是 | 插件名称 |
|
||||||
|
| `slug` | 是 | 唯一标识符 |
|
||||||
|
| `summary` | 是 | 简短描述 |
|
||||||
|
| `description` | 否 | 详细描述 |
|
||||||
|
| `iconUrl` | 否 | 图标 URL |
|
||||||
|
| `category` | 否 | 分类,默认 `"general"` |
|
||||||
|
|
||||||
|
#### 更新插件
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/addons/{id}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "新名称",
|
||||||
|
"published": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 删除插件
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/addons/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 插件版本发布
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/releases
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"addonId": "插件ID",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"changelog": "首次发布",
|
||||||
|
"downloadType": "url",
|
||||||
|
"externalUrl": "https://cdn.example.com/addon.zip",
|
||||||
|
"gameVersion": "11.1.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `addonId` | 是 | 所属插件 ID |
|
||||||
|
| `version` | 是 | 版本号 |
|
||||||
|
| `changelog` | 否 | 更新日志 |
|
||||||
|
| `downloadType` | 否 | `"local"` 或 `"url"` |
|
||||||
|
| `filePath` | 否 | 本地文件路径 |
|
||||||
|
| `externalUrl` | 否 | 外部链接(`downloadType` 为 `"url"` 时必填) |
|
||||||
|
| `gameVersion` | 否 | 适配的游戏版本 |
|
||||||
|
|
||||||
|
#### 获取版本列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/releases?addonId={addonId}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Banner 管理
|
||||||
|
|
||||||
|
#### 创建 Banner
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/banners
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"imageUrl": "/uploads/banner.png",
|
||||||
|
"sortOrder": 0,
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 更新 Banner
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/banners/{id}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"sortOrder": 1,
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 删除 Banner
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/banners/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 画廊管理
|
||||||
|
|
||||||
|
#### 创建画廊图片
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/gallery
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"imageUrl": "/uploads/screenshot.png",
|
||||||
|
"title": "主界面截图",
|
||||||
|
"sortOrder": 0,
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 更新画廊图片
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/gallery/{id}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"title": "新标题",
|
||||||
|
"sortOrder": 2,
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 删除画廊图片
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/gallery/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 文件上传
|
||||||
|
|
||||||
|
上传文件到服务器。
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/upload
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
|
file: (二进制文件)
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filePath": "/uploads/1710748800000-image.png",
|
||||||
|
"originalName": "image.png",
|
||||||
|
"size": 204800
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
返回的 `filePath` 可用于其他接口的 `imageUrl`、`filePath` 等字段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 修改密码
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/change-password
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"currentPassword": "当前密码",
|
||||||
|
"newPassword": "新密码(≥6位)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误响应:**
|
||||||
|
|
||||||
|
| 状态码 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `400` | 参数缺失或新密码过短 |
|
||||||
|
| `403` | 当前密码错误 |
|
||||||
|
| `404` | 用户不存在 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 通用错误格式
|
||||||
|
|
||||||
|
所有 API 错误均以 JSON 格式返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "错误描述信息"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 状态码 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `400` | 请求参数错误 |
|
||||||
|
| `401` | 未认证 |
|
||||||
|
| `404` | 资源不存在 |
|
||||||
|
| `409` | 资源冲突(如 slug 重复) |
|
||||||
|
| `500` | 服务器内部错误 |
|
||||||
2282
package-lock.json
generated
@@ -20,6 +20,7 @@
|
|||||||
"@base-ui/react": "^1.3.0",
|
"@base-ui/react": "^1.3.0",
|
||||||
"@prisma/adapter-pg": "^7.5.0",
|
"@prisma/adapter-pg": "^7.5.0",
|
||||||
"@prisma/client": "^7.5.0",
|
"@prisma/client": "^7.5.0",
|
||||||
|
"@uiw/react-md-editor": "^4.0.11",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -30,6 +31,9 @@
|
|||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"rehype-highlight": "^7.0.2",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"shadcn": "^4.0.8",
|
"shadcn": "^4.0.8",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
|||||||
@@ -87,3 +87,45 @@ model SoftwareVersion {
|
|||||||
@@unique([softwareId, version])
|
@@unique([softwareId, version])
|
||||||
@@index([softwareId])
|
@@index([softwareId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model BannerImage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
imageUrl String
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model GalleryImage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
imageUrl String
|
||||||
|
title String @default("")
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Article {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
slug String @unique
|
||||||
|
summary String @default("")
|
||||||
|
content String @db.Text
|
||||||
|
coverImage String?
|
||||||
|
published Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model PageView {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
path String
|
||||||
|
referrer String @default("")
|
||||||
|
userAgent String @default("")
|
||||||
|
ip String @default("")
|
||||||
|
date String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([date])
|
||||||
|
@@index([path])
|
||||||
|
}
|
||||||
|
|||||||
BIN
public/views/view_1.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/views/view_10.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
public/views/view_11.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
public/views/view_12.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/views/view_13.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/views/view_14.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/views/view_2.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/views/view_3.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/views/view_4.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/views/view_5.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
public/views/view_6.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
public/views/view_7.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
public/views/view_8.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
public/views/view_9.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
67
src/app/(public)/articles/[slug]/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { Calendar, ArrowLeft } from "lucide-react";
|
||||||
|
import { MarkdownContent } from "@/components/public/MarkdownContent";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ArticleDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const article = await prisma.article.findUnique({
|
||||||
|
where: { slug, published: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!article) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="mx-auto max-w-3xl px-3 py-10 sm:px-4 sm:py-16">
|
||||||
|
<Link
|
||||||
|
href="/articles"
|
||||||
|
className="mb-6 inline-flex items-center gap-1.5 text-sm text-gray-400 transition-colors hover:text-amber-200"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
返回文章列表
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mb-3 text-2xl font-bold text-amber-100 sm:text-3xl">
|
||||||
|
{article.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-center gap-1.5 text-sm text-gray-500">
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
{new Date(article.createdAt).toLocaleDateString("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{article.coverImage && (
|
||||||
|
<div className="mb-8 overflow-hidden rounded-lg">
|
||||||
|
<img
|
||||||
|
src={article.coverImage}
|
||||||
|
alt={article.title}
|
||||||
|
className="w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MarkdownContent content={article.content} />
|
||||||
|
|
||||||
|
<div className="mt-12 border-t border-amber-500/10 pt-6">
|
||||||
|
<Link
|
||||||
|
href="/articles"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-gray-400 transition-colors hover:text-amber-200"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
返回文章列表
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/app/(public)/articles/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { Calendar } from "lucide-react";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ArticlesPage() {
|
||||||
|
const articles = await prisma.article.findMany({
|
||||||
|
where: { published: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-4xl px-3 py-10 sm:px-4 sm:py-16">
|
||||||
|
<h1 className="mb-2 text-2xl font-bold text-amber-100 sm:text-3xl">
|
||||||
|
公告与文章
|
||||||
|
</h1>
|
||||||
|
<p className="mb-8 text-sm text-gray-400 sm:mb-12 sm:text-base">
|
||||||
|
最新动态与更新公告
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{articles.length === 0 ? (
|
||||||
|
<p className="py-16 text-center text-gray-500">暂无文章</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
|
{articles.map((article) => (
|
||||||
|
<Link
|
||||||
|
key={article.id}
|
||||||
|
href={`/articles/${article.slug}`}
|
||||||
|
className="group overflow-hidden rounded-lg border border-amber-500/10 bg-white/[0.03] transition-colors hover:border-amber-500/25"
|
||||||
|
>
|
||||||
|
{article.coverImage && (
|
||||||
|
<div className="aspect-[16/9] overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={article.coverImage}
|
||||||
|
alt={article.title}
|
||||||
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-4 sm:p-5">
|
||||||
|
<h2 className="mb-2 text-lg font-semibold text-amber-100 group-hover:text-amber-200 sm:text-xl">
|
||||||
|
{article.title}
|
||||||
|
</h2>
|
||||||
|
{article.summary && (
|
||||||
|
<p className="mb-3 line-clamp-2 text-sm text-gray-400">
|
||||||
|
{article.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{new Date(article.createdAt).toLocaleDateString("zh-CN")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/app/(public)/changelog/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { Download, Calendar, Tag } from "lucide-react";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ChangelogPage() {
|
||||||
|
const software = await prisma.software.findUnique({
|
||||||
|
where: { slug: "nanami-launcher" },
|
||||||
|
include: {
|
||||||
|
versions: {
|
||||||
|
orderBy: { versionCode: "desc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const versions = software?.versions ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-3xl px-3 py-10 sm:px-4 sm:py-16">
|
||||||
|
<h1 className="mb-2 text-2xl font-bold text-amber-100 sm:text-3xl">
|
||||||
|
版本历史
|
||||||
|
</h1>
|
||||||
|
<p className="mb-8 text-sm text-gray-400 sm:mb-12 sm:text-base">
|
||||||
|
Nanami 启动器更新日志
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{versions.length === 0 ? (
|
||||||
|
<p className="py-16 text-center text-gray-500">暂无版本记录</p>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Timeline line */}
|
||||||
|
<div className="absolute left-[15px] top-2 bottom-0 w-px bg-amber-500/20 sm:left-[19px]" />
|
||||||
|
|
||||||
|
<div className="space-y-8 sm:space-y-10">
|
||||||
|
{versions.map((v, idx) => (
|
||||||
|
<div key={v.id} className="relative pl-10 sm:pl-12">
|
||||||
|
{/* Timeline dot */}
|
||||||
|
<div
|
||||||
|
className={`absolute left-[10px] top-1.5 h-3 w-3 rounded-full border-2 sm:left-[13px] sm:h-3.5 sm:w-3.5 ${
|
||||||
|
idx === 0
|
||||||
|
? "border-amber-400 bg-amber-400 shadow-[0_0_8px_rgba(251,191,36,0.5)]"
|
||||||
|
: "border-amber-500/40 bg-[#0d0b15]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-amber-500/10 bg-white/[0.03] p-4 sm:p-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
|
<h2 className="text-lg font-semibold text-amber-100 sm:text-xl">
|
||||||
|
v{v.version}
|
||||||
|
</h2>
|
||||||
|
{v.isLatest && (
|
||||||
|
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-300 sm:text-xs">
|
||||||
|
最新版本
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{v.forceUpdate && (
|
||||||
|
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-medium text-red-400 sm:text-xs">
|
||||||
|
强制更新
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-gray-500 sm:gap-4 sm:text-sm">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||||
|
{new Date(v.createdAt).toLocaleDateString("zh-CN")}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Tag className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||||
|
Build {v.versionCode}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Download className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||||
|
{v.downloadCount.toLocaleString()} 次下载
|
||||||
|
</span>
|
||||||
|
{v.fileSize > 0 && (
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{(v.fileSize / 1024 / 1024).toFixed(1)} MB
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Changelog */}
|
||||||
|
<div className="whitespace-pre-wrap text-sm leading-relaxed text-gray-300 sm:text-[15px]">
|
||||||
|
{v.changelog || "无更新说明"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Navbar } from "@/components/public/Navbar";
|
import { Navbar } from "@/components/public/Navbar";
|
||||||
import { Footer } from "@/components/public/Footer";
|
import { Footer } from "@/components/public/Footer";
|
||||||
|
import { PageTracker } from "@/components/public/PageTracker";
|
||||||
|
|
||||||
export default function PublicLayout({
|
export default function PublicLayout({
|
||||||
children,
|
children,
|
||||||
@@ -11,6 +12,7 @@ export default function PublicLayout({
|
|||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<PageTracker />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { prisma } from "@/lib/db";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AddonCard } from "@/components/public/AddonCard";
|
import { AddonCard } from "@/components/public/AddonCard";
|
||||||
import { HeroBanner } from "@/components/public/HeroBanner";
|
import { HeroBanner } from "@/components/public/HeroBanner";
|
||||||
import { Sparkles, Shield, Zap } from "lucide-react";
|
import { GameGallery } from "@/components/public/GameGallery";
|
||||||
|
import { Sparkles, Shield, Zap, Calendar } from "lucide-react";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const [featuredAddons, totalDownloads, launcher] = await Promise.all([
|
const [featuredAddons, launcher, launcherDownloads, banners, galleryImages, latestArticles] =
|
||||||
|
await Promise.all([
|
||||||
prisma.addon.findMany({
|
prisma.addon.findMany({
|
||||||
where: { published: true },
|
where: { published: true },
|
||||||
include: {
|
include: {
|
||||||
@@ -20,9 +22,6 @@ export default async function HomePage() {
|
|||||||
orderBy: { totalDownloads: "desc" },
|
orderBy: { totalDownloads: "desc" },
|
||||||
take: 6,
|
take: 6,
|
||||||
}),
|
}),
|
||||||
prisma.addon.aggregate({
|
|
||||||
_sum: { totalDownloads: true },
|
|
||||||
}),
|
|
||||||
prisma.software.findUnique({
|
prisma.software.findUnique({
|
||||||
where: { slug: "nanami-launcher" },
|
where: { slug: "nanami-launcher" },
|
||||||
include: {
|
include: {
|
||||||
@@ -32,22 +31,45 @@ export default async function HomePage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
prisma.softwareVersion.aggregate({
|
||||||
|
where: { software: { slug: "nanami-launcher" } },
|
||||||
|
_sum: { downloadCount: true },
|
||||||
|
}),
|
||||||
|
prisma.bannerImage.findMany({
|
||||||
|
where: { enabled: true },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
select: { imageUrl: true },
|
||||||
|
}),
|
||||||
|
prisma.galleryImage.findMany({
|
||||||
|
where: { enabled: true },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
select: { imageUrl: true, title: true },
|
||||||
|
}),
|
||||||
|
prisma.article.findMany({
|
||||||
|
where: { published: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 3,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const launcherVersion = launcher?.versions[0]?.version ?? null;
|
const launcherVersion = launcher?.versions[0]?.version ?? null;
|
||||||
|
const totalDownloads = launcherDownloads._sum.downloadCount ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeroBanner
|
<HeroBanner
|
||||||
totalDownloads={totalDownloads._sum.totalDownloads ?? undefined}
|
totalDownloads={totalDownloads || undefined}
|
||||||
launcherVersion={launcherVersion}
|
launcherVersion={launcherVersion}
|
||||||
|
banners={banners}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<GameGallery items={galleryImages} />
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<section className="relative border-t border-amber-900/20 bg-gradient-to-b from-[#0d0b15] to-[#110f1a] dark:from-[#0d0b15] dark:to-[#110f1a]">
|
<section className="relative border-t border-amber-900/20 bg-gradient-to-b from-[#0d0b15] to-[#110f1a] dark:from-[#0d0b15] dark:to-[#110f1a]">
|
||||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(168,85,247,0.06)_0%,transparent_70%)]" />
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(168,85,247,0.06)_0%,transparent_70%)]" />
|
||||||
<div className="relative mx-auto max-w-6xl px-4 py-16">
|
<div className="relative mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
|
||||||
<div className="grid gap-8 md:grid-cols-3">
|
<div className="grid gap-4 sm:gap-8 md:grid-cols-3">
|
||||||
<div className="flex flex-col items-center rounded-xl border border-amber-500/10 bg-white/5 p-6 text-center backdrop-blur transition-colors hover:border-amber-500/25">
|
<div className="flex flex-col items-center rounded-xl border border-amber-500/10 bg-white/5 p-6 text-center backdrop-blur transition-colors hover:border-amber-500/25">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-500/10">
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-500/10">
|
||||||
<Sparkles className="h-6 w-6 text-amber-400" />
|
<Sparkles className="h-6 w-6 text-amber-400" />
|
||||||
@@ -83,11 +105,62 @@ export default async function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Latest Articles */}
|
||||||
|
{latestArticles.length > 0 && (
|
||||||
|
<section className="border-t border-amber-900/20 bg-gradient-to-b from-[#110f1a] to-[#0d0b15] dark:from-[#110f1a] dark:to-[#0d0b15]">
|
||||||
|
<div className="mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
|
||||||
|
<div className="mb-6 flex items-center justify-between sm:mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-amber-100">最新公告</h2>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-amber-500/20 text-amber-200 hover:border-amber-500/40 hover:bg-amber-500/10 hover:text-amber-100"
|
||||||
|
render={<Link href="/articles" />}
|
||||||
|
>
|
||||||
|
查看更多
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:gap-6 md:grid-cols-3">
|
||||||
|
{latestArticles.map((article) => (
|
||||||
|
<Link
|
||||||
|
key={article.id}
|
||||||
|
href={`/articles/${article.slug}`}
|
||||||
|
className="group overflow-hidden rounded-xl border border-amber-500/10 bg-white/[0.03] transition-colors hover:border-amber-500/25"
|
||||||
|
>
|
||||||
|
{article.coverImage && (
|
||||||
|
<div className="aspect-[16/9] overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={article.coverImage}
|
||||||
|
alt={article.title}
|
||||||
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="mb-1.5 font-semibold text-amber-100 group-hover:text-amber-200 line-clamp-1">
|
||||||
|
{article.title}
|
||||||
|
</h3>
|
||||||
|
{article.summary && (
|
||||||
|
<p className="mb-2 line-clamp-2 text-sm text-gray-400">
|
||||||
|
{article.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{new Date(article.createdAt).toLocaleDateString("zh-CN")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Featured Addons */}
|
{/* Featured Addons */}
|
||||||
{featuredAddons.length > 0 && (
|
{featuredAddons.length > 0 && (
|
||||||
<section className="border-t border-amber-900/20 bg-gradient-to-b from-[#110f1a] to-[#0d0b15] dark:from-[#110f1a] dark:to-[#0d0b15]">
|
<section className="border-t border-amber-900/20 bg-gradient-to-b from-[#110f1a] to-[#0d0b15] dark:from-[#110f1a] dark:to-[#0d0b15]">
|
||||||
<div className="mx-auto max-w-6xl px-4 py-16">
|
<div className="mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
|
||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between sm:mb-8">
|
||||||
<h2 className="text-2xl font-bold text-amber-100">热门插件</h2>
|
<h2 className="text-2xl font-bold text-amber-100">热门插件</h2>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
33
src/app/admin/(dashboard)/articles/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { ArticleEditor } from "@/components/admin/ArticleEditor";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function EditArticlePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const article = await prisma.article.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!article) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-3xl font-bold">编辑文章</h1>
|
||||||
|
<ArticleEditor
|
||||||
|
initial={{
|
||||||
|
id: article.id,
|
||||||
|
title: article.title,
|
||||||
|
slug: article.slug,
|
||||||
|
summary: article.summary,
|
||||||
|
content: article.content,
|
||||||
|
coverImage: article.coverImage,
|
||||||
|
published: article.published,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/app/admin/(dashboard)/articles/new/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ArticleEditor } from "@/components/admin/ArticleEditor";
|
||||||
|
|
||||||
|
export default function NewArticlePage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-3xl font-bold">新建文章</h1>
|
||||||
|
<ArticleEditor />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/app/admin/(dashboard)/articles/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { ArticleActions } from "@/components/admin/ArticleActions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ArticlesPage() {
|
||||||
|
const articles = await prisma.article.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-3xl font-bold">文章管理</h1>
|
||||||
|
<Button render={<Link href="/admin/articles/new" />}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
新建文章
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{articles.length === 0 ? (
|
||||||
|
<p className="py-10 text-center text-muted-foreground">
|
||||||
|
暂无文章,点击右上角创建第一篇
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>标题</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead>更新时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{articles.map((article) => (
|
||||||
|
<TableRow key={article.id}>
|
||||||
|
<TableCell className="font-medium">{article.title}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={article.published ? "default" : "secondary"}>
|
||||||
|
{article.published ? "已发布" : "草稿"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(article.createdAt).toLocaleDateString("zh-CN")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(article.updatedAt).toLocaleDateString("zh-CN")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ArticleActions id={article.id} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/app/admin/(dashboard)/media/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { MediaManager } from "@/components/admin/MediaManager";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function MediaPage() {
|
||||||
|
const [banners, gallery] = await Promise.all([
|
||||||
|
prisma.bannerImage.findMany({ orderBy: { sortOrder: "asc" } }),
|
||||||
|
prisma.galleryImage.findMany({ orderBy: { sortOrder: "asc" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">媒体管理</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
管理首页 Banner 轮播图和实机演示画廊图片
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="banner">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="banner">Banner 管理</TabsTrigger>
|
||||||
|
<TabsTrigger value="gallery">画廊管理</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="banner" className="mt-6">
|
||||||
|
<MediaManager type="banner" initial={JSON.parse(JSON.stringify(banners))} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="gallery" className="mt-6">
|
||||||
|
<MediaManager type="gallery" initial={JSON.parse(JSON.stringify(gallery))} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,13 +6,34 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Package, Download, FileUp } from "lucide-react";
|
import { Package, Download, FileUp, Eye, Users, TrendingUp } from "lucide-react";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function getLast7Days() {
|
||||||
|
const days: string[] = [];
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
days.push(d.toISOString().slice(0, 10));
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const [addonCount, totalDownloads, releaseCount, recentReleases] =
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
await Promise.all([
|
const days = getLast7Days();
|
||||||
|
|
||||||
|
const [
|
||||||
|
addonCount,
|
||||||
|
totalDownloads,
|
||||||
|
releaseCount,
|
||||||
|
recentReleases,
|
||||||
|
todayPV,
|
||||||
|
totalPV,
|
||||||
|
todayUV,
|
||||||
|
pvByDay,
|
||||||
|
] = await Promise.all([
|
||||||
prisma.addon.count(),
|
prisma.addon.count(),
|
||||||
prisma.addon.aggregate({ _sum: { totalDownloads: true } }),
|
prisma.addon.aggregate({ _sum: { totalDownloads: true } }),
|
||||||
prisma.release.count(),
|
prisma.release.count(),
|
||||||
@@ -21,24 +42,34 @@ export default async function DashboardPage() {
|
|||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: { addon: { select: { name: true } } },
|
include: { addon: { select: { name: true } } },
|
||||||
}),
|
}),
|
||||||
|
prisma.pageView.count({ where: { date: today } }),
|
||||||
|
prisma.pageView.count(),
|
||||||
|
prisma.pageView.groupBy({
|
||||||
|
by: ["ip"],
|
||||||
|
where: { date: today, ip: { not: "" } },
|
||||||
|
}).then((r) => r.length),
|
||||||
|
prisma.pageView.groupBy({
|
||||||
|
by: ["date"],
|
||||||
|
where: { date: { in: days } },
|
||||||
|
_count: true,
|
||||||
|
orderBy: { date: "asc" },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const pvMap = new Map(pvByDay.map((d) => [d.date, d._count]));
|
||||||
|
const chartData = days.map((d) => ({
|
||||||
|
date: d.slice(5),
|
||||||
|
pv: pvMap.get(d) || 0,
|
||||||
|
}));
|
||||||
|
const maxPV = Math.max(...chartData.map((d) => d.pv), 1);
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{ title: "插件总数", value: addonCount, icon: Package },
|
||||||
title: "插件总数",
|
{ title: "总下载量", value: totalDownloads._sum.totalDownloads || 0, icon: Download },
|
||||||
value: addonCount,
|
{ title: "版本发布数", value: releaseCount, icon: FileUp },
|
||||||
icon: Package,
|
{ title: "今日访问 (PV)", value: todayPV, icon: Eye },
|
||||||
},
|
{ title: "今日独立访客 (UV)", value: todayUV, icon: Users },
|
||||||
{
|
{ title: "累计访问量", value: totalPV, icon: TrendingUp },
|
||||||
title: "总下载量",
|
|
||||||
value: totalDownloads._sum.totalDownloads || 0,
|
|
||||||
icon: Download,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "版本发布数",
|
|
||||||
value: releaseCount,
|
|
||||||
icon: FileUp,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -55,12 +86,42 @@ export default async function DashboardPage() {
|
|||||||
<stat.icon className="h-5 w-5 text-muted-foreground" />
|
<stat.icon className="h-5 w-5 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-bold">{stat.value}</div>
|
<div className="text-3xl font-bold">
|
||||||
|
{stat.value.toLocaleString()}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 7-day PV chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>近 7 天访问趋势</CardTitle>
|
||||||
|
<CardDescription>页面浏览量 (PV)</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex h-48 items-end gap-2">
|
||||||
|
{chartData.map((d) => (
|
||||||
|
<div key={d.date} className="flex flex-1 flex-col items-center gap-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{d.pv}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t bg-primary/80 transition-all"
|
||||||
|
style={{
|
||||||
|
height: `${Math.max((d.pv / maxPV) * 160, 4)}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{d.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>最近发布</CardTitle>
|
<CardTitle>最近发布</CardTitle>
|
||||||
|
|||||||
75
src/app/api/articles/[id]/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const article = await prisma.article.findFirst({
|
||||||
|
where: { OR: [{ id }, { slug: id }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(article);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (body.slug) {
|
||||||
|
const existing = await prisma.article.findFirst({
|
||||||
|
where: { slug: body.slug, NOT: { id } },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Slug already exists" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const article = await prisma.article.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(body.title !== undefined && { title: body.title }),
|
||||||
|
...(body.slug !== undefined && { slug: body.slug }),
|
||||||
|
...(body.summary !== undefined && { summary: body.summary }),
|
||||||
|
...(body.content !== undefined && { content: body.content }),
|
||||||
|
...(body.coverImage !== undefined && {
|
||||||
|
coverImage: body.coverImage || null,
|
||||||
|
}),
|
||||||
|
...(body.published !== undefined && { published: body.published }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(article);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
await prisma.article.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
57
src/app/api/articles/route.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const publishedOnly = searchParams.get("published") !== "false";
|
||||||
|
const limit = searchParams.get("limit");
|
||||||
|
|
||||||
|
const where = publishedOnly ? { published: true } : {};
|
||||||
|
|
||||||
|
const articles = await prisma.article.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
...(limit ? { take: parseInt(limit, 10) } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(articles);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { title, slug, summary, content, coverImage, published } = body;
|
||||||
|
|
||||||
|
if (!title || !slug) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "title and slug are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.article.findUnique({ where: { slug } });
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Slug already exists" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const article = await prisma.article.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
summary: summary || "",
|
||||||
|
content: content || "",
|
||||||
|
coverImage: coverImage || null,
|
||||||
|
published: published ?? false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(article, { status: 201 });
|
||||||
|
}
|
||||||
37
src/app/api/banners/[id]/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder;
|
||||||
|
if (body.enabled !== undefined) data.enabled = body.enabled;
|
||||||
|
if (body.imageUrl !== undefined) data.imageUrl = body.imageUrl;
|
||||||
|
|
||||||
|
const updated = await prisma.bannerImage.update({ where: { id }, data });
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
await prisma.bannerImage.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
30
src/app/api/banners/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const enabledOnly = request.nextUrl.searchParams.get("enabled") === "1";
|
||||||
|
const where = enabledOnly ? { enabled: true } : {};
|
||||||
|
const banners = await prisma.bannerImage.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
return NextResponse.json(banners);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const banner = await prisma.bannerImage.create({
|
||||||
|
data: {
|
||||||
|
imageUrl: body.imageUrl,
|
||||||
|
sortOrder: body.sortOrder ?? 0,
|
||||||
|
enabled: body.enabled ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(banner);
|
||||||
|
}
|
||||||
38
src/app/api/gallery/[id]/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder;
|
||||||
|
if (body.enabled !== undefined) data.enabled = body.enabled;
|
||||||
|
if (body.title !== undefined) data.title = body.title;
|
||||||
|
if (body.imageUrl !== undefined) data.imageUrl = body.imageUrl;
|
||||||
|
|
||||||
|
const updated = await prisma.galleryImage.update({ where: { id }, data });
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
await prisma.galleryImage.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
31
src/app/api/gallery/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const enabledOnly = request.nextUrl.searchParams.get("enabled") === "1";
|
||||||
|
const where = enabledOnly ? { enabled: true } : {};
|
||||||
|
const images = await prisma.galleryImage.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
return NextResponse.json(images);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const image = await prisma.galleryImage.create({
|
||||||
|
data: {
|
||||||
|
imageUrl: body.imageUrl,
|
||||||
|
title: body.title ?? "",
|
||||||
|
sortOrder: body.sortOrder ?? 0,
|
||||||
|
enabled: body.enabled ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(image);
|
||||||
|
}
|
||||||
36
src/app/api/seed/route.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const key = request.nextUrl.searchParams.get("key");
|
||||||
|
if (key !== "nanami-init-2024") {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bannerCount = await prisma.bannerImage.count();
|
||||||
|
const galleryCount = await prisma.galleryImage.count();
|
||||||
|
|
||||||
|
let bannersCreated = 0;
|
||||||
|
let galleryCreated = 0;
|
||||||
|
|
||||||
|
if (bannerCount === 0) {
|
||||||
|
const banners = [
|
||||||
|
{ imageUrl: "/banners/banner_2.png", sortOrder: 0 },
|
||||||
|
{ imageUrl: "/banners/banner_1.png", sortOrder: 1 },
|
||||||
|
{ imageUrl: "/banners/banner_3.png", sortOrder: 2 },
|
||||||
|
];
|
||||||
|
await prisma.bannerImage.createMany({ data: banners });
|
||||||
|
bannersCreated = banners.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (galleryCount === 0) {
|
||||||
|
const gallery = Array.from({ length: 14 }, (_, i) => ({
|
||||||
|
imageUrl: `/views/view_${i + 1}.png`,
|
||||||
|
sortOrder: i,
|
||||||
|
}));
|
||||||
|
await prisma.galleryImage.createMany({ data: gallery });
|
||||||
|
galleryCreated = gallery.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ bannersCreated, galleryCreated });
|
||||||
|
}
|
||||||
10
src/app/api/server-time/route.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const now = new Date();
|
||||||
|
return NextResponse.json({
|
||||||
|
timestamp: now.getTime(),
|
||||||
|
iso: now.toISOString(),
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -37,9 +37,15 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const hasUpdate = latest.versionCode > clientVersionCode;
|
const hasUpdate = latest.versionCode > clientVersionCode;
|
||||||
|
|
||||||
const downloadUrl = latest.downloadType === "url" && latest.externalUrl
|
const origin =
|
||||||
|
request.headers.get("x-forwarded-proto") && request.headers.get("host")
|
||||||
|
? `${request.headers.get("x-forwarded-proto")}://${request.headers.get("host")}`
|
||||||
|
: request.nextUrl.origin;
|
||||||
|
|
||||||
|
const downloadUrl =
|
||||||
|
latest.downloadType === "url" && latest.externalUrl
|
||||||
? latest.externalUrl
|
? latest.externalUrl
|
||||||
: `${request.nextUrl.origin}/api/software/download/${latest.id}`;
|
: `${origin}/api/software/download/${latest.id}`;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
hasUpdate,
|
hasUpdate,
|
||||||
|
|||||||
@@ -32,10 +32,19 @@ export async function GET(request: NextRequest) {
|
|||||||
const latest = software.versions[0];
|
const latest = software.versions[0];
|
||||||
|
|
||||||
if (infoOnly) {
|
if (infoOnly) {
|
||||||
|
const track = searchParams.get("track") === "1";
|
||||||
|
if (track) {
|
||||||
|
await prisma.softwareVersion.update({
|
||||||
|
where: { id: latest.id },
|
||||||
|
data: { downloadCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const downloadUrl =
|
const downloadUrl =
|
||||||
latest.downloadType === "url" && latest.externalUrl
|
latest.downloadType === "url" && latest.externalUrl
|
||||||
? latest.externalUrl
|
? latest.externalUrl
|
||||||
: `/api/software/download/${latest.id}`;
|
: `/api/software/download/${latest.id}`;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
available: true,
|
available: true,
|
||||||
version: latest.version,
|
version: latest.version,
|
||||||
|
|||||||
24
src/app/api/track/route.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const path = body.path || "/";
|
||||||
|
const referrer = body.referrer || "";
|
||||||
|
const ua = request.headers.get("user-agent") || "";
|
||||||
|
const ip =
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|
request.headers.get("x-real-ip") ||
|
||||||
|
"";
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
await prisma.pageView.create({
|
||||||
|
data: { path, referrer, userAgent: ua, ip, date },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ ok: false }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,59 +138,275 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 80px;
|
height: 100px;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
filter: blur(30px);
|
filter: blur(40px);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-aurora--top {
|
.hero-aurora--top {
|
||||||
top: -30px;
|
top: -40px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
transparent 0%,
|
transparent 0%,
|
||||||
rgba(168, 85, 247, 0.4) 15%,
|
rgba(168, 85, 247, 0.45) 12%,
|
||||||
rgba(59, 130, 246, 0.3) 30%,
|
rgba(59, 130, 246, 0.35) 28%,
|
||||||
rgba(236, 72, 153, 0.35) 50%,
|
rgba(236, 72, 153, 0.4) 45%,
|
||||||
rgba(139, 92, 246, 0.4) 70%,
|
rgba(245, 158, 11, 0.3) 60%,
|
||||||
rgba(6, 182, 212, 0.3) 85%,
|
rgba(139, 92, 246, 0.45) 75%,
|
||||||
|
rgba(6, 182, 212, 0.35) 88%,
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
animation: auroraShift 8s ease-in-out infinite alternate;
|
animation: auroraShift 10s ease-in-out infinite alternate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-aurora--bottom {
|
.hero-aurora--bottom {
|
||||||
bottom: -30px;
|
bottom: -40px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
transparent 0%,
|
transparent 0%,
|
||||||
rgba(245, 158, 11, 0.35) 15%,
|
rgba(245, 158, 11, 0.4) 12%,
|
||||||
rgba(168, 85, 247, 0.3) 35%,
|
rgba(168, 85, 247, 0.35) 30%,
|
||||||
rgba(6, 182, 212, 0.35) 55%,
|
rgba(6, 182, 212, 0.4) 48%,
|
||||||
rgba(236, 72, 153, 0.3) 75%,
|
rgba(236, 72, 153, 0.35) 65%,
|
||||||
rgba(245, 158, 11, 0.35) 90%,
|
rgba(139, 92, 246, 0.3) 80%,
|
||||||
|
rgba(245, 158, 11, 0.4) 92%,
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
animation: auroraShift 8s ease-in-out infinite alternate-reverse;
|
animation: auroraShift 10s ease-in-out infinite alternate-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes auroraShift {
|
@keyframes auroraShift {
|
||||||
0% {
|
0% {
|
||||||
background-position: 0% 50%;
|
background-position: 0% 50%;
|
||||||
opacity: 0.5;
|
opacity: 0.45;
|
||||||
|
filter: blur(35px);
|
||||||
}
|
}
|
||||||
50% {
|
33% {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
filter: blur(45px);
|
||||||
|
}
|
||||||
|
66% {
|
||||||
|
opacity: 0.6;
|
||||||
|
filter: blur(38px);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
background-position: 100% 50%;
|
background-position: 100% 50%;
|
||||||
opacity: 0.5;
|
opacity: 0.45;
|
||||||
|
filter: blur(35px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Force background-size for aurora animation */
|
|
||||||
.hero-aurora--top,
|
.hero-aurora--top,
|
||||||
.hero-aurora--bottom {
|
.hero-aurora--bottom {
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Hero Banner Effects ---- */
|
||||||
|
.hero-banner {
|
||||||
|
border-bottom: 1px solid rgba(245, 158, 11, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-scanlines {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 2px,
|
||||||
|
rgba(0, 0, 0, 0.015) 2px,
|
||||||
|
rgba(0, 0, 0, 0.015) 4px
|
||||||
|
);
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-tagline {
|
||||||
|
text-shadow: 0 0 20px rgba(245, 158, 11, 0.2);
|
||||||
|
animation: taglineFade 1.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes taglineFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-shimmer {
|
||||||
|
background: linear-gradient(
|
||||||
|
105deg,
|
||||||
|
transparent 40%,
|
||||||
|
rgba(255, 255, 255, 0.06) 45%,
|
||||||
|
rgba(255, 255, 255, 0.1) 50%,
|
||||||
|
rgba(255, 255, 255, 0.06) 55%,
|
||||||
|
transparent 60%
|
||||||
|
);
|
||||||
|
background-size: 250% 100%;
|
||||||
|
animation: shimmerSweep 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmerSweep {
|
||||||
|
0%, 100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: -50% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-download-btn::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(245, 158, 11, 0.3),
|
||||||
|
rgba(168, 85, 247, 0.15),
|
||||||
|
rgba(245, 158, 11, 0.3)
|
||||||
|
);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: borderGlow 4s ease-in-out infinite;
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes borderGlow {
|
||||||
|
0%, 100% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-indicator {
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hero-shimmer,
|
||||||
|
.hero-tagline {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.hero-download-btn::before {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.hero-scanlines {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Download button shimmer */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Gallery Thumbnail Scrollbar ---- */
|
||||||
|
.gallery-thumbs {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(251, 191, 36, 0.25) transparent;
|
||||||
|
}
|
||||||
|
.gallery-thumbs::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
.gallery-thumbs::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.gallery-thumbs::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.gallery-thumbs::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(251, 191, 36, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Article Markdown Prose ---- */
|
||||||
|
.prose-article {
|
||||||
|
color: #d1d5db;
|
||||||
|
line-height: 1.8;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.prose-article h1,
|
||||||
|
.prose-article h2,
|
||||||
|
.prose-article h3,
|
||||||
|
.prose-article h4 {
|
||||||
|
color: #fef3c7;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 1.6em;
|
||||||
|
margin-bottom: 0.6em;
|
||||||
|
}
|
||||||
|
.prose-article h1 { font-size: 1.75em; }
|
||||||
|
.prose-article h2 { font-size: 1.4em; border-bottom: 1px solid rgba(251,191,36,0.15); padding-bottom: 0.3em; }
|
||||||
|
.prose-article h3 { font-size: 1.15em; }
|
||||||
|
.prose-article p { margin-bottom: 1em; }
|
||||||
|
.prose-article a {
|
||||||
|
color: #fbbf24;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.prose-article a:hover { color: #fde68a; }
|
||||||
|
.prose-article strong { color: #fef3c7; }
|
||||||
|
.prose-article blockquote {
|
||||||
|
border-left: 3px solid rgba(251,191,36,0.3);
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
margin: 1em 0;
|
||||||
|
color: #9ca3af;
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
.prose-article ul, .prose-article ol { padding-left: 1.5em; margin-bottom: 1em; }
|
||||||
|
.prose-article li { margin-bottom: 0.3em; }
|
||||||
|
.prose-article ul { list-style-type: disc; }
|
||||||
|
.prose-article ol { list-style-type: decimal; }
|
||||||
|
.prose-article code {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
.prose-article pre {
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1em;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
.prose-article pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
.prose-article img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
.prose-article table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
.prose-article th, .prose-article td {
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
padding: 0.5em 0.75em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.prose-article th {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
color: #fef3c7;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.prose-article hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid rgba(251,191,36,0.15);
|
||||||
|
margin: 2em 0;
|
||||||
|
}
|
||||||
49
src/app/uploads/[...path]/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { readFile, stat } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".zip": "application/zip",
|
||||||
|
".exe": "application/octet-stream",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> }
|
||||||
|
) {
|
||||||
|
const segments = await params;
|
||||||
|
const filePath = path.join(process.cwd(), "uploads", ...segments.path);
|
||||||
|
|
||||||
|
const resolved = path.resolve(filePath);
|
||||||
|
const uploadsDir = path.resolve(path.join(process.cwd(), "uploads"));
|
||||||
|
if (!resolved.startsWith(uploadsDir)) {
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await stat(resolved);
|
||||||
|
if (!info.isFile()) {
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await readFile(resolved);
|
||||||
|
const ext = path.extname(resolved).toLowerCase();
|
||||||
|
const contentType = MIME[ext] || "application/octet-stream";
|
||||||
|
|
||||||
|
return new Response(buffer, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/components/admin/ArticleActions.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
export function ArticleActions({ id }: { id: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm("确定要删除这篇文章吗?")) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/articles/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
toast.success("文章已删除");
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast.error("删除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push(`/admin/articles/${id}/edit`)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleDelete}>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
268
src/components/admin/ArticleEditor.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ImagePlus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
const MDEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false });
|
||||||
|
|
||||||
|
interface ArticleData {
|
||||||
|
id?: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
summary: string;
|
||||||
|
content: string;
|
||||||
|
coverImage: string | null;
|
||||||
|
published: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArticleEditor({ initial }: { initial?: ArticleData }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const isEdit = !!initial?.id;
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [title, setTitle] = useState(initial?.title ?? "");
|
||||||
|
const [slug, setSlug] = useState(initial?.slug ?? "");
|
||||||
|
const [summary, setSummary] = useState(initial?.summary ?? "");
|
||||||
|
const [content, setContent] = useState(initial?.content ?? "");
|
||||||
|
const [coverImage, setCoverImage] = useState(initial?.coverImage ?? "");
|
||||||
|
const [published, setPublished] = useState(initial?.published ?? false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [slugEdited, setSlugEdited] = useState(false);
|
||||||
|
|
||||||
|
const generateSlug = (t: string) =>
|
||||||
|
t
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
|
||||||
|
const handleTitleChange = (val: string) => {
|
||||||
|
setTitle(val);
|
||||||
|
if (!slugEdited && !isEdit) {
|
||||||
|
setSlug(generateSlug(val));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = async (file: File): Promise<string | null> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/upload", { method: "POST", body: form });
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
return data.filePath as string;
|
||||||
|
} catch {
|
||||||
|
toast.error("图片上传失败");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
const path = await uploadFile(file);
|
||||||
|
if (path) setCoverImage(path);
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (asDraft = false) => {
|
||||||
|
if (!title.trim() || !slug.trim()) {
|
||||||
|
toast.error("标题和 Slug 不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
title: title.trim(),
|
||||||
|
slug: slug.trim(),
|
||||||
|
summary: summary.trim(),
|
||||||
|
content,
|
||||||
|
coverImage: coverImage || null,
|
||||||
|
published: asDraft ? false : published,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = isEdit ? `/api/articles/${initial!.id}` : "/api/articles";
|
||||||
|
const method = isEdit ? "PUT" : "POST";
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "保存失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(isEdit ? "文章已更新" : "文章已创建");
|
||||||
|
router.push("/admin/articles");
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Title */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">标题</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => handleTitleChange(e.target.value)}
|
||||||
|
placeholder="文章标题"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slug */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="slug">Slug (URL 标识)</Label>
|
||||||
|
<Input
|
||||||
|
id="slug"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSlug(e.target.value);
|
||||||
|
setSlugEdited(true);
|
||||||
|
}}
|
||||||
|
placeholder="article-url-slug"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="summary">摘要</Label>
|
||||||
|
<Input
|
||||||
|
id="summary"
|
||||||
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
|
placeholder="文章摘要(可选,用于列表展示)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover Image */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>封面图</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{coverImage ? (
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={coverImage}
|
||||||
|
alt="封面"
|
||||||
|
className="h-24 w-40 rounded border object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCoverImage("")}
|
||||||
|
className="absolute -right-2 -top-2 rounded-full bg-destructive p-1 text-destructive-foreground"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="flex h-24 w-40 items-center justify-center rounded border-2 border-dashed border-muted-foreground/25 text-muted-foreground transition-colors hover:border-muted-foreground/50"
|
||||||
|
>
|
||||||
|
{uploading ? "上传中..." : <ImagePlus className="h-6 w-6" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleCoverUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Published */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
checked={published}
|
||||||
|
onCheckedChange={setPublished}
|
||||||
|
id="published"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="published">{published ? "已发布" : "草稿"}</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Markdown Editor */}
|
||||||
|
<div className="space-y-2" data-color-mode="light">
|
||||||
|
<Label>内容 (Markdown)</Label>
|
||||||
|
<MDEditor
|
||||||
|
value={content}
|
||||||
|
onChange={(val) => setContent(val || "")}
|
||||||
|
height={500}
|
||||||
|
preview="live"
|
||||||
|
onDrop={async (e) => {
|
||||||
|
const file = e.dataTransfer?.files?.[0];
|
||||||
|
if (!file || !file.type.startsWith("image/")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const path = await uploadFile(file);
|
||||||
|
if (path) {
|
||||||
|
setContent((prev) => prev + `\n\n`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPaste={async (e) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.type.startsWith("image/")) {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (!file) continue;
|
||||||
|
const path = await uploadFile(file);
|
||||||
|
if (path) {
|
||||||
|
setContent(
|
||||||
|
(prev) => prev + `\n\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
支持拖拽或粘贴图片自动上传
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button onClick={() => handleSave()} disabled={saving}>
|
||||||
|
{saving ? "保存中..." : isEdit ? "更新文章" : "发布文章"}
|
||||||
|
</Button>
|
||||||
|
{!isEdit && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleSave(true)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
保存为草稿
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.push("/admin/articles")}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
src/components/admin/MediaManager.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Trash2, Upload, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
interface MediaItem {
|
||||||
|
id: string;
|
||||||
|
imageUrl: string;
|
||||||
|
sortOrder: number;
|
||||||
|
enabled: boolean;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type: "banner" | "gallery";
|
||||||
|
initial: MediaItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaManager({ type, initial }: Props) {
|
||||||
|
const [items, setItems] = useState<MediaItem[]>(initial);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const endpoint = type === "banner" ? "/api/banners" : "/api/gallery";
|
||||||
|
|
||||||
|
const uploadFile = async (file: File) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch("/api/upload", { method: "POST", body: fd });
|
||||||
|
if (!res.ok) throw new Error("Upload failed");
|
||||||
|
return (await res.json()) as { filePath: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files?.length) return;
|
||||||
|
setUploading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const { filePath } = await uploadFile(file);
|
||||||
|
const maxSort = items.reduce((m, it) => Math.max(m, it.sortOrder), -1);
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ imageUrl: filePath, sortOrder: maxSort + 1 }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Create failed");
|
||||||
|
const created = await res.json();
|
||||||
|
setItems((prev) => [...prev, created]);
|
||||||
|
}
|
||||||
|
toast.success("上传成功");
|
||||||
|
} catch {
|
||||||
|
toast.error("上传失败");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("确认删除?")) return;
|
||||||
|
const res = await fetch(`${endpoint}/${id}`, { method: "DELETE" });
|
||||||
|
if (res.ok) {
|
||||||
|
setItems((prev) => prev.filter((it) => it.id !== id));
|
||||||
|
toast.success("已删除");
|
||||||
|
} else {
|
||||||
|
toast.error("删除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (id: string, data: Partial<MediaItem>) => {
|
||||||
|
const res = await fetch(`${endpoint}/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const updated = await res.json();
|
||||||
|
setItems((prev) => prev.map((it) => (it.id === id ? updated : it)));
|
||||||
|
} else {
|
||||||
|
toast.error("更新失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
上传图片
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleUpload}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
共 {items.length} 张
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="py-12 text-center text-muted-foreground">
|
||||||
|
暂无图片,请点击上方按钮上传
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="overflow-hidden rounded-lg border bg-card"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video bg-muted">
|
||||||
|
<img
|
||||||
|
src={item.imageUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
{!item.enabled && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||||
|
<span className="text-sm font-medium text-white/80">
|
||||||
|
已禁用
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{type === "gallery" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">标题</Label>
|
||||||
|
<Input
|
||||||
|
value={item.title ?? ""}
|
||||||
|
placeholder="可选标题"
|
||||||
|
onChange={(e) =>
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((it) =>
|
||||||
|
it.id === item.id
|
||||||
|
? { ...it, title: e.target.value }
|
||||||
|
: it
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onBlur={() =>
|
||||||
|
handleUpdate(item.id, { title: item.title })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs">排序</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item.sortOrder}
|
||||||
|
className="h-8 w-20"
|
||||||
|
onChange={(e) =>
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((it) =>
|
||||||
|
it.id === item.id
|
||||||
|
? { ...it, sortOrder: parseInt(e.target.value) || 0 }
|
||||||
|
: it
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onBlur={() =>
|
||||||
|
handleUpdate(item.id, { sortOrder: item.sortOrder })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs">启用</Label>
|
||||||
|
<Switch
|
||||||
|
checked={item.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleUpdate(item.id, { enabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="ml-auto h-8 w-8 text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Upload,
|
Upload,
|
||||||
Monitor,
|
Monitor,
|
||||||
|
ImageIcon,
|
||||||
|
FileText,
|
||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -21,6 +23,8 @@ const navItems = [
|
|||||||
{ href: "/admin/addons", label: "插件管理", icon: Package },
|
{ href: "/admin/addons", label: "插件管理", icon: Package },
|
||||||
{ href: "/admin/releases", label: "插件版本", icon: Upload },
|
{ href: "/admin/releases", label: "插件版本", icon: Upload },
|
||||||
{ href: "/admin/software", label: "软件管理", icon: Monitor },
|
{ href: "/admin/software", label: "软件管理", icon: Monitor },
|
||||||
|
{ href: "/admin/media", label: "媒体管理", icon: ImageIcon },
|
||||||
|
{ href: "/admin/articles", label: "文章管理", icon: FileText },
|
||||||
{ href: "/admin/settings", label: "系统设置", icon: Settings },
|
{ href: "/admin/settings", label: "系统设置", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
215
src/components/public/GameGallery.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { ChevronLeft, ChevronRight, X, Monitor } from "lucide-react";
|
||||||
|
|
||||||
|
interface GalleryItem {
|
||||||
|
imageUrl: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ITEMS: GalleryItem[] = Array.from({ length: 14 }, (_, i) => ({
|
||||||
|
imageUrl: `/views/view_${i + 1}.png`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SWIPE_THRESHOLD = 40;
|
||||||
|
|
||||||
|
export function GameGallery({ items }: { items?: GalleryItem[] }) {
|
||||||
|
const gallery = items && items.length > 0 ? items : DEFAULT_ITEMS;
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [lightbox, setLightbox] = useState(false);
|
||||||
|
const thumbsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const touchStartX = useRef(0);
|
||||||
|
const empty = items && items.length === 0;
|
||||||
|
|
||||||
|
const go = useCallback(
|
||||||
|
(dir: 1 | -1) => {
|
||||||
|
setActive((prev) => (prev + dir + gallery.length) % gallery.length);
|
||||||
|
},
|
||||||
|
[gallery.length]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = thumbsRef.current?.children[active] as HTMLElement | undefined;
|
||||||
|
el?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowLeft") go(-1);
|
||||||
|
if (e.key === "ArrowRight") go(1);
|
||||||
|
if (e.key === "Escape") setLightbox(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [go]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lightbox) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => { document.body.style.overflow = ""; };
|
||||||
|
}
|
||||||
|
}, [lightbox]);
|
||||||
|
|
||||||
|
if (empty) return null;
|
||||||
|
|
||||||
|
const onTouchStart = (e: React.TouchEvent) => {
|
||||||
|
touchStartX.current = e.touches[0].clientX;
|
||||||
|
};
|
||||||
|
const onTouchEnd = (e: React.TouchEvent) => {
|
||||||
|
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||||
|
if (Math.abs(dx) < SWIPE_THRESHOLD) return;
|
||||||
|
if (dx < 0) go(1);
|
||||||
|
else go(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="border-t border-amber-900/20 bg-gradient-to-b from-[#0d0b15] to-[#110f1a]">
|
||||||
|
<div className="mx-auto max-w-6xl px-3 py-8 sm:px-4 sm:py-14">
|
||||||
|
{/* Title */}
|
||||||
|
<div className="mb-4 flex items-center gap-2 sm:mb-8 sm:gap-3">
|
||||||
|
<Monitor className="h-4 w-4 text-amber-400 sm:h-5 sm:w-5" />
|
||||||
|
<h2 className="text-xl font-bold text-amber-100 sm:text-2xl">实机演示</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main image */}
|
||||||
|
<div
|
||||||
|
className="group relative overflow-hidden rounded-lg border border-amber-500/15 bg-black/30 sm:rounded-xl"
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
>
|
||||||
|
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
|
||||||
|
{gallery.map((item, i) => (
|
||||||
|
<img
|
||||||
|
key={item.imageUrl}
|
||||||
|
src={item.imageUrl}
|
||||||
|
alt={item.title || `实机截图 ${i + 1}`}
|
||||||
|
loading={i <= 1 ? "eager" : "lazy"}
|
||||||
|
draggable={false}
|
||||||
|
onClick={() => setLightbox(true)}
|
||||||
|
className={`absolute inset-0 h-full w-full cursor-pointer object-contain transition-opacity duration-500 ${
|
||||||
|
i === active ? "opacity-100" : "pointer-events-none opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title overlay */}
|
||||||
|
{gallery[active]?.title && (
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-3 pb-2 pt-6 sm:px-4 sm:pb-3 sm:pt-8">
|
||||||
|
<p className="text-xs font-medium text-white/90 sm:text-sm">
|
||||||
|
{gallery[active].title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Counter */}
|
||||||
|
<span className="absolute right-2 top-2 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/70 backdrop-blur sm:right-3 sm:top-3 sm:rounded-md sm:px-2.5 sm:py-1 sm:text-xs">
|
||||||
|
{active + 1} / {gallery.length}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Arrows - always visible on mobile, hover on desktop */}
|
||||||
|
<button
|
||||||
|
onClick={() => go(-1)}
|
||||||
|
className="absolute left-1.5 top-1/2 -translate-y-1/2 cursor-pointer rounded-full bg-black/40 p-1.5 text-white/60 backdrop-blur transition-all active:scale-90 sm:left-3 sm:bg-black/50 sm:p-2.5 sm:text-white/70 sm:opacity-0 sm:hover:bg-black/70 sm:hover:text-white sm:group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => go(1)}
|
||||||
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 cursor-pointer rounded-full bg-black/40 p-1.5 text-white/60 backdrop-blur transition-all active:scale-90 sm:right-3 sm:bg-black/50 sm:p-2.5 sm:text-white/70 sm:opacity-0 sm:hover:bg-black/70 sm:hover:text-white sm:group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnails */}
|
||||||
|
<div
|
||||||
|
ref={thumbsRef}
|
||||||
|
className="gallery-thumbs mt-3 flex gap-1.5 overflow-x-auto pb-1 sm:mt-4 sm:gap-2 sm:pb-2"
|
||||||
|
>
|
||||||
|
{gallery.map((item, i) => (
|
||||||
|
<button
|
||||||
|
key={item.imageUrl}
|
||||||
|
onClick={() => setActive(i)}
|
||||||
|
className={`flex-none cursor-pointer overflow-hidden rounded border-2 transition-all duration-300 sm:rounded-lg ${
|
||||||
|
i === active
|
||||||
|
? "border-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.3)]"
|
||||||
|
: "border-transparent opacity-50 hover:opacity-80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={item.imageUrl}
|
||||||
|
alt={item.title || `缩略图 ${i + 1}`}
|
||||||
|
loading="lazy"
|
||||||
|
draggable={false}
|
||||||
|
className="h-12 w-20 object-cover sm:h-16 sm:w-28 md:h-[72px] md:w-32"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Lightbox */}
|
||||||
|
{lightbox && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 sm:bg-black/90 sm:backdrop-blur-sm"
|
||||||
|
onClick={() => setLightbox(false)}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
>
|
||||||
|
{/* Close */}
|
||||||
|
<button
|
||||||
|
className="absolute right-3 top-3 z-10 cursor-pointer rounded-full bg-white/10 p-2.5 text-white/70 transition-colors hover:bg-white/20 hover:text-white sm:right-4 sm:top-4 sm:p-2"
|
||||||
|
onClick={() => setLightbox(false)}
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Left arrow */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
go(-1);
|
||||||
|
}}
|
||||||
|
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 cursor-pointer rounded-full bg-white/10 p-2 text-white/70 transition-colors active:scale-90 sm:left-4 sm:p-3 sm:hover:bg-white/20 sm:hover:text-white"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Image + title */}
|
||||||
|
<div className="flex flex-col items-center px-10 sm:px-16" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<img
|
||||||
|
src={gallery[active].imageUrl}
|
||||||
|
alt={gallery[active].title || `实机截图 ${active + 1}`}
|
||||||
|
className="max-h-[80vh] max-w-[95vw] rounded-lg object-contain sm:max-h-[85vh] sm:max-w-[90vw]"
|
||||||
|
/>
|
||||||
|
{gallery[active].title && (
|
||||||
|
<p className="mt-2 text-sm font-medium text-white/80 sm:mt-3 sm:text-base">
|
||||||
|
{gallery[active].title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right arrow */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
go(1);
|
||||||
|
}}
|
||||||
|
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 cursor-pointer rounded-full bg-white/10 p-2 text-white/70 transition-colors active:scale-90 sm:right-4 sm:p-3 sm:hover:bg-white/20 sm:hover:text-white"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Counter */}
|
||||||
|
<span className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-md bg-black/60 px-2.5 py-1 text-xs text-white/70 backdrop-blur sm:bottom-6 sm:px-3 sm:py-1.5 sm:text-sm">
|
||||||
|
{active + 1} / {gallery.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import { Download } from "lucide-react";
|
import { Download, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
interface Particle {
|
interface Particle {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -15,27 +15,70 @@ interface Particle {
|
|||||||
twinklePhase: number;
|
twinklePhase: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slides = [
|
interface Nebula {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
radius: number;
|
||||||
|
color: string;
|
||||||
|
alpha: number;
|
||||||
|
drift: number;
|
||||||
|
phase: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SLIDES = [
|
||||||
{ image: "/banners/banner_2.png" },
|
{ image: "/banners/banner_2.png" },
|
||||||
{ image: "/banners/banner_1.png" },
|
{ image: "/banners/banner_1.png" },
|
||||||
{ image: "/banners/banner_3.png" },
|
{ image: "/banners/banner_3.png" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const PARTICLE_COUNT = 50;
|
const PARTICLE_COUNT = 60;
|
||||||
|
const NEBULA_COUNT = 5;
|
||||||
const AUTO_PLAY_MS = 6000;
|
const AUTO_PLAY_MS = 6000;
|
||||||
|
const SWIPE_THRESHOLD = 50;
|
||||||
|
|
||||||
export function HeroBanner({
|
export function HeroBanner({
|
||||||
totalDownloads,
|
totalDownloads,
|
||||||
launcherVersion,
|
launcherVersion,
|
||||||
|
banners,
|
||||||
}: {
|
}: {
|
||||||
totalDownloads?: number;
|
totalDownloads?: number;
|
||||||
launcherVersion?: string | null;
|
launcherVersion?: string | null;
|
||||||
|
banners?: { imageUrl: string }[];
|
||||||
}) {
|
}) {
|
||||||
|
const slides =
|
||||||
|
banners && banners.length > 0
|
||||||
|
? banners.map((b) => ({ image: b.imageUrl }))
|
||||||
|
: DEFAULT_SLIDES;
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const particlesRef = useRef<Particle[]>([]);
|
const particlesRef = useRef<Particle[]>([]);
|
||||||
|
const nebulaeRef = useRef<Nebula[]>([]);
|
||||||
const rafRef = useRef(0);
|
const rafRef = useRef(0);
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
|
const [prev, setPrev] = useState(-1);
|
||||||
|
const [transitioning, setTransitioning] = useState(false);
|
||||||
|
const touchStartX = useRef(0);
|
||||||
|
const prefersReducedMotion = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
prefersReducedMotion.current = window.matchMedia(
|
||||||
|
"(prefers-reduced-motion: reduce)"
|
||||||
|
).matches;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goTo = useCallback(
|
||||||
|
(next: number) => {
|
||||||
|
if (transitioning || next === active) return;
|
||||||
|
setPrev(active);
|
||||||
|
setActive(next);
|
||||||
|
setTransitioning(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setPrev(-1);
|
||||||
|
setTransitioning(false);
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
[active, transitioning]
|
||||||
|
);
|
||||||
|
|
||||||
const createParticles = useCallback((w: number, h: number) => {
|
const createParticles = useCallback((w: number, h: number) => {
|
||||||
const palettes = [
|
const palettes = [
|
||||||
@@ -45,25 +88,53 @@ export function HeroBanner({
|
|||||||
"rgba(255,255,220,",
|
"rgba(255,255,220,",
|
||||||
"rgba(200,160,255,",
|
"rgba(200,160,255,",
|
||||||
"rgba(120,200,255,",
|
"rgba(120,200,255,",
|
||||||
|
"rgba(255,180,120,",
|
||||||
];
|
];
|
||||||
const out: Particle[] = [];
|
const out: Particle[] = [];
|
||||||
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
const count = prefersReducedMotion.current
|
||||||
|
? Math.floor(PARTICLE_COUNT / 3)
|
||||||
|
: PARTICLE_COUNT;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
const z = Math.random();
|
const z = Math.random();
|
||||||
out.push({
|
out.push({
|
||||||
x: Math.random() * w,
|
x: Math.random() * w,
|
||||||
y: Math.random() * h,
|
y: Math.random() * h,
|
||||||
z,
|
z,
|
||||||
vx: (Math.random() - 0.5) * 0.3,
|
vx: (Math.random() - 0.5) * 0.25,
|
||||||
vy: -(Math.random() * 0.3 + 0.05),
|
vy: -(Math.random() * 0.25 + 0.04),
|
||||||
baseSize: z * 2 + 0.6,
|
baseSize: z * 2.2 + 0.5,
|
||||||
color: palettes[Math.floor(Math.random() * palettes.length)],
|
color: palettes[Math.floor(Math.random() * palettes.length)],
|
||||||
twinkleSpeed: Math.random() * 0.025 + 0.006,
|
twinkleSpeed: Math.random() * 0.02 + 0.005,
|
||||||
twinklePhase: Math.random() * Math.PI * 2,
|
twinklePhase: Math.random() * Math.PI * 2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const createNebulae = useCallback((w: number, h: number) => {
|
||||||
|
if (prefersReducedMotion.current) return [];
|
||||||
|
const colors = [
|
||||||
|
"rgba(168,85,247,",
|
||||||
|
"rgba(59,130,246,",
|
||||||
|
"rgba(245,158,11,",
|
||||||
|
"rgba(236,72,153,",
|
||||||
|
"rgba(6,182,212,",
|
||||||
|
];
|
||||||
|
const out: Nebula[] = [];
|
||||||
|
for (let i = 0; i < NEBULA_COUNT; i++) {
|
||||||
|
out.push({
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: Math.random() * h * 0.8 + h * 0.1,
|
||||||
|
radius: Math.random() * 120 + 60,
|
||||||
|
color: colors[i % colors.length],
|
||||||
|
alpha: Math.random() * 0.03 + 0.015,
|
||||||
|
drift: (Math.random() - 0.5) * 0.15,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -85,6 +156,7 @@ export function HeroBanner({
|
|||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
if (particlesRef.current.length === 0) {
|
if (particlesRef.current.length === 0) {
|
||||||
particlesRef.current = createParticles(w, h);
|
particlesRef.current = createParticles(w, h);
|
||||||
|
nebulaeRef.current = createNebulae(w, h);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,6 +168,23 @@ export function HeroBanner({
|
|||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
tick++;
|
tick++;
|
||||||
|
|
||||||
|
for (const n of nebulaeRef.current) {
|
||||||
|
const pulse = Math.sin(tick * 0.008 + n.phase) * 0.5 + 0.5;
|
||||||
|
const a = n.alpha * (0.6 + pulse * 0.4);
|
||||||
|
n.x += n.drift;
|
||||||
|
if (n.x < -n.radius) n.x = w + n.radius;
|
||||||
|
if (n.x > w + n.radius) n.x = -n.radius;
|
||||||
|
|
||||||
|
const grad = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, n.radius);
|
||||||
|
grad.addColorStop(0, n.color + a.toFixed(4) + ")");
|
||||||
|
grad.addColorStop(0.5, n.color + (a * 0.4).toFixed(4) + ")");
|
||||||
|
grad.addColorStop(1, n.color + "0)");
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
for (const p of particlesRef.current) {
|
for (const p of particlesRef.current) {
|
||||||
p.x += p.vx;
|
p.x += p.vx;
|
||||||
p.y += p.vy;
|
p.y += p.vy;
|
||||||
@@ -108,17 +197,14 @@ export function HeroBanner({
|
|||||||
|
|
||||||
const twinkle =
|
const twinkle =
|
||||||
Math.sin(tick * p.twinkleSpeed + p.twinklePhase) * 0.5 + 0.5;
|
Math.sin(tick * p.twinkleSpeed + p.twinklePhase) * 0.5 + 0.5;
|
||||||
const alpha = (p.z * 0.45 + 0.1) * twinkle;
|
const alpha = (p.z * 0.5 + 0.12) * twinkle;
|
||||||
const glowR = p.baseSize * 5;
|
const glowR = p.baseSize * 6;
|
||||||
const grad = ctx.createRadialGradient(
|
const grad = ctx.createRadialGradient(
|
||||||
p.x,
|
p.x, p.y, 0,
|
||||||
p.y,
|
p.x, p.y, glowR
|
||||||
0,
|
|
||||||
p.x,
|
|
||||||
p.y,
|
|
||||||
glowR
|
|
||||||
);
|
);
|
||||||
grad.addColorStop(0, p.color + (alpha * 0.4).toFixed(3) + ")");
|
grad.addColorStop(0, p.color + (alpha * 0.5).toFixed(3) + ")");
|
||||||
|
grad.addColorStop(0.3, p.color + (alpha * 0.2).toFixed(3) + ")");
|
||||||
grad.addColorStop(1, p.color + "0)");
|
grad.addColorStop(1, p.color + "0)");
|
||||||
ctx.fillStyle = grad;
|
ctx.fillStyle = grad;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -139,20 +225,27 @@ export function HeroBanner({
|
|||||||
window.removeEventListener("resize", resize);
|
window.removeEventListener("resize", resize);
|
||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafRef.current);
|
||||||
};
|
};
|
||||||
}, [createParticles]);
|
}, [createParticles, createNebulae]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
setActive((prev) => (prev + 1) % slides.length);
|
setActive((cur) => {
|
||||||
|
const next = (cur + 1) % slides.length;
|
||||||
|
setPrev(cur);
|
||||||
|
setTransitioning(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setPrev(-1);
|
||||||
|
setTransitioning(false);
|
||||||
|
}, 1000);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, AUTO_PLAY_MS);
|
}, AUTO_PLAY_MS);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, [slides.length]);
|
||||||
|
|
||||||
const goTo = (i: number) => setActive(i);
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/software/latest?info=1");
|
const res = await fetch("/api/software/latest?info=1&track=1");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.available && data.downloadUrl) {
|
if (data.available && data.downloadUrl) {
|
||||||
window.location.href = data.downloadUrl;
|
window.location.href = data.downloadUrl;
|
||||||
@@ -164,20 +257,51 @@ export function HeroBanner({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onTouchStart = (e: React.TouchEvent) => {
|
||||||
|
touchStartX.current = e.touches[0].clientX;
|
||||||
|
};
|
||||||
|
const onTouchEnd = (e: React.TouchEvent) => {
|
||||||
|
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||||
|
if (Math.abs(dx) < SWIPE_THRESHOLD) return;
|
||||||
|
const next = dx < 0
|
||||||
|
? (active + 1) % slides.length
|
||||||
|
: (active - 1 + slides.length) % slides.length;
|
||||||
|
goTo(next);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hero-aurora-wrapper">
|
<div className="hero-aurora-wrapper">
|
||||||
<div className="hero-aurora hero-aurora--top" />
|
<div className="hero-aurora hero-aurora--top" />
|
||||||
|
|
||||||
<section
|
<section
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative overflow-hidden bg-[#0a0912]"
|
className="hero-banner relative overflow-hidden bg-[#0a0912]"
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
>
|
>
|
||||||
{/* Slide images */}
|
{/* Slide images with parallax + zoom transition */}
|
||||||
{slides.map((s, i) => (
|
{slides.map((s, i) => {
|
||||||
|
const isActive = i === active;
|
||||||
|
const isPrev = i === prev;
|
||||||
|
const isVisible = isActive || isPrev;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="absolute inset-0 transition-opacity duration-[1200ms] ease-in-out"
|
className="absolute inset-0"
|
||||||
style={{ opacity: i === active ? 1 : 0 }}
|
style={{
|
||||||
|
opacity: isActive ? 1 : isPrev ? 0 : 0,
|
||||||
|
transform: isActive
|
||||||
|
? "scale(1) translateX(0)"
|
||||||
|
: isPrev
|
||||||
|
? "scale(1.08) translateX(-3%)"
|
||||||
|
: "scale(1.05) translateX(3%)",
|
||||||
|
transition: isVisible
|
||||||
|
? "opacity 1s cubic-bezier(0.4, 0, 0.2, 1), transform 1.2s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||||
|
: "none",
|
||||||
|
zIndex: isActive ? 2 : isPrev ? 1 : 0,
|
||||||
|
willChange: isVisible ? "opacity, transform" : "auto",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={s.image}
|
src={s.image}
|
||||||
@@ -186,65 +310,104 @@ export function HeroBanner({
|
|||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Spacer to maintain 21:9 aspect ratio */}
|
{/* Mobile spacer: ~16:9 */}
|
||||||
<div className="pointer-events-none w-full" style={{ paddingBottom: "min(42.86%, 720px)" }} />
|
<div
|
||||||
|
className="pointer-events-none relative z-10 w-full sm:hidden"
|
||||||
{/* Bottom gradient for button readability */}
|
style={{ paddingBottom: "clamp(220px, 56.25%, 420px)" }}
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-[#0a0912] via-[#0a0912]/60 to-transparent" />
|
/>
|
||||||
|
{/* Desktop spacer: 21:9, max 720px */}
|
||||||
{/* Particle canvas */}
|
<div
|
||||||
<canvas
|
className="pointer-events-none relative z-10 hidden w-full sm:block"
|
||||||
ref={canvasRef}
|
style={{ paddingBottom: "min(42.86%, 720px)" }}
|
||||||
className="pointer-events-none absolute inset-0 z-10"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bottom content */}
|
{/* Multi-layer gradient overlays */}
|
||||||
<div className="absolute inset-x-0 bottom-0 z-20 flex flex-col items-center pb-6 sm:pb-8">
|
<div className="pointer-events-none absolute inset-0 z-[3] bg-gradient-to-t from-[#0a0912] via-[#0a0912]/40 to-transparent" />
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-[3] bg-gradient-to-r from-[#0a0912]/30 via-transparent to-[#0a0912]/30" />
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[3] h-44 bg-gradient-to-t from-[#0a0912] via-[#0a0912]/80 to-transparent sm:h-52" />
|
||||||
|
|
||||||
|
{/* Vignette effect */}
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-[3]" style={{
|
||||||
|
background: "radial-gradient(ellipse at center, transparent 50%, rgba(10,9,18,0.4) 100%)"
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Particle + nebula canvas */}
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="pointer-events-none absolute inset-0 z-[4]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scan line overlay for texture */}
|
||||||
|
<div className="hero-scanlines pointer-events-none absolute inset-0 z-[4]" />
|
||||||
|
|
||||||
|
{/* Bottom content area */}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 z-20 flex flex-col items-center px-4 pb-5 sm:pb-10">
|
||||||
|
{/* Tagline */}
|
||||||
|
<p className="hero-tagline mb-3 text-center text-xs font-medium tracking-widest text-amber-200/60 uppercase sm:mb-4 sm:text-sm">
|
||||||
|
Turtle WoW 一站式插件管理平台
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Download button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
className="group relative cursor-pointer overflow-hidden rounded-lg border border-amber-500/60 bg-gradient-to-b from-amber-900/80 to-amber-950/90 px-8 py-3.5 backdrop-blur transition-all duration-300 hover:border-amber-400/80 hover:shadow-[0_0_30px_rgba(255,191,0,0.3)] active:scale-[0.97]"
|
className="hero-download-btn group relative cursor-pointer overflow-hidden rounded-xl border border-amber-500/50 bg-gradient-to-b from-amber-800/70 via-amber-900/80 to-amber-950/90 px-6 py-3 shadow-[0_0_20px_rgba(245,158,11,0.15)] backdrop-blur-sm transition-all duration-400 hover:border-amber-400/80 hover:shadow-[0_0_40px_rgba(255,191,0,0.35)] active:scale-[0.97] sm:px-10 sm:py-4 sm:rounded-2xl"
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-amber-500/10 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
{/* Shimmer sweep */}
|
||||||
<span className="relative flex items-center gap-2.5 text-amber-100">
|
<div className="hero-shimmer absolute inset-0" />
|
||||||
<Download className="h-5 w-5" />
|
{/* Inner glow on hover */}
|
||||||
<span className="text-base font-semibold tracking-wide">
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-amber-500/8 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
||||||
|
|
||||||
|
<span className="relative flex items-center gap-2.5 sm:gap-3">
|
||||||
|
<Download className="h-4.5 w-4.5 transition-transform duration-300 group-hover:-translate-y-0.5 sm:h-5 sm:w-5" />
|
||||||
|
<span className="text-sm font-bold tracking-wide text-amber-50 sm:text-base">
|
||||||
下载 Nanami 启动器
|
下载 Nanami 启动器
|
||||||
</span>
|
</span>
|
||||||
{launcherVersion && (
|
{launcherVersion && (
|
||||||
<span className="rounded border border-amber-500/30 bg-amber-500/15 px-2 py-0.5 text-xs font-medium text-amber-300">
|
<span className="hidden rounded-md border border-amber-400/30 bg-amber-500/15 px-2 py-0.5 text-xs font-semibold text-amber-200 sm:inline-block">
|
||||||
v{launcherVersion}
|
v{launcherVersion}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<ChevronRight className="h-4 w-4 text-amber-300/60 transition-transform duration-300 group-hover:translate-x-0.5 sm:h-4.5 sm:w-4.5" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="mt-2.5 flex items-center gap-3 sm:mt-4">
|
||||||
{totalDownloads != null && totalDownloads > 0 && (
|
{totalDownloads != null && totalDownloads > 0 && (
|
||||||
<p className="mt-2.5 text-sm text-white/40">
|
<p className="flex items-center gap-1.5 text-xs text-white/35 sm:text-sm">
|
||||||
累计{" "}
|
<span className="inline-block h-1 w-1 rounded-full bg-green-400/60" />
|
||||||
<span className="font-semibold text-amber-300/70">
|
<span className="font-semibold text-amber-300/60 tabular-nums">
|
||||||
{totalDownloads.toLocaleString()}
|
{totalDownloads.toLocaleString()}
|
||||||
</span>{" "}
|
</span>
|
||||||
次下载
|
次下载
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex gap-2.5">
|
{/* Carousel indicators */}
|
||||||
|
<div className="mt-3 flex gap-2 sm:mt-4 sm:gap-2.5">
|
||||||
{slides.map((_, i) => (
|
{slides.map((_, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
aria-label={`前往第 ${i + 1} 张`}
|
aria-label={`前往第 ${i + 1} 张`}
|
||||||
onClick={() => goTo(i)}
|
onClick={() => goTo(i)}
|
||||||
className={`cursor-pointer h-1.5 rounded-full transition-all duration-500 ${
|
className={`hero-indicator cursor-pointer rounded-full transition-all duration-500 ${
|
||||||
i === active
|
i === active
|
||||||
? "w-8 bg-amber-400"
|
? "h-1.5 w-7 bg-amber-400 shadow-[0_0_8px_rgba(251,191,36,0.5)] sm:h-2 sm:w-8"
|
||||||
: "w-1.5 bg-white/30 hover:bg-white/60"
|
: "h-1.5 w-1.5 bg-white/25 hover:bg-white/50 sm:h-2 sm:w-2"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Top-left subtle glow */}
|
||||||
|
<div className="pointer-events-none absolute -left-20 -top-20 z-[3] h-80 w-80 rounded-full bg-purple-500/[0.04] blur-3xl" />
|
||||||
|
{/* Bottom-right subtle glow */}
|
||||||
|
<div className="pointer-events-none absolute -bottom-20 -right-20 z-[3] h-80 w-80 rounded-full bg-amber-500/[0.04] blur-3xl" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="hero-aurora hero-aurora--bottom" />
|
<div className="hero-aurora hero-aurora--bottom" />
|
||||||
|
|||||||
18
src/components/public/MarkdownContent.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypeHighlight from "rehype-highlight";
|
||||||
|
|
||||||
|
export function MarkdownContent({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<div className="prose-article">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeHighlight]}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { ThemeToggle } from "@/components/ThemeToggle";
|
|||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 border-b border-amber-900/20 bg-[#0a0912]/95 backdrop-blur supports-[backdrop-filter]:bg-[#0a0912]/80">
|
<header className="sticky top-0 z-50 border-b border-amber-900/20 bg-[#0a0912]/95 backdrop-blur supports-[backdrop-filter]:bg-[#0a0912]/80">
|
||||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4">
|
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between px-3 sm:h-14 sm:px-4">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="flex items-center gap-2 text-lg font-bold text-amber-100"
|
className="flex items-center gap-2 text-lg font-bold text-amber-100"
|
||||||
@@ -14,13 +14,25 @@ export function Navbar() {
|
|||||||
<span>Nanami</span>
|
<span>Nanami</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex items-center gap-4">
|
<nav className="flex items-center gap-2 sm:gap-4">
|
||||||
<Link
|
<Link
|
||||||
href="/addons"
|
href="/addons"
|
||||||
className="text-sm text-gray-400 transition-colors hover:text-amber-200"
|
className="text-xs text-gray-400 transition-colors hover:text-amber-200 sm:text-sm"
|
||||||
>
|
>
|
||||||
插件列表
|
插件列表
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/articles"
|
||||||
|
className="text-xs text-gray-400 transition-colors hover:text-amber-200 sm:text-sm"
|
||||||
|
>
|
||||||
|
公告
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/changelog"
|
||||||
|
className="text-xs text-gray-400 transition-colors hover:text-amber-200 sm:text-sm"
|
||||||
|
>
|
||||||
|
更新日志
|
||||||
|
</Link>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
24
src/components/public/PageTracker.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
export function PageTracker() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetch("/api/track", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: pathname,
|
||||||
|
referrer: document.referrer,
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
}).catch(() => {});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
32
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: SwitchPrimitive.Root.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
BIN
src/img/view_1.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
src/img/view_10.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
src/img/view_11.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
src/img/view_12.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
src/img/view_13.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
src/img/view_14.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
src/img/view_2.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
src/img/view_3.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
src/img/view_4.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
src/img/view_5.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
src/img/view_6.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
src/img/view_7.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
src/img/view_8.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
src/img/view_9.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |