Compare commits
2 Commits
9dc6c0dcce
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
557ecebee6 | ||
|
|
fa7aedb8e7 |
534
API.md
534
API.md
@@ -1,103 +1,193 @@
|
|||||||
# Nanami 启动器 API 文档
|
# Nanami Web · Public API
|
||||||
|
|
||||||
> Base URL: `https://nanami.rucky.cn`(或对应部署地址)
|
Bilingual (zh / en) public REST API for the Nanami Turtle WoW platform.
|
||||||
>
|
|
||||||
> 标注说明:🆕 新增 | ✏️ 有变动 | 无标注为原有不变
|
> 标注:🆕 新增 · ✏️ 有变动 · 🌐 支持多语言
|
||||||
|
|
||||||
|
- **Base URL(生产环境):** `https://nanami.rucky.cn`
|
||||||
|
- **Base URL(直连 IP):** `http://120.77.146.152:3000`
|
||||||
|
- **Content-Type:** `application/json`
|
||||||
|
- **认证:** 公共读接口无需认证;POST/PUT/DELETE 写接口需要管理员 session(不属于公共 API 范围)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 检查更新 ✏️
|
## 🎮 WoW 客户端版本
|
||||||
|
|
||||||
检查指定软件是否有新版本。
|
平台当前只对外提供 Turtle WoW **1.18** 版本。每个 `Release` / `SoftwareVersion` 仍保留 `wowVersion` 字段用于标识历史数据和数据库约束,但公共网页、后台、下载和更新接口都会固定使用 `1.18`。
|
||||||
|
|
||||||
|
### 版本解析
|
||||||
|
|
||||||
|
- `wow` / `wowVersion` 查询参数可以省略;即使传入其他历史值,也会回落到 `1.18`
|
||||||
|
- 浏览器 Cookie 不再参与 wow 版本选择
|
||||||
|
- 列表接口不再支持 `?wow=all` 返回所有历史版本
|
||||||
|
|
||||||
|
### 响应字段
|
||||||
|
|
||||||
|
- 单条对象上含 `wowVersion`,当前对外返回值为 `"1.18"`
|
||||||
|
- 列表型响应顶层含 `wowVersion` 字段,回显当前固定过滤值
|
||||||
|
|
||||||
|
### 启动器自更新建议
|
||||||
|
|
||||||
|
启动器调用 `/api/software/check-update` 时无需传 wow 频道;服务端始终返回 1.18 通道。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'https://nanami.rucky.cn/api/software/check-update?slug=nanami-launcher&versionCode=1010'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 国际化(i18n)
|
||||||
|
|
||||||
|
所有公共读接口都支持中英双语,通过查询参数选择语言。
|
||||||
|
|
||||||
|
### 语言协商
|
||||||
|
|
||||||
|
| 来源 | 示例 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 查询参数 `lang` | `?lang=en` 或 `?lang=zh` | 1(最高) |
|
||||||
|
| 查询参数 `locale` | `?locale=en` | 2(`lang` 别名) |
|
||||||
|
| `Accept-Language` 请求头 | `Accept-Language: en-US,en;q=0.9` | 3 |
|
||||||
|
| 默认 | — | `zh` |
|
||||||
|
|
||||||
|
合法值:`zh`、`en`。其他值会被忽略,回退到默认。
|
||||||
|
|
||||||
|
### 响应字段约定
|
||||||
|
|
||||||
|
每条可翻译的文本字段,会同时返回三个键:
|
||||||
|
|
||||||
|
| 键名 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| `name` / `summary` / `description` / `changelog` / `title` / `content` | 「规范字段」,按当前 lang 解析后的值 |
|
||||||
|
| `*Zh`(如 `nameZh`) | 原始中文 |
|
||||||
|
| `*En`(如 `nameEn`) | 原始英文 |
|
||||||
|
|
||||||
|
响应顶层附带 `lang` 字段,回显本次实际响应使用的语言,便于客户端确认协商结果。
|
||||||
|
|
||||||
|
### 回退规则
|
||||||
|
|
||||||
|
请求 `lang=en` 时若 `*En` 字段为空,则规范字段回退到中文(保证客户端永远拿到非空内容)。`*Zh` / `*En` 始终是数据库存储的原始值(其中之一可能为空)。
|
||||||
|
|
||||||
|
### 兼容性
|
||||||
|
|
||||||
|
- **未传 `lang` 的旧客户端不受影响**:`name`、`summary`、`changelog` 等字段仍是中文,行为与改造前一致
|
||||||
|
- 新增的 `*Zh` / `*En` 字段是增量字段,旧客户端可以安全忽略
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 检查更新 ✏️ 🌐 🎮
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/software/check-update?slug={slug}&versionCode={versionCode}
|
GET /api/software/check-update?slug={slug}&versionCode={n}&wow={wow}&lang={lang}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| slug | string | 是 | 软件标识,如 `nanami-launcher` 或 `nanami-launcher-patch` |
|
| slug | string | ✅ | 软件标识,例如 `nanami-launcher`、`nanami-launcher-patch` |
|
||||||
| versionCode | number | 否 | 当前客户端版本号(整数),不传默认为 0 |
|
| versionCode | number | ❌ | 当前客户端版本号(整数),缺省为 0 |
|
||||||
|
| wow | string | ❌ | 固定为 `1.18`;可省略 |
|
||||||
|
| lang | string | ❌ | `zh` / `en`,缺省 `zh` |
|
||||||
|
|
||||||
**响应示例:**
|
**响应示例(wow=1.18, lang=en):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"hasUpdate": true,
|
"hasUpdate": true,
|
||||||
"forceUpdate": false,
|
"forceUpdate": false,
|
||||||
|
"lang": "en",
|
||||||
|
"wowVersion": "1.18",
|
||||||
"latest": {
|
"latest": {
|
||||||
"version": "1.3.0",
|
"version": "1.0.15",
|
||||||
"versionCode": 130,
|
"versionCode": 1015,
|
||||||
"changelog": "- 修复xxx\n- 新增xxx",
|
"changelog": "Added Memorial Box feature\n…",
|
||||||
"downloadUrl": "https://nanami.rucky.cn/api/software/download/clxxx?source=launcher",
|
"changelogZh": "添加骨灰盒功能\n…",
|
||||||
"fileSize": 52428800,
|
"changelogEn": "Added Memorial Box feature\n…",
|
||||||
"minVersion": "1.0.0",
|
"downloadUrl": "https://nanami.rucky.cn/api/software/download/abc123?source=launcher",
|
||||||
"createdAt": "2026-03-25T10:00:00.000Z"
|
"fileSize": 0,
|
||||||
|
"minVersion": null,
|
||||||
|
"wowVersion": "1.18",
|
||||||
|
"createdAt": "2026-04-28T14:40:30.844Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**✏️ 变动说明**:返回的 `downloadUrl` 现在自动附带 `?source=launcher` 参数,启动器直接使用该 URL 下载即可,下载量会被单独统计为「客户端更新下载」,与网页端下载区分。启动器无需做任何额外处理。
|
`forceUpdate=true` 仅当存在更新且最新版被标记为强制更新。下载 URL 自动带 `source=launcher`,启动器更新下载会单独计数。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 下载文件 ✏️
|
## 2. 下载文件 ✏️
|
||||||
|
|
||||||
根据版本 ID 下载文件。通常不需要手动拼接,直接使用 check-update 返回的 `downloadUrl` 即可。
|
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/software/download/{versionId}?source={source}
|
GET /api/software/download/{versionId}?source={source}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| versionId | string | 是 | 路径参数,版本记录 ID |
|
| versionId | string | ✅ | 路径参数,版本记录 ID |
|
||||||
| source | string | 否 | 下载来源标识,传 `launcher` 表示客户端更新下载 |
|
| source | string | ❌ | 下载来源标记,`launcher` 表示客户端自更新 |
|
||||||
|
|
||||||
**响应**:
|
**响应:**
|
||||||
|
|
||||||
- 本地文件:直接返回二进制流(`Content-Type: application/octet-stream`)
|
- 本地文件:`Content-Type: application/octet-stream`,二进制流
|
||||||
- 外链文件:302 重定向到外部 URL
|
- 外链文件:HTTP 302 跳转
|
||||||
|
|
||||||
**✏️ 变动说明**:新增 `source` 查询参数。当 `source=launcher` 时,下载量会同时计入总下载量和启动器更新下载量两个计数器。check-update 返回的 URL 已自动包含此参数,启动器无需手动处理。
|
`source=launcher` 时,下载量计入「启动器更新下载」单独计数器。该参数已由 `check-update` 自动附加,启动器无需关心。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 获取最新版本信息(网页用)
|
## 3. 获取最新启动器(网页用) 🌐
|
||||||
|
|
||||||
获取 nanami-launcher 的最新版本信息或直接下载,主要给网页端使用。
|
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/software/latest?info=1&track=1
|
GET /api/software/latest?info=1&track=1&lang={lang}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| info | string | 否 | 传 `1` 返回 JSON 元数据;不传则直接下载文件 |
|
| info | string | ❌ | 传 `1` 返回 JSON 元数据;不传则直接返回二进制文件 |
|
||||||
| track | string | 否 | 仅在 `info=1` 时生效,传 `1` 同时计数下载量 |
|
| track | string | ❌ | 仅 `info=1` 时生效,传 `1` 同时累加下载计数 |
|
||||||
|
| lang | string | ❌ | `zh` / `en`,仅 `info=1` 时影响 changelog 字段 |
|
||||||
|
|
||||||
**响应示例(info=1):**
|
**响应示例(info=1, lang=en):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"available": true,
|
"available": true,
|
||||||
"version": "1.3.0",
|
"version": "1.0.15",
|
||||||
"versionCode": 130,
|
"versionCode": 1015,
|
||||||
"changelog": "...",
|
"changelog": "Added Memorial Box feature\n…",
|
||||||
"fileSize": 52428800,
|
"changelogZh": "添加骨灰盒功能\n…",
|
||||||
"createdAt": "2026-03-25T10:00:00.000Z",
|
"changelogEn": "Added Memorial Box feature\n…",
|
||||||
"downloadUrl": "/api/software/download/clxxx",
|
"fileSize": 0,
|
||||||
"downloadType": "local"
|
"createdAt": "2026-04-28T14:40:30.844Z",
|
||||||
|
"downloadUrl": "/api/software/download/abc123",
|
||||||
|
"downloadType": "url",
|
||||||
|
"lang": "en"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 此接口固定查询 slug 为 `nanami-launcher` 的软件,启动器通常使用 check-update 接口而非此接口。
|
---
|
||||||
|
|
||||||
|
## 4. 启动器友好直链 🆕 🎮
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /download/launcher?wow={wow}
|
||||||
|
```
|
||||||
|
|
||||||
|
外部网页可直接 `<a href>` 引用这个地址下载最新版启动器,等价于 `GET /api/software/latest`(无 `info=1`):本地文件直返,外链 302 跳转。
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| wow | string | ❌ | 固定为 `1.18`;可省略 |
|
||||||
|
|
||||||
|
⚠️ **第三方链接安全**:这条 URL 是公开嵌入用的,不读用户 cookie,始终下载 1.18 通道。
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href="https://nanami.rucky.cn/download/launcher">下载 Nanami 启动器(默认 WoW 1.18)</a>
|
||||||
|
<a href="https://nanami.rucky.cn/download/launcher?wow=1.18">下载 Nanami 启动器(WoW 1.18)</a>
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 上报心跳(在线状态) 🆕
|
## 5. 上报心跳(在线状态) 🆕
|
||||||
|
|
||||||
启动器定期调用此接口上报在线状态,建议每 **60 秒** 调用一次。超过 3 分钟无心跳视为离线。
|
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/launcher/heartbeat
|
POST /api/launcher/heartbeat
|
||||||
@@ -117,86 +207,338 @@ Content-Type: application/json
|
|||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| deviceId | string | **是** | 设备唯一标识,建议使用机器码或 UUID 持久化存储 |
|
| deviceId | string | ✅ | 设备唯一标识,建议机器码或 UUID 并持久化 |
|
||||||
| os | string | 否 | 操作系统名称,如 `Windows`、`macOS`、`Linux` |
|
| os | string | ❌ | 操作系统,例如 `Windows` |
|
||||||
| osVersion | string | 否 | 系统版本号,如 `10.0.19045`、`14.3` |
|
| osVersion | string | ❌ | 系统版本号 |
|
||||||
| appVersion | string | 否 | 启动器版本号,如 `1.3.0` |
|
| appVersion | string | ❌ | 启动器版本 |
|
||||||
|
|
||||||
**响应:**
|
**响应:** `{ "ok": true }`
|
||||||
|
|
||||||
```json
|
实现建议:启动时发送一次心跳,之后每 60 秒一次;超过 3 分钟未心跳视为离线。
|
||||||
{ "ok": true }
|
|
||||||
```
|
|
||||||
|
|
||||||
**实现建议**:
|
|
||||||
- 启动器启动时立即发送一次心跳,之后每 60 秒发送一次
|
|
||||||
- `deviceId` 需要在客户端持久化保存,确保同一台机器始终使用相同 ID
|
|
||||||
- IP 地址由服务端自动获取,无需客户端上报
|
|
||||||
- 网络失败时静默忽略即可,不影响启动器正常功能
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 历史更新日志 🆕
|
## 6. 历史更新日志 ✏️ 🌐 🎮
|
||||||
|
|
||||||
查询指定软件的所有版本历史和更新日志,按版本号倒序排列。
|
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/software/changelog?slug={slug}
|
GET /api/software/changelog?slug={slug}&wow={wow}&lang={lang}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| slug | string | 是 | 软件标识,如 `nanami-launcher` 或 `nanami-launcher-patch` |
|
| slug | string | ✅ | 软件标识 |
|
||||||
|
| wow | string | ❌ | 固定为 `1.18`;可省略 |
|
||||||
|
| lang | string | ❌ | `zh` / `en` |
|
||||||
|
|
||||||
**响应示例:**
|
**响应示例(wow=1.18, lang=en):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "Nanami 启动器(全量包)",
|
"name": "Nanami Launcher",
|
||||||
|
"nameZh": "Nanami 启动器",
|
||||||
|
"nameEn": "Nanami Launcher",
|
||||||
"slug": "nanami-launcher",
|
"slug": "nanami-launcher",
|
||||||
|
"lang": "en",
|
||||||
|
"wowVersion": "1.18",
|
||||||
"versions": [
|
"versions": [
|
||||||
{
|
{
|
||||||
"version": "1.3.0",
|
"version": "1.0.15",
|
||||||
"versionCode": 130,
|
"versionCode": 1015,
|
||||||
"changelog": "- 新增xxx\n- 修复xxx",
|
"changelog": "Added Memorial Box feature\n…",
|
||||||
"fileSize": 52428800,
|
"changelogZh": "添加骨灰盒功能\n…",
|
||||||
|
"changelogEn": "Added Memorial Box feature\n…",
|
||||||
|
"fileSize": 0,
|
||||||
"isLatest": true,
|
"isLatest": true,
|
||||||
"forceUpdate": false,
|
"forceUpdate": false,
|
||||||
"createdAt": "2026-03-25T10:00:00.000Z"
|
"wowVersion": "1.18",
|
||||||
},
|
"createdAt": "2026-04-28T14:40:30.844Z"
|
||||||
{
|
|
||||||
"version": "1.2.0",
|
|
||||||
"versionCode": 120,
|
|
||||||
"changelog": "- 优化xxx",
|
|
||||||
"fileSize": 50331648,
|
|
||||||
"isLatest": false,
|
|
||||||
"forceUpdate": false,
|
|
||||||
"createdAt": "2026-03-10T08:00:00.000Z"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应字段说明:**
|
---
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
## 7. 软件列表 / 详情 🆕 🌐
|
||||||
|------|------|------|
|
|
||||||
| versions[].version | string | 版本号 |
|
### `GET /api/software?lang={lang}`
|
||||||
| versions[].versionCode | number | 版本号(整数),用于比较大小 |
|
|
||||||
| versions[].changelog | string | 更新日志内容 |
|
返回全部软件包及其当前最新版本。
|
||||||
| versions[].fileSize | number | 文件大小(字节) |
|
|
||||||
| versions[].isLatest | boolean | 是否为当前最新版本 |
|
**响应示例:**
|
||||||
| versions[].forceUpdate | boolean | 是否为强制更新版本 |
|
|
||||||
| versions[].createdAt | string | 发布时间(ISO 8601) |
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"slug": "nanami-launcher",
|
||||||
|
"name": "Nanami Launcher",
|
||||||
|
"nameZh": "Nanami 启动器",
|
||||||
|
"nameEn": "Nanami Launcher",
|
||||||
|
"description": "Nanami addon launcher…",
|
||||||
|
"descriptionZh": "Nanami 插件启动器…",
|
||||||
|
"descriptionEn": "Nanami addon launcher…",
|
||||||
|
"createdAt": "...",
|
||||||
|
"updatedAt": "...",
|
||||||
|
"versions": [{ "version": "1.0.15", "isLatest": true, "...": "..." }],
|
||||||
|
"_count": { "versions": 48 },
|
||||||
|
"lang": "en"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/software/{idOrSlug}?lang={lang}`
|
||||||
|
|
||||||
|
返回单个软件包及其全部历史版本(versions 按 versionCode 倒序)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 插件列表 / 详情 🆕 🌐 🎮
|
||||||
|
|
||||||
|
### `GET /api/addons`
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| lang | string | ❌ | `zh` / `en` |
|
||||||
|
| wow | string | ❌ | 固定为 `1.18`;可省略 |
|
||||||
|
| published | string | ❌ | 默认 `true`;传 `false` 包括草稿 |
|
||||||
|
| category | string | ❌ | 按分类过滤 |
|
||||||
|
| search | string | ❌ | 名称 / 简介模糊匹配,中英都搜 |
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"slug": "nanami-ui",
|
||||||
|
"name": "Nanami-UI",
|
||||||
|
"nameZh": "Nanami-UI",
|
||||||
|
"nameEn": "Nanami-UI",
|
||||||
|
"summary": "All-in-one UI replacement…",
|
||||||
|
"summaryZh": "一款为乌龟服精心打造的全功能界面…",
|
||||||
|
"summaryEn": "All-in-one UI replacement…",
|
||||||
|
"description": "# Nanami-UI\n…",
|
||||||
|
"descriptionZh": "# Nanami-UI\n…",
|
||||||
|
"descriptionEn": "# Nanami-UI\n…",
|
||||||
|
"iconUrl": "/uploads/...",
|
||||||
|
"category": "ui",
|
||||||
|
"published": true,
|
||||||
|
"totalDownloads": 1234,
|
||||||
|
"releases": [
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"version": "0.9.14",
|
||||||
|
"changelog": "See the launcher changelog for details.",
|
||||||
|
"changelogZh": "详见启动器更新日志",
|
||||||
|
"changelogEn": "See the launcher changelog for details.",
|
||||||
|
"downloadType": "local",
|
||||||
|
"filePath": "/uploads/...",
|
||||||
|
"isLatest": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [],
|
||||||
|
"_count": { "releases": 5 },
|
||||||
|
"lang": "en"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/addons/{idOrSlug}?lang={lang}`
|
||||||
|
|
||||||
|
返回单个插件及其所有 releases(按 createdAt 倒序)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 插件版本(Releases) 🆕 🌐 🎮
|
||||||
|
|
||||||
|
### `GET /api/releases?addonId={id}&wow={wow}&lang={lang}`
|
||||||
|
|
||||||
|
列出 release。
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| addonId | string | ❌ | 限定某个插件;缺省返回全平台所有插件的 release |
|
||||||
|
| wow | string | ❌ | 固定为 `1.18`;可省略 |
|
||||||
|
| lang | string | ❌ | `zh` / `en` |
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"version": "1.2.5",
|
||||||
|
"changelog": "2026.04.03 update:\n…",
|
||||||
|
"changelogZh": "2026.04.03更新:\n…",
|
||||||
|
"changelogEn": "2026.04.03 update:\n…",
|
||||||
|
"downloadType": "local",
|
||||||
|
"filePath": "/uploads/...",
|
||||||
|
"externalUrl": null,
|
||||||
|
"gameVersion": "1.18.1",
|
||||||
|
"downloadCount": 0,
|
||||||
|
"isLatest": true,
|
||||||
|
"createdAt": "...",
|
||||||
|
"addon": {
|
||||||
|
"id": "...",
|
||||||
|
"slug": "instancejournal",
|
||||||
|
"name": "InstanceJournal",
|
||||||
|
"nameZh": "InstanceJournal",
|
||||||
|
"nameEn": "InstanceJournal"
|
||||||
|
},
|
||||||
|
"lang": "en"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/download/{releaseId}`
|
||||||
|
|
||||||
|
直接下载 release 文件(本地文件返二进制,外链 302 跳转),同时累加 release 下载量。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 公告文章 🆕 🌐
|
||||||
|
|
||||||
|
### `GET /api/articles`
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| lang | string | ❌ | `zh` / `en` |
|
||||||
|
| published | string | ❌ | 默认 `true` |
|
||||||
|
| limit | number | ❌ | 限制条数 |
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"slug": "shutdown-announcement",
|
||||||
|
"title": "Server shutdown",
|
||||||
|
"titleZh": "关服公告",
|
||||||
|
"titleEn": "Server shutdown",
|
||||||
|
"summary": "...",
|
||||||
|
"summaryZh": "...",
|
||||||
|
"summaryEn": "...",
|
||||||
|
"content": "Markdown content…",
|
||||||
|
"contentZh": "Markdown 内容…",
|
||||||
|
"contentEn": "Markdown content…",
|
||||||
|
"coverImage": "/uploads/…",
|
||||||
|
"published": true,
|
||||||
|
"createdAt": "...",
|
||||||
|
"updatedAt": "...",
|
||||||
|
"lang": "en"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/articles/{idOrSlug}?lang={lang}`
|
||||||
|
|
||||||
|
返回单篇文章。字段同上。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 站点资源 🌐
|
||||||
|
|
||||||
|
### `GET /api/banners?enabled=1`
|
||||||
|
|
||||||
|
首页 Hero Banner 图。无可翻译文本,`lang` 参数不影响响应。
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{ "id": "...", "imageUrl": "/uploads/...", "sortOrder": 0, "enabled": true }]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/gallery?enabled=1&lang={lang}`
|
||||||
|
|
||||||
|
截图画廊。`title` 字段双语。
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"imageUrl": "/uploads/...",
|
||||||
|
"title": "Nameplate showcase",
|
||||||
|
"titleZh": "姓名版展示",
|
||||||
|
"titleEn": "Nameplate showcase",
|
||||||
|
"sortOrder": 0,
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 服务器时间
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/server-time
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "serverTime": "2026-04-29T08:00:00.000Z", "epochMs": 1798156800000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
供客户端校时与首页倒计时使用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
所有失败响应都返回统一结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "Slug already exists" }
|
||||||
|
```
|
||||||
|
|
||||||
|
| HTTP | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| 400 | 参数缺失或格式错误 |
|
||||||
|
| 401 | 未授权(写接口) |
|
||||||
|
| 404 | 资源不存在 |
|
||||||
|
| 409 | 冲突(如 slug 重复) |
|
||||||
|
| 500 | 服务端错误 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 接口一览
|
## 接口一览
|
||||||
|
|
||||||
| 方法 | 路径 | 状态 | 用途 |
|
| 方法 | 路径 | 状态 | 多语言 | WoW 过滤 | 用途 |
|
||||||
|------|------|------|------|
|
|------|------|------|--------|----------|------|
|
||||||
| GET | `/api/software/check-update` | ✏️ 变动 | 检查更新,downloadUrl 已含 source 标记 |
|
| GET | `/api/software/check-update` | ✏️ | ✅ | ✅ | 启动器自更新检查 |
|
||||||
| GET | `/api/software/download/{id}` | ✏️ 变动 | 下载文件,支持 source 参数区分来源 |
|
| GET | `/api/software/download/{id}` | ✏️ | ❌ | ❌ | 下载启动器文件(按 versionId 精确) |
|
||||||
| GET | `/api/software/latest` | 无变动 | 获取最新版信息/下载(网页用) |
|
| GET | `/api/software/latest` | ✏️ | ✅ | ✅ | 最新启动器信息 / 下载(网页用) |
|
||||||
| GET | `/api/software/changelog` | 🆕 新增 | 查询历史更新日志 |
|
| GET | `/download/launcher` | 🆕 | ❌ | ✅ | 友好直链:始终下载 1.18 最新 |
|
||||||
| POST | `/api/launcher/heartbeat` | 🆕 新增 | 上报在线状态 |
|
| POST | `/api/launcher/heartbeat` | — | ❌ | ❌ | 上报启动器在线状态 |
|
||||||
|
| GET | `/api/software/changelog` | ✏️ | ✅ | ✅ | 启动器历史 changelog |
|
||||||
|
| GET | `/api/software` | 🆕 | ✅ | ✅ | 软件列表 |
|
||||||
|
| GET | `/api/software/{idOrSlug}` | 🆕 | ✅ | ✅ | 单个软件 + 全部版本 |
|
||||||
|
| GET | `/api/addons` | 🆕 | ✅ | ✅ | 插件列表 |
|
||||||
|
| GET | `/api/addons/{idOrSlug}` | 🆕 | ✅ | ✅ | 单个插件 + 所有 release |
|
||||||
|
| GET | `/api/releases` | 🆕 | ✅ | ✅ | 插件版本列表 |
|
||||||
|
| GET | `/api/download/{id}` | — | ❌ | ❌ | 下载插件 release 文件(按 releaseId) |
|
||||||
|
| GET | `/api/articles` | 🆕 | ✅ | ❌ | 公告列表 |
|
||||||
|
| GET | `/api/articles/{idOrSlug}` | 🆕 | ✅ | ❌ | 单篇公告 |
|
||||||
|
| GET | `/api/banners` | 🆕 | ❌ | ❌ | Hero Banner 图 |
|
||||||
|
| GET | `/api/gallery` | 🆕 | ✅ | ❌ | 截图画廊 |
|
||||||
|
| GET | `/api/server-time` | — | — | ❌ | 服务器时间 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 调用示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1.18 启动器最新版(英文 changelog)
|
||||||
|
curl 'https://nanami.rucky.cn/api/software/latest?info=1&lang=en&wow=1.18'
|
||||||
|
|
||||||
|
# 1.18 启动器全部历史 changelog(中文)
|
||||||
|
curl 'https://nanami.rucky.cn/api/software/changelog?slug=nanami-launcher&wow=1.18&lang=zh'
|
||||||
|
|
||||||
|
# 1.18 客户端的 UI 类插件(英文)
|
||||||
|
curl 'https://nanami.rucky.cn/api/addons?lang=en&category=ui'
|
||||||
|
|
||||||
|
# 1.18 启动器友好直链
|
||||||
|
curl -L -o nanami-launcher-1.18.exe 'https://nanami.rucky.cn/download/launcher?wow=1.18'
|
||||||
|
|
||||||
|
# 通过 Accept-Language 协商语言
|
||||||
|
curl -H 'Accept-Language: en-US,en;q=0.9' 'https://nanami.rucky.cn/api/articles'
|
||||||
|
```
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ echo "=> [6/6] 服务器安装生产依赖 & 同步数据库 & 重启..."
|
|||||||
run_ssh "cd ${REMOTE_DIR} && \
|
run_ssh "cd ${REMOTE_DIR} && \
|
||||||
npm install --omit=dev --registry=https://registry.npmjs.org && \
|
npm install --omit=dev --registry=https://registry.npmjs.org && \
|
||||||
npx prisma generate && \
|
npx prisma generate && \
|
||||||
npx prisma db push && \
|
npx prisma db push --accept-data-loss && \
|
||||||
mkdir -p uploads && \
|
mkdir -p uploads && \
|
||||||
ln -sf ${REMOTE_DIR}/uploads ${REMOTE_DIR}/.next/standalone/uploads && \
|
ln -sf ${REMOTE_DIR}/uploads ${REMOTE_DIR}/.next/standalone/uploads && \
|
||||||
ln -sf ${REMOTE_DIR}/.env ${REMOTE_DIR}/.next/standalone/.env && \
|
ln -sf ${REMOTE_DIR}/.env ${REMOTE_DIR}/.next/standalone/.env && \
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ model Admin {
|
|||||||
model Addon {
|
model Addon {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
nameEn String @default("")
|
||||||
slug String @unique
|
slug String @unique
|
||||||
summary String
|
summary String
|
||||||
|
summaryEn String @default("")
|
||||||
description String @db.Text
|
description String @db.Text
|
||||||
|
descriptionEn String @default("") @db.Text
|
||||||
iconUrl String?
|
iconUrl String?
|
||||||
category String @default("general")
|
category String @default("general")
|
||||||
published Boolean @default(false)
|
published Boolean @default(false)
|
||||||
@@ -38,16 +41,20 @@ model Release {
|
|||||||
addonId String
|
addonId String
|
||||||
version String
|
version String
|
||||||
changelog String @db.Text
|
changelog String @db.Text
|
||||||
|
changelogEn String @default("") @db.Text
|
||||||
downloadType String @default("local")
|
downloadType String @default("local")
|
||||||
filePath String?
|
filePath String?
|
||||||
externalUrl String?
|
externalUrl String?
|
||||||
gameVersion String @default("")
|
gameVersion String @default("")
|
||||||
|
// Turtle WoW client major version this build targets. Only "1.18" is active.
|
||||||
|
wowVersion String @default("1.18")
|
||||||
downloadCount Int @default(0)
|
downloadCount Int @default(0)
|
||||||
isLatest Boolean @default(false)
|
isLatest Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
addon Addon @relation(fields: [addonId], references: [id], onDelete: Cascade)
|
addon Addon @relation(fields: [addonId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([addonId])
|
@@index([addonId])
|
||||||
|
@@index([addonId, wowVersion, isLatest])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Screenshot {
|
model Screenshot {
|
||||||
@@ -63,8 +70,10 @@ model Screenshot {
|
|||||||
model Software {
|
model Software {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
nameEn String @default("")
|
||||||
slug String @unique
|
slug String @unique
|
||||||
description String @default("")
|
description String @default("")
|
||||||
|
descriptionEn String @default("")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
versions SoftwareVersion[]
|
versions SoftwareVersion[]
|
||||||
@@ -76,6 +85,7 @@ model SoftwareVersion {
|
|||||||
version String
|
version String
|
||||||
versionCode Int
|
versionCode Int
|
||||||
changelog String @db.Text
|
changelog String @db.Text
|
||||||
|
changelogEn String @default("") @db.Text
|
||||||
downloadType String @default("local")
|
downloadType String @default("local")
|
||||||
filePath String?
|
filePath String?
|
||||||
externalUrl String?
|
externalUrl String?
|
||||||
@@ -85,11 +95,14 @@ model SoftwareVersion {
|
|||||||
isLatest Boolean @default(false)
|
isLatest Boolean @default(false)
|
||||||
forceUpdate Boolean @default(false)
|
forceUpdate Boolean @default(false)
|
||||||
minVersion String?
|
minVersion String?
|
||||||
|
// Turtle WoW client major version this build targets. Only "1.18" is active.
|
||||||
|
wowVersion String @default("1.18")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
software Software @relation(fields: [softwareId], references: [id], onDelete: Cascade)
|
software Software @relation(fields: [softwareId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([softwareId, version])
|
@@unique([softwareId, version, wowVersion])
|
||||||
@@index([softwareId])
|
@@index([softwareId])
|
||||||
|
@@index([softwareId, wowVersion, isLatest])
|
||||||
}
|
}
|
||||||
|
|
||||||
model BannerImage {
|
model BannerImage {
|
||||||
@@ -104,6 +117,7 @@ model GalleryImage {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
imageUrl String
|
imageUrl String
|
||||||
title String @default("")
|
title String @default("")
|
||||||
|
titleEn String @default("")
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
enabled Boolean @default(true)
|
enabled Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -112,9 +126,12 @@ model GalleryImage {
|
|||||||
model Article {
|
model Article {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
|
titleEn String @default("")
|
||||||
slug String @unique
|
slug String @unique
|
||||||
summary String @default("")
|
summary String @default("")
|
||||||
|
summaryEn String @default("")
|
||||||
content String @db.Text
|
content String @db.Text
|
||||||
|
contentEn String @default("") @db.Text
|
||||||
coverImage String?
|
coverImage String?
|
||||||
published Boolean @default(false)
|
published Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -148,3 +165,18 @@ model LauncherOnline {
|
|||||||
|
|
||||||
@@index([lastSeen])
|
@@index([lastSeen])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SiteSetting {
|
||||||
|
id Int @id @default(1)
|
||||||
|
grayscale Boolean @default(false)
|
||||||
|
shutdownBannerEnabled Boolean @default(false)
|
||||||
|
shutdownTitle String @default("")
|
||||||
|
shutdownTitleEn String @default("")
|
||||||
|
shutdownSubtitle String @default("")
|
||||||
|
shutdownSubtitleEn String @default("")
|
||||||
|
shutdownAt DateTime?
|
||||||
|
bgmUrl String @default("")
|
||||||
|
bgmAutoplay Boolean @default(false)
|
||||||
|
bgmVolume Int @default(50)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { Download, Package, Calendar, Tag, ArrowLeft } from "lucide-react";
|
import { AddonDetail } from "@/components/public/AddonDetail";
|
||||||
import { DownloadButton } from "@/components/public/DownloadButton";
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
import { MarkdownContent } from "@/components/public/MarkdownContent";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -33,177 +30,50 @@ export default async function AddonDetailPage({
|
|||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
const wowVersion = DEFAULT_WOW_VERSION;
|
||||||
const addon = await prisma.addon.findUnique({
|
const addon = await prisma.addon.findUnique({
|
||||||
where: { slug },
|
where: { slug },
|
||||||
include: {
|
include: {
|
||||||
releases: { orderBy: { createdAt: "desc" } },
|
releases: {
|
||||||
|
where: { wowVersion },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
},
|
||||||
screenshots: { orderBy: { sortOrder: "asc" } },
|
screenshots: { orderBy: { sortOrder: "asc" } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!addon || !addon.published) notFound();
|
if (!addon || !addon.published) notFound();
|
||||||
|
|
||||||
const latestRelease = addon.releases.find((r) => r.isLatest);
|
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = {
|
|
||||||
general: "通用", gameplay: "游戏玩法", ui: "界面增强",
|
|
||||||
combat: "战斗", raid: "团队副本", pvp: "PvP",
|
|
||||||
tradeskill: "专业技能", utility: "实用工具",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
|
<AddonDetail
|
||||||
<Link
|
addon={{
|
||||||
href="/addons"
|
slug: addon.slug,
|
||||||
className="mb-6 inline-flex items-center gap-1.5 text-sm text-gray-400 transition-colors hover:text-amber-200"
|
name: addon.name,
|
||||||
>
|
nameEn: addon.nameEn,
|
||||||
<ArrowLeft className="h-3.5 w-3.5" />
|
summary: addon.summary,
|
||||||
返回插件列表
|
summaryEn: addon.summaryEn,
|
||||||
</Link>
|
description: addon.description,
|
||||||
|
descriptionEn: addon.descriptionEn,
|
||||||
{/* Header */}
|
iconUrl: addon.iconUrl,
|
||||||
<div className="flex flex-col gap-5 rounded-xl border border-amber-500/10 bg-white/[0.03] p-5 sm:flex-row sm:items-start sm:p-6">
|
category: addon.category,
|
||||||
{addon.iconUrl ? (
|
totalDownloads: addon.totalDownloads,
|
||||||
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl ring-1 ring-amber-500/20 sm:h-20 sm:w-20">
|
wowVersion,
|
||||||
<Image
|
releases: addon.releases.map((r) => ({
|
||||||
src={addon.iconUrl}
|
id: r.id,
|
||||||
alt={addon.name}
|
version: r.version,
|
||||||
fill
|
changelog: r.changelog,
|
||||||
className="object-cover"
|
changelogEn: r.changelogEn,
|
||||||
sizes="80px"
|
gameVersion: r.gameVersion,
|
||||||
|
wowVersion: r.wowVersion,
|
||||||
|
downloadCount: r.downloadCount,
|
||||||
|
isLatest: r.isLatest,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
screenshots: addon.screenshots.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
imageUrl: s.imageUrl,
|
||||||
|
})),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 ring-1 ring-amber-500/20 sm:h-20 sm:w-20">
|
|
||||||
<Package className="h-8 w-8 text-amber-400 sm:h-10 sm:w-10" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
|
||||||
<h1 className="text-2xl font-bold text-amber-100 sm:text-3xl">
|
|
||||||
{addon.name}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-gray-400 sm:text-base">
|
|
||||||
{addon.summary}
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
|
||||||
<span className="rounded-md bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-300/80">
|
|
||||||
{categoryLabels[addon.category] || addon.category}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1 text-sm text-gray-500">
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
{addon.totalDownloads.toLocaleString()} 次下载
|
|
||||||
</span>
|
|
||||||
{latestRelease && (
|
|
||||||
<span className="flex items-center gap-1 text-sm text-gray-500">
|
|
||||||
<Tag className="h-3.5 w-3.5" />
|
|
||||||
v{latestRelease.version}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{latestRelease && (
|
|
||||||
<div className="shrink-0">
|
|
||||||
<DownloadButton
|
|
||||||
releaseId={latestRelease.id}
|
|
||||||
version={latestRelease.version}
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 border-t border-amber-500/10" />
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-6 lg:grid-cols-3 sm:mt-8">
|
|
||||||
{/* Description */}
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
|
||||||
<div className="rounded-xl border border-amber-500/10 bg-white/[0.03] p-5 sm:p-6">
|
|
||||||
<h2 className="mb-4 text-lg font-semibold text-amber-100">介绍</h2>
|
|
||||||
<MarkdownContent content={addon.description} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Screenshots */}
|
|
||||||
{addon.screenshots.length > 0 && (
|
|
||||||
<div className="rounded-xl border border-amber-500/10 bg-white/[0.03] p-5 sm:p-6">
|
|
||||||
<h2 className="mb-4 text-lg font-semibold text-amber-100">截图</h2>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{addon.screenshots.map((ss) => (
|
|
||||||
<div key={ss.id} className="overflow-hidden rounded-lg ring-1 ring-amber-500/10">
|
|
||||||
<Image
|
|
||||||
src={ss.imageUrl}
|
|
||||||
alt="Screenshot"
|
|
||||||
width={600}
|
|
||||||
height={340}
|
|
||||||
className="w-full object-cover transition-transform duration-300 hover:scale-105"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar - Releases */}
|
|
||||||
<div>
|
|
||||||
<div className="rounded-xl border border-amber-500/10 bg-white/[0.03] p-5 sm:p-6">
|
|
||||||
<h2 className="mb-1 text-lg font-semibold text-amber-100">
|
|
||||||
版本历史
|
|
||||||
</h2>
|
|
||||||
<p className="mb-4 text-sm text-gray-500">
|
|
||||||
共 {addon.releases.length} 个版本
|
|
||||||
</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{addon.releases.map((release) => (
|
|
||||||
<div
|
|
||||||
key={release.id}
|
|
||||||
className="rounded-lg border border-amber-500/10 bg-white/[0.02] p-4 transition-colors hover:border-amber-500/20 hover:bg-white/[0.04]"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-semibold text-amber-100">
|
|
||||||
v{release.version}
|
|
||||||
</span>
|
|
||||||
{release.isLatest && (
|
|
||||||
<span className="rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-300">
|
|
||||||
最新
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DownloadButton
|
|
||||||
releaseId={release.id}
|
|
||||||
version={release.version}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{release.gameVersion && (
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
WoW {release.gameVersion}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="mt-2 flex items-center gap-3 text-xs text-gray-500">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Calendar className="h-3 w-3" />
|
|
||||||
{new Date(release.createdAt).toLocaleDateString("zh-CN")}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Download className="h-3 w-3" />
|
|
||||||
{release.downloadCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{release.changelog && (
|
|
||||||
<p className="mt-2 whitespace-pre-line text-sm text-gray-400">
|
|
||||||
{release.changelog}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{addon.releases.length === 0 && (
|
|
||||||
<p className="text-sm text-gray-500">暂无版本发布</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { AddonCard } from "@/components/public/AddonCard";
|
import { AddonCard } from "@/components/public/AddonCard";
|
||||||
|
import { AddonsCategoryFilter } from "@/components/public/AddonsCategoryFilter";
|
||||||
|
import { T } from "@/components/public/T";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Package, Search, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Package, Search, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = {
|
|
||||||
general: "通用",
|
|
||||||
gameplay: "游戏玩法",
|
|
||||||
ui: "界面增强",
|
|
||||||
combat: "战斗",
|
|
||||||
raid: "团队副本",
|
|
||||||
pvp: "PvP",
|
|
||||||
tradeskill: "专业技能",
|
|
||||||
utility: "实用工具",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "插件列表 - Nanami",
|
title: "插件列表 - Nanami",
|
||||||
description: "浏览和下载 Turtle WoW 插件",
|
description: "浏览和下载 Turtle WoW 插件",
|
||||||
@@ -32,13 +24,16 @@ export default async function AddonsPage({
|
|||||||
}) {
|
}) {
|
||||||
const { category, search, page: pageStr } = await searchParams;
|
const { category, search, page: pageStr } = await searchParams;
|
||||||
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
|
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
|
||||||
|
const wowVersion = DEFAULT_WOW_VERSION;
|
||||||
|
|
||||||
const where: Record<string, unknown> = { published: true };
|
const where: Record<string, unknown> = { published: true };
|
||||||
if (category) where.category = category;
|
if (category) where.category = category;
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ name: { contains: search, mode: "insensitive" } },
|
{ name: { contains: search, mode: "insensitive" } },
|
||||||
|
{ nameEn: { contains: search, mode: "insensitive" } },
|
||||||
{ summary: { contains: search, mode: "insensitive" } },
|
{ summary: { contains: search, mode: "insensitive" } },
|
||||||
|
{ summaryEn: { contains: search, mode: "insensitive" } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,8 +42,12 @@ export default async function AddonsPage({
|
|||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
releases: {
|
releases: {
|
||||||
where: { isLatest: true },
|
where: { isLatest: true, wowVersion },
|
||||||
select: { version: true },
|
select: { version: true, wowVersion: true },
|
||||||
|
},
|
||||||
|
screenshots: {
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
take: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { totalDownloads: "desc" },
|
orderBy: { totalDownloads: "desc" },
|
||||||
@@ -79,39 +78,21 @@ export default async function AddonsPage({
|
|||||||
<div className="mb-2 flex items-center gap-2 sm:gap-3">
|
<div className="mb-2 flex items-center gap-2 sm:gap-3">
|
||||||
<Package className="h-5 w-5 text-amber-400 sm:h-6 sm:w-6" />
|
<Package className="h-5 w-5 text-amber-400 sm:h-6 sm:w-6" />
|
||||||
<h1 className="text-2xl font-bold text-amber-100 sm:text-3xl">
|
<h1 className="text-2xl font-bold text-amber-100 sm:text-3xl">
|
||||||
插件列表
|
<T section="addons" k="title" />
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-6 text-sm text-gray-400 sm:mb-10 sm:text-base">
|
<p className="mb-6 text-sm text-gray-400 sm:mb-10 sm:text-base">
|
||||||
浏览和下载 World of Warcraft 插件
|
<T section="addons" k="subtitle" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Category Filter */}
|
{/* Category Filter */}
|
||||||
<div className="mb-6 flex flex-wrap gap-2 sm:mb-8">
|
<AddonsCategoryFilter
|
||||||
<Link
|
active={category ?? null}
|
||||||
href="/addons"
|
categories={categories.map((c) => ({
|
||||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all sm:text-sm ${
|
slug: c.category,
|
||||||
!category
|
count: c._count.id,
|
||||||
? "bg-amber-500/20 text-amber-200 ring-1 ring-amber-500/30"
|
}))}
|
||||||
: "bg-white/[0.04] text-gray-400 hover:bg-white/[0.08] hover:text-gray-300"
|
/>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
全部
|
|
||||||
</Link>
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<Link
|
|
||||||
key={cat.category}
|
|
||||||
href={`/addons?category=${cat.category}`}
|
|
||||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all sm:text-sm ${
|
|
||||||
category === cat.category
|
|
||||||
? "bg-amber-500/20 text-amber-200 ring-1 ring-amber-500/30"
|
|
||||||
: "bg-white/[0.04] text-gray-400 hover:bg-white/[0.08] hover:text-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{categoryLabels[cat.category] || cat.category} ({cat._count.id})
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Addon Grid */}
|
{/* Addon Grid */}
|
||||||
{addons.length > 0 ? (
|
{addons.length > 0 ? (
|
||||||
@@ -168,18 +149,32 @@ export default async function AddonsPage({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<AddonsEmpty search={search ?? null} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddonsEmpty({ search }: { search: string | null }) {
|
||||||
|
return (
|
||||||
<div className="flex flex-col items-center py-20">
|
<div className="flex flex-col items-center py-20">
|
||||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-500/10">
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-500/10">
|
||||||
<Search className="h-7 w-7 text-amber-400/60" />
|
<Search className="h-7 w-7 text-amber-400/60" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-medium text-gray-400">
|
<p className="text-lg font-medium text-gray-400">
|
||||||
{search ? `没有找到"${search}"相关的插件` : "暂无插件"}
|
{search ? (
|
||||||
|
<T section="addons" k="notFoundTitle" vars={{ q: search }} />
|
||||||
|
) : (
|
||||||
|
<T section="addons" k="emptyTitle" />
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
{search ? "尝试更换关键词搜索" : "稍后再来查看吧"}
|
{search ? (
|
||||||
|
<T section="addons" k="notFoundSubtitle" />
|
||||||
|
) : (
|
||||||
|
<T section="addons" k="emptySubtitle" />
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { Calendar, ArrowLeft } from "lucide-react";
|
import { ArticleDetail } from "@/components/public/ArticleDetail";
|
||||||
import { MarkdownContent } from "@/components/public/MarkdownContent";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -44,52 +41,15 @@ export default async function ArticleDetailPage({
|
|||||||
if (!article) notFound();
|
if (!article) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="mx-auto max-w-3xl px-3 py-10 sm:px-4 sm:py-16">
|
<ArticleDetail
|
||||||
<Link
|
article={{
|
||||||
href="/articles"
|
title: article.title,
|
||||||
className="mb-6 inline-flex items-center gap-1.5 text-sm text-gray-400 transition-colors hover:text-amber-200"
|
titleEn: article.titleEn,
|
||||||
>
|
content: article.content,
|
||||||
<ArrowLeft className="h-3.5 w-3.5" />
|
contentEn: article.contentEn,
|
||||||
返回文章列表
|
coverImage: article.coverImage,
|
||||||
</Link>
|
createdAt: article.createdAt.toISOString(),
|
||||||
|
}}
|
||||||
<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="relative mb-8 aspect-[16/9] overflow-hidden rounded-lg">
|
|
||||||
<Image
|
|
||||||
src={article.coverImage}
|
|
||||||
alt={article.title}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
sizes="(max-width: 768px) 100vw, 750px"
|
|
||||||
priority
|
|
||||||
/>
|
/>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { Calendar, ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { ArticleCard } from "@/components/public/ArticleCard";
|
||||||
|
import { T } from "@/components/public/T";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "公告与文章",
|
title: "公告与文章",
|
||||||
@@ -35,49 +36,33 @@ export default async function ArticlesPage({
|
|||||||
return (
|
return (
|
||||||
<section className="mx-auto max-w-4xl px-3 py-10 sm:px-4 sm:py-16">
|
<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 className="mb-2 text-2xl font-bold text-amber-100 sm:text-3xl">
|
||||||
公告与文章
|
<T section="articles" k="title" />
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mb-8 text-sm text-gray-400 sm:mb-12 sm:text-base">
|
<p className="mb-8 text-sm text-gray-400 sm:mb-12 sm:text-base">
|
||||||
最新动态与更新公告
|
<T section="articles" k="subtitle" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{articles.length === 0 ? (
|
{articles.length === 0 ? (
|
||||||
<p className="py-16 text-center text-gray-500">暂无文章</p>
|
<p className="py-16 text-center text-gray-500">
|
||||||
|
<T section="articles" k="empty" />
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
{articles.map((article) => (
|
{articles.map((article) => (
|
||||||
<Link
|
<ArticleCard
|
||||||
key={article.id}
|
key={article.id}
|
||||||
href={`/articles/${article.slug}`}
|
article={{
|
||||||
className="group overflow-hidden rounded-lg border border-amber-500/10 bg-white/[0.03] transition-colors hover:border-amber-500/25"
|
id: article.id,
|
||||||
>
|
slug: article.slug,
|
||||||
{article.coverImage && (
|
title: article.title,
|
||||||
<div className="relative aspect-[16/9] overflow-hidden">
|
titleEn: article.titleEn,
|
||||||
<Image
|
summary: article.summary,
|
||||||
src={article.coverImage}
|
summaryEn: article.summaryEn,
|
||||||
alt={article.title}
|
coverImage: article.coverImage,
|
||||||
fill
|
createdAt: article.createdAt.toISOString(),
|
||||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
}}
|
||||||
sizes="(max-width: 640px) 100vw, 50vw"
|
|
||||||
/>
|
/>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { Download, Calendar, Tag } from "lucide-react";
|
import { ChangelogTimeline } from "@/components/public/ChangelogTimeline";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -11,94 +12,30 @@ export const metadata = {
|
|||||||
export const revalidate = 120;
|
export const revalidate = 120;
|
||||||
|
|
||||||
export default async function ChangelogPage() {
|
export default async function ChangelogPage() {
|
||||||
|
const wowVersion = DEFAULT_WOW_VERSION;
|
||||||
const software = await prisma.software.findUnique({
|
const software = await prisma.software.findUnique({
|
||||||
where: { slug: "nanami-launcher" },
|
where: { slug: "nanami-launcher" },
|
||||||
include: {
|
include: {
|
||||||
versions: {
|
versions: {
|
||||||
|
where: { wowVersion },
|
||||||
orderBy: { versionCode: "desc" },
|
orderBy: { versionCode: "desc" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const versions = software?.versions ?? [];
|
const versions = (software?.versions ?? []).map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
version: v.version,
|
||||||
|
versionCode: v.versionCode,
|
||||||
|
changelog: v.changelog,
|
||||||
|
changelogEn: v.changelogEn,
|
||||||
|
fileSize: v.fileSize,
|
||||||
|
isLatest: v.isLatest,
|
||||||
|
forceUpdate: v.forceUpdate,
|
||||||
|
wowVersion: v.wowVersion,
|
||||||
|
downloadCount: v.downloadCount,
|
||||||
|
createdAt: v.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return <ChangelogTimeline versions={versions} wowVersion={wowVersion} />;
|
||||||
<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,18 +1,34 @@
|
|||||||
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";
|
import { PageTracker } from "@/components/public/PageTracker";
|
||||||
|
import { BgmPlayer } from "@/components/public/BgmPlayer";
|
||||||
|
import { getSiteSettings } from "@/lib/site-settings";
|
||||||
|
|
||||||
export default function PublicLayout({
|
export default async function PublicLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const settings = await getSiteSettings();
|
||||||
return (
|
return (
|
||||||
<div className="dark flex min-h-screen flex-col bg-[#0d0b15]">
|
<>
|
||||||
|
<div
|
||||||
|
className={`dark flex min-h-screen flex-col bg-[#0d0b15] ${
|
||||||
|
settings.grayscale ? "site-grayscale-wrapper" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
<PageTracker />
|
<PageTracker />
|
||||||
</div>
|
</div>
|
||||||
|
{settings.bgmUrl && (
|
||||||
|
<BgmPlayer
|
||||||
|
src={settings.bgmUrl}
|
||||||
|
autoplay={settings.bgmAutoplay}
|
||||||
|
volume={settings.bgmVolume}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
|
||||||
import { prisma } from "@/lib/db";
|
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 { GameGallery } from "@/components/public/GameGallery";
|
import { GameGallery } from "@/components/public/GameGallery";
|
||||||
import { Sparkles, Shield, Zap, Calendar } from "lucide-react";
|
import { ShutdownBanner } from "@/components/public/ShutdownBanner";
|
||||||
|
import { T } from "@/components/public/T";
|
||||||
|
import { ArticleCard } from "@/components/public/ArticleCard";
|
||||||
|
import { getSiteSettings } from "@/lib/site-settings";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
import { Sparkles, Shield, Zap } from "lucide-react";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
|
const siteSettings = await getSiteSettings();
|
||||||
|
const wowVersion = DEFAULT_WOW_VERSION;
|
||||||
const [featuredAddons, launcher, launcherDownloads, banners, galleryImages, latestArticles] =
|
const [featuredAddons, launcher, launcherDownloads, banners, galleryImages, latestArticles] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.addon.findMany({
|
prisma.addon.findMany({
|
||||||
where: { published: true },
|
where: { published: true },
|
||||||
include: {
|
include: {
|
||||||
releases: {
|
releases: {
|
||||||
where: { isLatest: true },
|
where: { isLatest: true, wowVersion },
|
||||||
select: { version: true },
|
select: { version: true },
|
||||||
},
|
},
|
||||||
|
screenshots: {
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
take: 1,
|
||||||
|
select: { imageUrl: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: { totalDownloads: "desc" },
|
orderBy: { totalDownloads: "desc" },
|
||||||
take: 6,
|
take: 6,
|
||||||
@@ -27,13 +38,13 @@ export default async function HomePage() {
|
|||||||
where: { slug: "nanami-launcher" },
|
where: { slug: "nanami-launcher" },
|
||||||
include: {
|
include: {
|
||||||
versions: {
|
versions: {
|
||||||
where: { isLatest: true },
|
where: { isLatest: true, wowVersion },
|
||||||
take: 1,
|
take: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.softwareVersion.aggregate({
|
prisma.softwareVersion.aggregate({
|
||||||
where: { software: { slug: "nanami-launcher" } },
|
where: { software: { slug: "nanami-launcher" }, wowVersion },
|
||||||
_sum: { downloadCount: true },
|
_sum: { downloadCount: true },
|
||||||
}),
|
}),
|
||||||
prisma.bannerImage.findMany({
|
prisma.bannerImage.findMany({
|
||||||
@@ -44,7 +55,7 @@ export default async function HomePage() {
|
|||||||
prisma.galleryImage.findMany({
|
prisma.galleryImage.findMany({
|
||||||
where: { enabled: true },
|
where: { enabled: true },
|
||||||
orderBy: { sortOrder: "asc" },
|
orderBy: { sortOrder: "asc" },
|
||||||
select: { imageUrl: true, title: true },
|
select: { imageUrl: true, title: true, titleEn: true },
|
||||||
}),
|
}),
|
||||||
prisma.article.findMany({
|
prisma.article.findMany({
|
||||||
where: { published: true },
|
where: { published: true },
|
||||||
@@ -58,6 +69,25 @@ export default async function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{siteSettings.shutdownBannerEnabled && siteSettings.shutdownAt && (
|
||||||
|
<ShutdownBanner
|
||||||
|
title={siteSettings.shutdownTitle || "旅途总有终点,\n但我们的故事未完待续"}
|
||||||
|
titleEn={
|
||||||
|
siteSettings.shutdownTitleEn ||
|
||||||
|
"Every journey reaches its end —\nbut our story is yet to be told"
|
||||||
|
}
|
||||||
|
subtitle={
|
||||||
|
siteSettings.shutdownSubtitle ||
|
||||||
|
"《Turtle WoW》即将关闭,服务器将于欧洲时间 5月15日 凌晨 00:00 正式关闭。"
|
||||||
|
}
|
||||||
|
subtitleEn={
|
||||||
|
siteSettings.shutdownSubtitleEn ||
|
||||||
|
"Turtle WoW is closing its gates. Servers will go dark at 00:00 European time on May 15."
|
||||||
|
}
|
||||||
|
shutdownAt={siteSettings.shutdownAt.toISOString()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<HeroBanner
|
<HeroBanner
|
||||||
totalDownloads={totalDownloads || undefined}
|
totalDownloads={totalDownloads || undefined}
|
||||||
launcherVersion={launcherVersion}
|
launcherVersion={launcherVersion}
|
||||||
@@ -75,9 +105,11 @@ export default async function HomePage() {
|
|||||||
<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" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-4 font-semibold text-amber-100">深度适配</h3>
|
<h3 className="mt-4 font-semibold text-amber-100">
|
||||||
|
<T section="home" k="featureDeepTitle" />
|
||||||
|
</h3>
|
||||||
<p className="mt-2 text-sm text-gray-400">
|
<p className="mt-2 text-sm text-gray-400">
|
||||||
专为乌龟服 1.18.0 打造,兼容自定义内容与新种族,稳定流畅
|
<T section="home" k="featureDeepDesc" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
@@ -85,10 +117,10 @@ export default async function HomePage() {
|
|||||||
<Shield className="h-6 w-6 text-purple-400" />
|
<Shield className="h-6 w-6 text-purple-400" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-4 font-semibold text-amber-100">
|
<h3 className="mt-4 font-semibold text-amber-100">
|
||||||
一键安装管理
|
<T section="home" k="featureInstallTitle" />
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-sm text-gray-400">
|
<p className="mt-2 text-sm text-gray-400">
|
||||||
通过 Nanami 启动器自动安装、更新,告别手动拖拽文件夹
|
<T section="home" k="featureInstallDesc" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
@@ -96,10 +128,10 @@ export default async function HomePage() {
|
|||||||
<Zap className="h-6 w-6 text-cyan-400" />
|
<Zap className="h-6 w-6 text-cyan-400" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-4 font-semibold text-amber-100">
|
<h3 className="mt-4 font-semibold text-amber-100">
|
||||||
内置 AI 翻译
|
<T section="home" k="featureAITitle" />
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-sm text-gray-400">
|
<p className="mt-2 text-sm text-gray-400">
|
||||||
自带智能翻译引擎,轻松畅玩英文服务器,语言不再是障碍
|
<T section="home" k="featureAIDesc" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,48 +143,33 @@ export default async function HomePage() {
|
|||||||
<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-3 py-10 sm:px-4 sm:py-16">
|
<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">
|
<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">
|
||||||
|
<T section="home" k="latestArticles" />
|
||||||
|
</h2>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-amber-500/20 text-amber-200 hover:border-amber-500/40 hover:bg-amber-500/10 hover:text-amber-100"
|
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" />}
|
render={<Link href="/articles" />}
|
||||||
>
|
>
|
||||||
查看更多
|
<T section="home" k="viewMore" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:gap-6 md:grid-cols-3">
|
<div className="grid gap-4 sm:gap-6 md:grid-cols-3">
|
||||||
{latestArticles.map((article) => (
|
{latestArticles.map((article) => (
|
||||||
<Link
|
<ArticleCard
|
||||||
key={article.id}
|
key={article.id}
|
||||||
href={`/articles/${article.slug}`}
|
variant="compact"
|
||||||
className="group overflow-hidden rounded-xl border border-amber-500/10 bg-white/[0.03] transition-colors hover:border-amber-500/25"
|
article={{
|
||||||
>
|
id: article.id,
|
||||||
{article.coverImage && (
|
slug: article.slug,
|
||||||
<div className="relative aspect-[16/9] overflow-hidden">
|
title: article.title,
|
||||||
<Image
|
titleEn: article.titleEn,
|
||||||
src={article.coverImage}
|
summary: article.summary,
|
||||||
alt={article.title}
|
summaryEn: article.summaryEn,
|
||||||
fill
|
coverImage: article.coverImage,
|
||||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
createdAt: article.createdAt.toISOString(),
|
||||||
sizes="(max-width: 768px) 100vw, 33vw"
|
}}
|
||||||
/>
|
/>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,13 +181,15 @@ export default async function HomePage() {
|
|||||||
<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-3 py-10 sm:px-4 sm:py-16">
|
<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">
|
<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">
|
||||||
|
<T section="home" k="hotAddons" />
|
||||||
|
</h2>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-amber-500/20 text-amber-200 hover:border-amber-500/40 hover:bg-amber-500/10 hover:text-amber-100"
|
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="/addons" />}
|
render={<Link href="/addons" />}
|
||||||
>
|
>
|
||||||
查看全部
|
<T section="home" k="viewAll" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { AddonForm } from "@/components/admin/AddonForm";
|
import { AddonForm } from "@/components/admin/AddonForm";
|
||||||
|
import { AddonScreenshots } from "@/components/admin/AddonScreenshots";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -10,7 +12,10 @@ export default async function EditAddonPage({
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const addon = await prisma.addon.findUnique({ where: { id } });
|
const addon = await prisma.addon.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { screenshots: { orderBy: { sortOrder: "asc" } } },
|
||||||
|
});
|
||||||
|
|
||||||
if (!addon) notFound();
|
if (!addon) notFound();
|
||||||
|
|
||||||
@@ -18,6 +23,17 @@ export default async function EditAddonPage({
|
|||||||
<div className="mx-auto max-w-2xl space-y-6">
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
<h1 className="text-3xl font-bold">编辑插件</h1>
|
<h1 className="text-3xl font-bold">编辑插件</h1>
|
||||||
<AddonForm initialData={addon} />
|
<AddonForm initialData={addon} />
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>说明截图</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AddonScreenshots
|
||||||
|
addonId={addon.id}
|
||||||
|
initial={JSON.parse(JSON.stringify(addon.screenshots))}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
import { ReleasesTable } from "@/components/admin/ReleasesTable";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function AdminReleasesPage() {
|
export default async function AdminReleasesPage() {
|
||||||
const releases = await prisma.release.findMany({
|
const releases = await prisma.release.findMany({
|
||||||
|
where: { wowVersion: DEFAULT_WOW_VERSION },
|
||||||
include: { addon: { select: { name: true, slug: true } } },
|
include: { addon: { select: { name: true, slug: true } } },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
@@ -31,57 +25,7 @@ export default async function AdminReleasesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card">
|
<div className="rounded-lg border bg-card">
|
||||||
<Table>
|
<ReleasesTable releases={JSON.parse(JSON.stringify(releases))} />
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>插件</TableHead>
|
|
||||||
<TableHead>版本</TableHead>
|
|
||||||
<TableHead>游戏版本</TableHead>
|
|
||||||
<TableHead>下载方式</TableHead>
|
|
||||||
<TableHead>下载量</TableHead>
|
|
||||||
<TableHead>发布时间</TableHead>
|
|
||||||
<TableHead>状态</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{releases.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={7}
|
|
||||||
className="text-center text-muted-foreground"
|
|
||||||
>
|
|
||||||
暂无版本发布
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
releases.map((release) => (
|
|
||||||
<TableRow key={release.id}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
{release.addon.name}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>v{release.version}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{release.gameVersion || "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{release.downloadType === "local" ? "本地文件" : "外部链接"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{release.downloadCount}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{new Date(release.createdAt).toLocaleDateString("zh-CN")}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{release.isLatest && (
|
|
||||||
<Badge>最新</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,154 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Trash2, Upload, Copy, ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
|
type SiteSettings = {
|
||||||
|
grayscale: boolean;
|
||||||
|
shutdownBannerEnabled: boolean;
|
||||||
|
shutdownTitle: string;
|
||||||
|
shutdownTitleEn: string;
|
||||||
|
shutdownSubtitle: string;
|
||||||
|
shutdownSubtitleEn: string;
|
||||||
|
shutdownAt: string | null;
|
||||||
|
bgmUrl: string;
|
||||||
|
bgmAutoplay: boolean;
|
||||||
|
bgmVolume: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toDatetimeLocal(iso: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return "";
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
|
||||||
|
d.getHours()
|
||||||
|
)}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [launcherUrl, setLauncherUrl] = useState("");
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
setLauncherUrl(`${window.location.origin}/download/launcher`);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleCopyLauncherUrl() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(launcherUrl);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [siteLoading, setSiteLoading] = useState(true);
|
||||||
|
const [siteSaving, setSiteSaving] = useState(false);
|
||||||
|
const [grayscale, setGrayscale] = useState(false);
|
||||||
|
const [bannerEnabled, setBannerEnabled] = useState(false);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [titleEn, setTitleEn] = useState("");
|
||||||
|
const [subtitle, setSubtitle] = useState("");
|
||||||
|
const [subtitleEn, setSubtitleEn] = useState("");
|
||||||
|
const [shutdownAtLocal, setShutdownAtLocal] = useState("");
|
||||||
|
const [bgmUrl, setBgmUrl] = useState("");
|
||||||
|
const [bgmAutoplay, setBgmAutoplay] = useState(false);
|
||||||
|
const [bgmVolume, setBgmVolume] = useState(50);
|
||||||
|
const [bgmUploading, setBgmUploading] = useState(false);
|
||||||
|
const bgmFileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/site-settings");
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data: SiteSettings = await res.json();
|
||||||
|
setGrayscale(data.grayscale);
|
||||||
|
setBannerEnabled(data.shutdownBannerEnabled);
|
||||||
|
setTitle(data.shutdownTitle);
|
||||||
|
setTitleEn(data.shutdownTitleEn ?? "");
|
||||||
|
setSubtitle(data.shutdownSubtitle);
|
||||||
|
setSubtitleEn(data.shutdownSubtitleEn ?? "");
|
||||||
|
setShutdownAtLocal(toDatetimeLocal(data.shutdownAt));
|
||||||
|
setBgmUrl(data.bgmUrl || "");
|
||||||
|
setBgmAutoplay(!!data.bgmAutoplay);
|
||||||
|
setBgmVolume(
|
||||||
|
typeof data.bgmVolume === "number" ? data.bgmVolume : 50
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("加载站点设置失败");
|
||||||
|
} finally {
|
||||||
|
setSiteLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSiteSave(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSiteSaving(true);
|
||||||
|
const payload = {
|
||||||
|
grayscale,
|
||||||
|
shutdownBannerEnabled: bannerEnabled,
|
||||||
|
shutdownTitle: title,
|
||||||
|
shutdownTitleEn: titleEn,
|
||||||
|
shutdownSubtitle: subtitle,
|
||||||
|
shutdownSubtitleEn: subtitleEn,
|
||||||
|
shutdownAt: shutdownAtLocal
|
||||||
|
? new Date(shutdownAtLocal).toISOString()
|
||||||
|
: null,
|
||||||
|
bgmUrl,
|
||||||
|
bgmAutoplay,
|
||||||
|
bgmVolume,
|
||||||
|
};
|
||||||
|
const res = await fetch("/api/admin/site-settings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success("站点设置已保存");
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
toast.error(data?.error || "保存失败");
|
||||||
|
}
|
||||||
|
setSiteSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBgmUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.type.startsWith("audio/")) {
|
||||||
|
toast.error("请选择音频文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBgmUploading(true);
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const res = await fetch("/api/upload", { method: "POST", body: form });
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
setBgmUrl(data.filePath as string);
|
||||||
|
toast.success("音频上传成功,请记得保存");
|
||||||
|
} catch {
|
||||||
|
toast.error("音频上传失败");
|
||||||
|
} finally {
|
||||||
|
setBgmUploading(false);
|
||||||
|
if (bgmFileRef.current) bgmFileRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePasswordSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
@@ -45,14 +183,273 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-lg space-y-6">
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
<h1 className="text-3xl font-bold">系统设置</h1>
|
<h1 className="text-3xl font-bold">系统设置</h1>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>站点外观</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSiteSave} className="space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-base">全站灰色模式</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
启用后前台整站将置为黑白灰,适用于重大纪念或肃穆场合。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={grayscale}
|
||||||
|
onCheckedChange={setGrayscale}
|
||||||
|
disabled={siteLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-base">关服倒计时 Banner</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
在首页顶部显示倒计时横幅,整体为肃穆风格。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={bannerEnabled}
|
||||||
|
onCheckedChange={setBannerEnabled}
|
||||||
|
disabled={siteLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="shutdownTitle">标题(中文)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="shutdownTitle"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder={"例如:旅程即将终结\n再见,乌龟服"}
|
||||||
|
disabled={siteLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
支持 Markdown:回车即换行,可用 <code>**粗体**</code>、<code>*斜体*</code>、<code>[链接](https://...)</code>。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="shutdownTitleEn">Title (English)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="shutdownTitleEn"
|
||||||
|
value={titleEn}
|
||||||
|
onChange={(e) => setTitleEn(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder={"e.g. Every journey ends,\nbut our story carries on"}
|
||||||
|
disabled={siteLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
英文用户访问时显示;为空则回退到中文。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="shutdownSubtitle">副标题 / 简短说明(中文)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="shutdownSubtitle"
|
||||||
|
value={subtitle}
|
||||||
|
onChange={(e) => setSubtitle(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="例如:《Turtle WoW》即将关闭,服务器将于欧洲时间 5月15日 凌晨 00:00 正式关闭。"
|
||||||
|
disabled={siteLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
支持 Markdown,回车即换行;空行分段。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="shutdownSubtitleEn">Subtitle (English)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="shutdownSubtitleEn"
|
||||||
|
value={subtitleEn}
|
||||||
|
onChange={(e) => setSubtitleEn(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="e.g. Turtle WoW will be shutting down. Servers go offline at midnight European time on May 15."
|
||||||
|
disabled={siteLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
英文用户访问时显示;为空则回退到中文。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="shutdownAt">关闭时间</Label>
|
||||||
|
<Input
|
||||||
|
id="shutdownAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={shutdownAtLocal}
|
||||||
|
onChange={(e) => setShutdownAtLocal(e.target.value)}
|
||||||
|
disabled={siteLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
使用浏览器本地时区;页面会基于访问者浏览器显示倒计时。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border p-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-base">整站背景音乐(BGM)</Label>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
支持上传 mp3 / ogg / wav 等音频文件,上传后将在前台页面右下角出现播放控件;用户可随时暂停或调节音量。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>音频文件</Label>
|
||||||
|
{bgmUrl ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm">
|
||||||
|
<span className="flex-1 truncate font-mono text-xs text-muted-foreground">
|
||||||
|
{bgmUrl}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBgmUrl("")}
|
||||||
|
className="text-destructive hover:text-destructive/80"
|
||||||
|
title="移除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<audio
|
||||||
|
src={bgmUrl}
|
||||||
|
controls
|
||||||
|
className="w-full"
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => bgmFileRef.current?.click()}
|
||||||
|
disabled={bgmUploading}
|
||||||
|
>
|
||||||
|
{bgmUploading ? "上传中..." : "替换音频"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => bgmFileRef.current?.click()}
|
||||||
|
disabled={bgmUploading}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
{bgmUploading ? "上传中..." : "上传音频"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={bgmFileRef}
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleBgmUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>自动播放</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
浏览器可能会拦截自动播放,遇此情况会在用户首次点击页面后自动启动。用户主动暂停后将不再自动续播。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={bgmAutoplay}
|
||||||
|
onCheckedChange={setBgmAutoplay}
|
||||||
|
disabled={siteLoading || !bgmUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="bgmVolume">默认音量</Label>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
||||||
|
{bgmVolume}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="bgmVolume"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={bgmVolume}
|
||||||
|
onChange={(e) => setBgmVolume(Number(e.target.value))}
|
||||||
|
disabled={siteLoading || !bgmUrl}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={siteLoading || siteSaving}>
|
||||||
|
{siteSaving ? "保存中..." : "保存"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>启动器直链</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
将该链接放到任意网页或外部站点,访问即可直接下载当前最新版本的
|
||||||
|
Nanami 启动器。链接始终指向最新版本,无需手动更新。
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2">
|
||||||
|
<span className="flex-1 truncate font-mono text-xs">
|
||||||
|
{launcherUrl || "/download/launcher"}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopyLauncherUrl}
|
||||||
|
disabled={!launcherUrl}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
launcherUrl && window.open(launcherUrl, "_blank")
|
||||||
|
}
|
||||||
|
disabled={!launcherUrl}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
测试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>修改密码</CardTitle>
|
<CardTitle>修改密码</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="currentPassword">当前密码</Label>
|
<Label htmlFor="currentPassword">当前密码</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { SoftwareEditForm } from "@/components/admin/SoftwareEditForm";
|
import { SoftwareEditForm } from "@/components/admin/SoftwareEditForm";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -12,7 +13,12 @@ export default async function EditSoftwarePage({
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const software = await prisma.software.findUnique({
|
const software = await prisma.software.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { versions: { orderBy: { versionCode: "desc" } } },
|
include: {
|
||||||
|
versions: {
|
||||||
|
where: { wowVersion: DEFAULT_WOW_VERSION },
|
||||||
|
orderBy: { versionCode: "desc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!software) notFound();
|
if (!software) notFound();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { SoftwareVersionTable } from "@/components/admin/SoftwareVersionTable";
|
import { SoftwareVersionTable } from "@/components/admin/SoftwareVersionTable";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
|
||||||
const SOFTWARE_DEFS = [
|
const SOFTWARE_DEFS = [
|
||||||
{
|
{
|
||||||
@@ -41,7 +42,7 @@ export default async function AdminSoftwarePage() {
|
|||||||
SOFTWARE_DEFS.map(async (def) => {
|
SOFTWARE_DEFS.map(async (def) => {
|
||||||
const sw = await ensureSoftware(def.slug, def.name, def.description);
|
const sw = await ensureSoftware(def.slug, def.name, def.description);
|
||||||
const versions = await prisma.softwareVersion.findMany({
|
const versions = await prisma.softwareVersion.findMany({
|
||||||
where: { softwareId: sw.id },
|
where: { softwareId: sw.id, wowVersion: DEFAULT_WOW_VERSION },
|
||||||
orderBy: { versionCode: "desc" },
|
orderBy: { versionCode: "desc" },
|
||||||
});
|
});
|
||||||
const totalDownloads = versions.reduce((s, v) => s + v.downloadCount, 0);
|
const totalDownloads = versions.reduce((s, v) => s + v.downloadCount, 0);
|
||||||
@@ -86,35 +87,22 @@ export default async function AdminSoftwarePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Card>
|
<Card className="border-amber-500/20">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription>当前版本</CardDescription>
|
<CardDescription className="flex items-center gap-2">
|
||||||
|
<span>WoW {DEFAULT_WOW_VERSION}</span>
|
||||||
|
<span className="text-xs text-muted-foreground/70">
|
||||||
|
({item.versions.length} 个版本 · {item.totalDownloads} 次下载)
|
||||||
|
</span>
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-2xl font-bold">
|
<p className="text-2xl font-bold">
|
||||||
{item.latestVersion
|
{item.latestVersion ? `v${item.latestVersion.version}` : "未发布"}
|
||||||
? `v${item.latestVersion.version}`
|
|
||||||
: "未发布"}
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardDescription>版本总数</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-2xl font-bold">{item.versions.length}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardDescription>总下载量</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-2xl font-bold">{item.totalDownloads}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getApiLang, pickText } from "@/lib/api-locale";
|
||||||
|
import { getApiWowVersion } from "@/lib/wow-versions";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const lang = getApiLang(request);
|
||||||
|
const wowVersion = getApiWowVersion(request);
|
||||||
|
|
||||||
const addon = await prisma.addon.findFirst({
|
const addon = await prisma.addon.findFirst({
|
||||||
where: { OR: [{ id }, { slug: id }] },
|
where: { OR: [{ id }, { slug: id }] },
|
||||||
include: {
|
include: {
|
||||||
releases: { orderBy: { createdAt: "desc" } },
|
releases: {
|
||||||
|
where: { wowVersion },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
},
|
||||||
screenshots: { orderBy: { sortOrder: "asc" } },
|
screenshots: { orderBy: { sortOrder: "asc" } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -20,7 +27,28 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(addon);
|
const releases = addon.releases.map((r) => ({
|
||||||
|
...r,
|
||||||
|
changelog: pickText(r.changelog, r.changelogEn, lang),
|
||||||
|
changelogZh: r.changelog,
|
||||||
|
changelogEn: r.changelogEn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...addon,
|
||||||
|
name: pickText(addon.name, addon.nameEn, lang),
|
||||||
|
summary: pickText(addon.summary, addon.summaryEn, lang),
|
||||||
|
description: pickText(addon.description, addon.descriptionEn, lang),
|
||||||
|
nameZh: addon.name,
|
||||||
|
summaryZh: addon.summary,
|
||||||
|
descriptionZh: addon.description,
|
||||||
|
nameEn: addon.nameEn,
|
||||||
|
summaryEn: addon.summaryEn,
|
||||||
|
descriptionEn: addon.descriptionEn,
|
||||||
|
releases,
|
||||||
|
lang,
|
||||||
|
wowVersion,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
@@ -34,8 +62,18 @@ export async function PUT(
|
|||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, slug, summary, description, iconUrl, category, published } =
|
const {
|
||||||
body;
|
name,
|
||||||
|
nameEn,
|
||||||
|
slug,
|
||||||
|
summary,
|
||||||
|
summaryEn,
|
||||||
|
description,
|
||||||
|
descriptionEn,
|
||||||
|
iconUrl,
|
||||||
|
category,
|
||||||
|
published,
|
||||||
|
} = body;
|
||||||
|
|
||||||
if (slug) {
|
if (slug) {
|
||||||
const existing = await prisma.addon.findFirst({
|
const existing = await prisma.addon.findFirst({
|
||||||
@@ -53,9 +91,12 @@ export async function PUT(
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...(name !== undefined && { name }),
|
...(name !== undefined && { name }),
|
||||||
|
...(nameEn !== undefined && { nameEn }),
|
||||||
...(slug !== undefined && { slug }),
|
...(slug !== undefined && { slug }),
|
||||||
...(summary !== undefined && { summary }),
|
...(summary !== undefined && { summary }),
|
||||||
|
...(summaryEn !== undefined && { summaryEn }),
|
||||||
...(description !== undefined && { description }),
|
...(description !== undefined && { description }),
|
||||||
|
...(descriptionEn !== undefined && { descriptionEn }),
|
||||||
...(iconUrl !== undefined && { iconUrl }),
|
...(iconUrl !== undefined && { iconUrl }),
|
||||||
...(category !== undefined && { category }),
|
...(category !== undefined && { category }),
|
||||||
...(published !== undefined && { published }),
|
...(published !== undefined && { published }),
|
||||||
|
|||||||
39
src/app/api/addons/[id]/screenshots/[screenshotId]/route.ts
Normal file
39
src/app/api/addons/[id]/screenshots/[screenshotId]/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; screenshotId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { screenshotId } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const screenshot = await prisma.screenshot.update({
|
||||||
|
where: { id: screenshotId },
|
||||||
|
data: {
|
||||||
|
...(body.sortOrder !== undefined && { sortOrder: body.sortOrder }),
|
||||||
|
...(body.imageUrl !== undefined && { imageUrl: body.imageUrl }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(screenshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; screenshotId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { screenshotId } = await params;
|
||||||
|
await prisma.screenshot.delete({ where: { id: screenshotId } });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
42
src/app/api/addons/[id]/screenshots/route.ts
Normal file
42
src/app/api/addons/[id]/screenshots/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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 screenshots = await prisma.screenshot.findMany({
|
||||||
|
where: { addonId: id },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
return NextResponse.json(screenshots);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
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 addon = await prisma.addon.findUnique({ where: { id } });
|
||||||
|
if (!addon) {
|
||||||
|
return NextResponse.json({ error: "Addon not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const screenshot = await prisma.screenshot.create({
|
||||||
|
data: {
|
||||||
|
addonId: id,
|
||||||
|
imageUrl: body.imageUrl,
|
||||||
|
sortOrder: body.sortOrder ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(screenshot);
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getApiLang, pickText } from "@/lib/api-locale";
|
||||||
|
import { getApiWowVersion } from "@/lib/wow-versions";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const category = searchParams.get("category");
|
const category = searchParams.get("category");
|
||||||
const search = searchParams.get("search");
|
const search = searchParams.get("search");
|
||||||
const publishedOnly = searchParams.get("published") !== "false";
|
const publishedOnly = searchParams.get("published") !== "false";
|
||||||
|
const lang = getApiLang(request);
|
||||||
|
const wowVersion = getApiWowVersion(request);
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (publishedOnly) where.published = true;
|
if (publishedOnly) where.published = true;
|
||||||
@@ -14,7 +18,9 @@ export async function GET(request: NextRequest) {
|
|||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ name: { contains: search, mode: "insensitive" } },
|
{ name: { contains: search, mode: "insensitive" } },
|
||||||
|
{ nameEn: { contains: search, mode: "insensitive" } },
|
||||||
{ summary: { contains: search, mode: "insensitive" } },
|
{ summary: { contains: search, mode: "insensitive" } },
|
||||||
|
{ summaryEn: { contains: search, mode: "insensitive" } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,15 +28,48 @@ export async function GET(request: NextRequest) {
|
|||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
releases: {
|
releases: {
|
||||||
where: { isLatest: true },
|
where: {
|
||||||
|
isLatest: true,
|
||||||
|
wowVersion,
|
||||||
|
},
|
||||||
take: 1,
|
take: 1,
|
||||||
},
|
},
|
||||||
|
screenshots: {
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
},
|
||||||
_count: { select: { releases: true } },
|
_count: { select: { releases: true } },
|
||||||
},
|
},
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(addons);
|
// Shape response: canonical fields hold the requested locale, with fallback
|
||||||
|
// to Chinese when English is empty. *Zh / *En siblings are also returned for
|
||||||
|
// clients that need both languages.
|
||||||
|
const shaped = addons.map((a) => {
|
||||||
|
const releases = a.releases.map((r) => ({
|
||||||
|
...r,
|
||||||
|
changelog: pickText(r.changelog, r.changelogEn, lang),
|
||||||
|
changelogZh: r.changelog,
|
||||||
|
changelogEn: r.changelogEn,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
...a,
|
||||||
|
name: pickText(a.name, a.nameEn, lang),
|
||||||
|
summary: pickText(a.summary, a.summaryEn, lang),
|
||||||
|
description: pickText(a.description, a.descriptionEn, lang),
|
||||||
|
nameZh: a.name,
|
||||||
|
summaryZh: a.summary,
|
||||||
|
descriptionZh: a.description,
|
||||||
|
nameEn: a.nameEn,
|
||||||
|
summaryEn: a.summaryEn,
|
||||||
|
descriptionEn: a.descriptionEn,
|
||||||
|
releases,
|
||||||
|
lang,
|
||||||
|
wowVersion,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(shaped);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -40,7 +79,18 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, slug, summary, description, iconUrl, category, published } = body;
|
const {
|
||||||
|
name,
|
||||||
|
nameEn,
|
||||||
|
slug,
|
||||||
|
summary,
|
||||||
|
summaryEn,
|
||||||
|
description,
|
||||||
|
descriptionEn,
|
||||||
|
iconUrl,
|
||||||
|
category,
|
||||||
|
published,
|
||||||
|
} = body;
|
||||||
|
|
||||||
if (!name || !slug || !summary) {
|
if (!name || !slug || !summary) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -60,9 +110,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const addon = await prisma.addon.create({
|
const addon = await prisma.addon.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
|
nameEn: nameEn || "",
|
||||||
slug,
|
slug,
|
||||||
summary,
|
summary,
|
||||||
|
summaryEn: summaryEn || "",
|
||||||
description: description || "",
|
description: description || "",
|
||||||
|
descriptionEn: descriptionEn || "",
|
||||||
iconUrl: iconUrl || null,
|
iconUrl: iconUrl || null,
|
||||||
category: category || "general",
|
category: category || "general",
|
||||||
published: published === true,
|
published: published === true,
|
||||||
|
|||||||
55
src/app/api/admin/site-settings/route.ts
Normal file
55
src/app/api/admin/site-settings/route.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getSiteSettings, upsertSiteSettings } from "@/lib/site-settings";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const settings = await getSiteSettings();
|
||||||
|
return NextResponse.json(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 patch: Parameters<typeof upsertSiteSettings>[0] = {};
|
||||||
|
|
||||||
|
if (typeof body.grayscale === "boolean") patch.grayscale = body.grayscale;
|
||||||
|
if (typeof body.shutdownBannerEnabled === "boolean")
|
||||||
|
patch.shutdownBannerEnabled = body.shutdownBannerEnabled;
|
||||||
|
if (typeof body.shutdownTitle === "string")
|
||||||
|
patch.shutdownTitle = body.shutdownTitle;
|
||||||
|
if (typeof body.shutdownTitleEn === "string")
|
||||||
|
patch.shutdownTitleEn = body.shutdownTitleEn;
|
||||||
|
if (typeof body.shutdownSubtitle === "string")
|
||||||
|
patch.shutdownSubtitle = body.shutdownSubtitle;
|
||||||
|
if (typeof body.shutdownSubtitleEn === "string")
|
||||||
|
patch.shutdownSubtitleEn = body.shutdownSubtitleEn;
|
||||||
|
if (typeof body.bgmUrl === "string") patch.bgmUrl = body.bgmUrl;
|
||||||
|
if (typeof body.bgmAutoplay === "boolean")
|
||||||
|
patch.bgmAutoplay = body.bgmAutoplay;
|
||||||
|
if (typeof body.bgmVolume === "number") {
|
||||||
|
const v = Math.round(body.bgmVolume);
|
||||||
|
patch.bgmVolume = Math.max(0, Math.min(100, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("shutdownAt" in body) {
|
||||||
|
if (body.shutdownAt === null || body.shutdownAt === "") {
|
||||||
|
patch.shutdownAt = null;
|
||||||
|
} else {
|
||||||
|
const d = new Date(body.shutdownAt);
|
||||||
|
if (isNaN(d.getTime())) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "shutdownAt 格式无效" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
patch.shutdownAt = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await upsertSiteSettings(patch);
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getApiLang, pickText } from "@/lib/api-locale";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const lang = getApiLang(request);
|
||||||
|
|
||||||
const article = await prisma.article.findFirst({
|
const article = await prisma.article.findFirst({
|
||||||
where: { OR: [{ id }, { slug: id }] },
|
where: { OR: [{ id }, { slug: id }] },
|
||||||
@@ -16,7 +18,19 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(article);
|
return NextResponse.json({
|
||||||
|
...article,
|
||||||
|
title: pickText(article.title, article.titleEn, lang),
|
||||||
|
summary: pickText(article.summary, article.summaryEn, lang),
|
||||||
|
content: pickText(article.content, article.contentEn, lang),
|
||||||
|
titleZh: article.title,
|
||||||
|
summaryZh: article.summary,
|
||||||
|
contentZh: article.content,
|
||||||
|
titleEn: article.titleEn,
|
||||||
|
summaryEn: article.summaryEn,
|
||||||
|
contentEn: article.contentEn,
|
||||||
|
lang,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
@@ -47,9 +61,12 @@ export async function PUT(
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...(body.title !== undefined && { title: body.title }),
|
...(body.title !== undefined && { title: body.title }),
|
||||||
|
...(body.titleEn !== undefined && { titleEn: body.titleEn }),
|
||||||
...(body.slug !== undefined && { slug: body.slug }),
|
...(body.slug !== undefined && { slug: body.slug }),
|
||||||
...(body.summary !== undefined && { summary: body.summary }),
|
...(body.summary !== undefined && { summary: body.summary }),
|
||||||
|
...(body.summaryEn !== undefined && { summaryEn: body.summaryEn }),
|
||||||
...(body.content !== undefined && { content: body.content }),
|
...(body.content !== undefined && { content: body.content }),
|
||||||
|
...(body.contentEn !== undefined && { contentEn: body.contentEn }),
|
||||||
...(body.coverImage !== undefined && {
|
...(body.coverImage !== undefined && {
|
||||||
coverImage: body.coverImage || null,
|
coverImage: body.coverImage || null,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getApiLang, pickText } from "@/lib/api-locale";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const publishedOnly = searchParams.get("published") !== "false";
|
const publishedOnly = searchParams.get("published") !== "false";
|
||||||
const limit = searchParams.get("limit");
|
const limit = searchParams.get("limit");
|
||||||
|
const lang = getApiLang(request);
|
||||||
|
|
||||||
const where = publishedOnly ? { published: true } : {};
|
const where = publishedOnly ? { published: true } : {};
|
||||||
|
|
||||||
@@ -15,7 +17,21 @@ export async function GET(request: NextRequest) {
|
|||||||
...(limit ? { take: parseInt(limit, 10) } : {}),
|
...(limit ? { take: parseInt(limit, 10) } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(articles);
|
return NextResponse.json(
|
||||||
|
articles.map((a) => ({
|
||||||
|
...a,
|
||||||
|
title: pickText(a.title, a.titleEn, lang),
|
||||||
|
summary: pickText(a.summary, a.summaryEn, lang),
|
||||||
|
content: pickText(a.content, a.contentEn, lang),
|
||||||
|
titleZh: a.title,
|
||||||
|
summaryZh: a.summary,
|
||||||
|
contentZh: a.content,
|
||||||
|
titleEn: a.titleEn,
|
||||||
|
summaryEn: a.summaryEn,
|
||||||
|
contentEn: a.contentEn,
|
||||||
|
lang,
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -25,7 +41,17 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { title, slug, summary, content, coverImage, published } = body;
|
const {
|
||||||
|
title,
|
||||||
|
titleEn,
|
||||||
|
slug,
|
||||||
|
summary,
|
||||||
|
summaryEn,
|
||||||
|
content,
|
||||||
|
contentEn,
|
||||||
|
coverImage,
|
||||||
|
published,
|
||||||
|
} = body;
|
||||||
|
|
||||||
if (!title || !slug) {
|
if (!title || !slug) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -45,9 +71,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const article = await prisma.article.create({
|
const article = await prisma.article.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
|
titleEn: titleEn || "",
|
||||||
slug,
|
slug,
|
||||||
summary: summary || "",
|
summary: summary || "",
|
||||||
|
summaryEn: summaryEn || "",
|
||||||
content: content || "",
|
content: content || "",
|
||||||
|
contentEn: contentEn || "",
|
||||||
coverImage: coverImage || null,
|
coverImage: coverImage || null,
|
||||||
published: published ?? false,
|
published: published ?? false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { readFile, stat } from "fs/promises";
|
import { readFile, stat } from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
@@ -9,8 +10,8 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const release = await prisma.release.findUnique({
|
const release = await prisma.release.findFirst({
|
||||||
where: { id },
|
where: { id, wowVersion: DEFAULT_WOW_VERSION },
|
||||||
include: { addon: { select: { name: true, slug: true } } },
|
include: { addon: { select: { name: true, slug: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export async function PUT(
|
|||||||
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder;
|
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder;
|
||||||
if (body.enabled !== undefined) data.enabled = body.enabled;
|
if (body.enabled !== undefined) data.enabled = body.enabled;
|
||||||
if (body.title !== undefined) data.title = body.title;
|
if (body.title !== undefined) data.title = body.title;
|
||||||
|
if (body.titleEn !== undefined) data.titleEn = body.titleEn;
|
||||||
if (body.imageUrl !== undefined) data.imageUrl = body.imageUrl;
|
if (body.imageUrl !== undefined) data.imageUrl = body.imageUrl;
|
||||||
|
|
||||||
const updated = await prisma.galleryImage.update({ where: { id }, data });
|
const updated = await prisma.galleryImage.update({ where: { id }, data });
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getApiLang, pickText } from "@/lib/api-locale";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const enabledOnly = request.nextUrl.searchParams.get("enabled") === "1";
|
const enabledOnly = request.nextUrl.searchParams.get("enabled") === "1";
|
||||||
|
const lang = getApiLang(request);
|
||||||
const where = enabledOnly ? { enabled: true } : {};
|
const where = enabledOnly ? { enabled: true } : {};
|
||||||
const images = await prisma.galleryImage.findMany({
|
const images = await prisma.galleryImage.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { sortOrder: "asc" },
|
orderBy: { sortOrder: "asc" },
|
||||||
});
|
});
|
||||||
return NextResponse.json(images);
|
return NextResponse.json(
|
||||||
|
images.map((img) => ({
|
||||||
|
...img,
|
||||||
|
title: pickText(img.title, img.titleEn, lang),
|
||||||
|
titleZh: img.title,
|
||||||
|
titleEn: img.titleEn,
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -23,6 +32,7 @@ export async function POST(request: NextRequest) {
|
|||||||
data: {
|
data: {
|
||||||
imageUrl: body.imageUrl,
|
imageUrl: body.imageUrl,
|
||||||
title: body.title ?? "",
|
title: body.title ?? "",
|
||||||
|
titleEn: body.titleEn ?? "",
|
||||||
sortOrder: body.sortOrder ?? 0,
|
sortOrder: body.sortOrder ?? 0,
|
||||||
enabled: body.enabled ?? true,
|
enabled: body.enabled ?? true,
|
||||||
},
|
},
|
||||||
|
|||||||
78
src/app/api/releases/[id]/route.ts
Normal file
78
src/app/api/releases/[id]/route.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
version,
|
||||||
|
changelog,
|
||||||
|
changelogEn,
|
||||||
|
downloadType,
|
||||||
|
filePath,
|
||||||
|
externalUrl,
|
||||||
|
gameVersion,
|
||||||
|
isLatest,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
const existing = await prisma.release.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: "Release not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWow = DEFAULT_WOW_VERSION;
|
||||||
|
|
||||||
|
if (isLatest === true && !existing.isLatest) {
|
||||||
|
await prisma.release.updateMany({
|
||||||
|
where: {
|
||||||
|
addonId: existing.addonId,
|
||||||
|
wowVersion: newWow,
|
||||||
|
isLatest: true,
|
||||||
|
NOT: { id },
|
||||||
|
},
|
||||||
|
data: { isLatest: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const release = await prisma.release.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(version !== undefined && { version }),
|
||||||
|
...(changelog !== undefined && { changelog }),
|
||||||
|
...(changelogEn !== undefined && { changelogEn }),
|
||||||
|
...(downloadType !== undefined && { downloadType }),
|
||||||
|
...(filePath !== undefined && { filePath }),
|
||||||
|
...(externalUrl !== undefined && { externalUrl }),
|
||||||
|
...(gameVersion !== undefined && { gameVersion }),
|
||||||
|
wowVersion: DEFAULT_WOW_VERSION,
|
||||||
|
...(isLatest !== undefined && { isLatest }),
|
||||||
|
},
|
||||||
|
include: { addon: { select: { name: true, slug: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(release);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.release.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -1,21 +1,50 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getApiLang, pickText } from "@/lib/api-locale";
|
||||||
|
import { DEFAULT_WOW_VERSION, getApiWowVersion } from "@/lib/wow-versions";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const addonId = searchParams.get("addonId");
|
const addonId = searchParams.get("addonId");
|
||||||
|
const lang = getApiLang(request);
|
||||||
|
const wowVersion = getApiWowVersion(request);
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (addonId) where.addonId = addonId;
|
if (addonId) where.addonId = addonId;
|
||||||
|
where.wowVersion = wowVersion;
|
||||||
|
|
||||||
const releases = await prisma.release.findMany({
|
const releases = await prisma.release.findMany({
|
||||||
where,
|
where,
|
||||||
include: { addon: { select: { id: true, name: true, slug: true } } },
|
include: {
|
||||||
|
addon: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
nameEn: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(releases);
|
return NextResponse.json(
|
||||||
|
releases.map((r) => ({
|
||||||
|
...r,
|
||||||
|
changelog: pickText(r.changelog, r.changelogEn, lang),
|
||||||
|
changelogZh: r.changelog,
|
||||||
|
changelogEn: r.changelogEn,
|
||||||
|
addon: {
|
||||||
|
id: r.addon.id,
|
||||||
|
slug: r.addon.slug,
|
||||||
|
name: pickText(r.addon.name, r.addon.nameEn, lang),
|
||||||
|
nameZh: r.addon.name,
|
||||||
|
nameEn: r.addon.nameEn,
|
||||||
|
},
|
||||||
|
lang,
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -29,6 +58,7 @@ export async function POST(request: NextRequest) {
|
|||||||
addonId,
|
addonId,
|
||||||
version,
|
version,
|
||||||
changelog,
|
changelog,
|
||||||
|
changelogEn,
|
||||||
downloadType,
|
downloadType,
|
||||||
filePath,
|
filePath,
|
||||||
externalUrl,
|
externalUrl,
|
||||||
@@ -49,13 +79,16 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wow = DEFAULT_WOW_VERSION;
|
||||||
|
|
||||||
const addon = await prisma.addon.findUnique({ where: { id: addonId } });
|
const addon = await prisma.addon.findUnique({ where: { id: addonId } });
|
||||||
if (!addon) {
|
if (!addon) {
|
||||||
return NextResponse.json({ error: "Addon not found" }, { status: 404 });
|
return NextResponse.json({ error: "Addon not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Demote previous latest only within the (addon, wowVersion) scope.
|
||||||
await prisma.release.updateMany({
|
await prisma.release.updateMany({
|
||||||
where: { addonId, isLatest: true },
|
where: { addonId, wowVersion: wow, isLatest: true },
|
||||||
data: { isLatest: false },
|
data: { isLatest: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,10 +97,12 @@ export async function POST(request: NextRequest) {
|
|||||||
addonId,
|
addonId,
|
||||||
version,
|
version,
|
||||||
changelog: changelog || "",
|
changelog: changelog || "",
|
||||||
|
changelogEn: changelogEn || "",
|
||||||
downloadType: downloadType || "local",
|
downloadType: downloadType || "local",
|
||||||
filePath: filePath || null,
|
filePath: filePath || null,
|
||||||
externalUrl: externalUrl || null,
|
externalUrl: externalUrl || null,
|
||||||
gameVersion: gameVersion || "",
|
gameVersion: gameVersion || "",
|
||||||
|
wowVersion: wow,
|
||||||
isLatest: true,
|
isLatest: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getApiLang, pickText } from "@/lib/api-locale";
|
||||||
|
import { getApiWowVersion } from "@/lib/wow-versions";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const lang = getApiLang(request);
|
||||||
|
const wowVersion = getApiWowVersion(request);
|
||||||
|
|
||||||
const software = await prisma.software.findFirst({
|
const software = await prisma.software.findFirst({
|
||||||
where: { OR: [{ id }, { slug: id }] },
|
where: { OR: [{ id }, { slug: id }] },
|
||||||
include: {
|
include: {
|
||||||
versions: { orderBy: { versionCode: "desc" } },
|
versions: {
|
||||||
|
where: { wowVersion },
|
||||||
|
orderBy: { versionCode: "desc" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,7 +26,23 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(software);
|
return NextResponse.json({
|
||||||
|
...software,
|
||||||
|
name: pickText(software.name, software.nameEn, lang),
|
||||||
|
description: pickText(software.description, software.descriptionEn, lang),
|
||||||
|
nameZh: software.name,
|
||||||
|
descriptionZh: software.description,
|
||||||
|
nameEn: software.nameEn,
|
||||||
|
descriptionEn: software.descriptionEn,
|
||||||
|
versions: software.versions.map((v) => ({
|
||||||
|
...v,
|
||||||
|
changelog: pickText(v.changelog, v.changelogEn, lang),
|
||||||
|
changelogZh: v.changelog,
|
||||||
|
changelogEn: v.changelogEn,
|
||||||
|
})),
|
||||||
|
lang,
|
||||||
|
wowVersion,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
@@ -38,8 +61,12 @@ export async function PUT(
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...(body.name !== undefined && { name: body.name }),
|
...(body.name !== undefined && { name: body.name }),
|
||||||
|
...(body.nameEn !== undefined && { nameEn: body.nameEn }),
|
||||||
...(body.slug !== undefined && { slug: body.slug }),
|
...(body.slug !== undefined && { slug: body.slug }),
|
||||||
...(body.description !== undefined && { description: body.description }),
|
...(body.description !== undefined && { description: body.description }),
|
||||||
|
...(body.descriptionEn !== undefined && {
|
||||||
|
descriptionEn: body.descriptionEn,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -17,6 +18,7 @@ export async function POST(
|
|||||||
version,
|
version,
|
||||||
versionCode,
|
versionCode,
|
||||||
changelog,
|
changelog,
|
||||||
|
changelogEn,
|
||||||
downloadType,
|
downloadType,
|
||||||
filePath,
|
filePath,
|
||||||
externalUrl,
|
externalUrl,
|
||||||
@@ -32,6 +34,8 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wow = DEFAULT_WOW_VERSION;
|
||||||
|
|
||||||
const software = await prisma.software.findUnique({
|
const software = await prisma.software.findUnique({
|
||||||
where: { id: softwareId },
|
where: { id: softwareId },
|
||||||
});
|
});
|
||||||
@@ -42,8 +46,10 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isLatest is scoped per (softwareId, wowVersion). Demote previous latest
|
||||||
|
// for the same wow only — leave the other wow's latest untouched.
|
||||||
await prisma.softwareVersion.updateMany({
|
await prisma.softwareVersion.updateMany({
|
||||||
where: { softwareId, isLatest: true },
|
where: { softwareId, wowVersion: wow, isLatest: true },
|
||||||
data: { isLatest: false },
|
data: { isLatest: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,12 +59,14 @@ export async function POST(
|
|||||||
version,
|
version,
|
||||||
versionCode: Number(versionCode),
|
versionCode: Number(versionCode),
|
||||||
changelog: changelog || "",
|
changelog: changelog || "",
|
||||||
|
changelogEn: changelogEn || "",
|
||||||
downloadType: downloadType || "local",
|
downloadType: downloadType || "local",
|
||||||
filePath: filePath || null,
|
filePath: filePath || null,
|
||||||
externalUrl: externalUrl || null,
|
externalUrl: externalUrl || null,
|
||||||
fileSize: Number(fileSize) || 0,
|
fileSize: Number(fileSize) || 0,
|
||||||
forceUpdate: forceUpdate || false,
|
forceUpdate: forceUpdate || false,
|
||||||
minVersion: minVersion || null,
|
minVersion: minVersion || null,
|
||||||
|
wowVersion: wow,
|
||||||
isLatest: true,
|
isLatest: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getApiLang, pickText } from "@/lib/api-locale";
|
||||||
|
import { getApiWowVersion } from "@/lib/wow-versions";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const slug = searchParams.get("slug");
|
const slug = searchParams.get("slug");
|
||||||
|
const lang = getApiLang(request);
|
||||||
|
const wowVersion = getApiWowVersion(request);
|
||||||
|
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -16,14 +20,17 @@ export async function GET(request: NextRequest) {
|
|||||||
where: { slug },
|
where: { slug },
|
||||||
include: {
|
include: {
|
||||||
versions: {
|
versions: {
|
||||||
|
where: { wowVersion },
|
||||||
orderBy: { versionCode: "desc" },
|
orderBy: { versionCode: "desc" },
|
||||||
select: {
|
select: {
|
||||||
version: true,
|
version: true,
|
||||||
versionCode: true,
|
versionCode: true,
|
||||||
changelog: true,
|
changelog: true,
|
||||||
|
changelogEn: true,
|
||||||
fileSize: true,
|
fileSize: true,
|
||||||
isLatest: true,
|
isLatest: true,
|
||||||
forceUpdate: true,
|
forceUpdate: true,
|
||||||
|
wowVersion: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -38,15 +45,22 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
name: software.name,
|
name: pickText(software.name, software.nameEn, lang),
|
||||||
|
nameZh: software.name,
|
||||||
|
nameEn: software.nameEn,
|
||||||
slug: software.slug,
|
slug: software.slug,
|
||||||
|
lang,
|
||||||
|
wowVersion,
|
||||||
versions: software.versions.map((v) => ({
|
versions: software.versions.map((v) => ({
|
||||||
version: v.version,
|
version: v.version,
|
||||||
versionCode: v.versionCode,
|
versionCode: v.versionCode,
|
||||||
changelog: v.changelog,
|
changelog: pickText(v.changelog, v.changelogEn, lang),
|
||||||
|
changelogZh: v.changelog,
|
||||||
|
changelogEn: v.changelogEn,
|
||||||
fileSize: v.fileSize,
|
fileSize: v.fileSize,
|
||||||
isLatest: v.isLatest,
|
isLatest: v.isLatest,
|
||||||
forceUpdate: v.forceUpdate,
|
forceUpdate: v.forceUpdate,
|
||||||
|
wowVersion: v.wowVersion,
|
||||||
createdAt: v.createdAt.toISOString(),
|
createdAt: v.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getApiLang, pickText } from "@/lib/api-locale";
|
||||||
|
import { getDownloadWowVersion } from "@/lib/wow-versions";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const slug = searchParams.get("slug");
|
const slug = searchParams.get("slug");
|
||||||
const currentVersionCode = searchParams.get("versionCode");
|
const currentVersionCode = searchParams.get("versionCode");
|
||||||
|
const lang = getApiLang(request);
|
||||||
|
// Self-update endpoint for launchers: ignore cookies and always serve the
|
||||||
|
// canonical 1.18 channel.
|
||||||
|
const wowVersion = getDownloadWowVersion(request);
|
||||||
|
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -17,7 +23,7 @@ export async function GET(request: NextRequest) {
|
|||||||
where: { slug },
|
where: { slug },
|
||||||
include: {
|
include: {
|
||||||
versions: {
|
versions: {
|
||||||
where: { isLatest: true },
|
where: { isLatest: true, wowVersion },
|
||||||
take: 1,
|
take: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -25,7 +31,10 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (!software || software.versions.length === 0) {
|
if (!software || software.versions.length === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Software or latest version not found" },
|
{
|
||||||
|
error: `No latest release for WoW ${wowVersion}`,
|
||||||
|
wowVersion,
|
||||||
|
},
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -37,20 +46,28 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const hasUpdate = latest.versionCode > clientVersionCode;
|
const hasUpdate = latest.versionCode > clientVersionCode;
|
||||||
|
|
||||||
const origin = process.env.API_BASE_URL || process.env.NEXTAUTH_URL || request.nextUrl.origin;
|
const origin =
|
||||||
|
process.env.API_BASE_URL ||
|
||||||
|
process.env.NEXTAUTH_URL ||
|
||||||
|
request.nextUrl.origin;
|
||||||
|
|
||||||
const downloadUrl = `${origin.replace(/\/$/, "")}/api/software/download/${latest.id}?source=launcher`;
|
const downloadUrl = `${origin.replace(/\/$/, "")}/api/software/download/${latest.id}?source=launcher`;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
hasUpdate,
|
hasUpdate,
|
||||||
forceUpdate: hasUpdate && latest.forceUpdate,
|
forceUpdate: hasUpdate && latest.forceUpdate,
|
||||||
|
lang,
|
||||||
|
wowVersion: latest.wowVersion,
|
||||||
latest: {
|
latest: {
|
||||||
version: latest.version,
|
version: latest.version,
|
||||||
versionCode: latest.versionCode,
|
versionCode: latest.versionCode,
|
||||||
changelog: latest.changelog,
|
changelog: pickText(latest.changelog, latest.changelogEn, lang),
|
||||||
|
changelogZh: latest.changelog,
|
||||||
|
changelogEn: latest.changelogEn,
|
||||||
downloadUrl,
|
downloadUrl,
|
||||||
fileSize: latest.fileSize,
|
fileSize: latest.fileSize,
|
||||||
minVersion: latest.minVersion,
|
minVersion: latest.minVersion,
|
||||||
|
wowVersion: latest.wowVersion,
|
||||||
createdAt: latest.createdAt,
|
createdAt: latest.createdAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { readFile, stat } from "fs/promises";
|
import { readFile, stat } from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -10,8 +11,8 @@ export async function GET(
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const source = new URL(request.url).searchParams.get("source");
|
const source = new URL(request.url).searchParams.get("source");
|
||||||
|
|
||||||
const sv = await prisma.softwareVersion.findUnique({
|
const sv = await prisma.softwareVersion.findFirst({
|
||||||
where: { id },
|
where: { id, wowVersion: DEFAULT_WOW_VERSION },
|
||||||
include: { software: { select: { slug: true } } },
|
include: { software: { select: { slug: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,24 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { readFile, stat } from "fs/promises";
|
import { readFile, stat } from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { getApiLang, pickText } from "@/lib/api-locale";
|
||||||
|
import { getDownloadWowVersion } from "@/lib/wow-versions";
|
||||||
|
|
||||||
const LAUNCHER_SLUG = "nanami-launcher";
|
const LAUNCHER_SLUG = "nanami-launcher";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const infoOnly = searchParams.get("info") === "1";
|
const infoOnly = searchParams.get("info") === "1";
|
||||||
|
const lang = getApiLang(request);
|
||||||
|
// Download endpoint: only the URL determines what gets downloaded.
|
||||||
|
// Cookies (wow / locale) do not influence the binary.
|
||||||
|
const wowVersion = getDownloadWowVersion(request);
|
||||||
|
|
||||||
const software = await prisma.software.findUnique({
|
const software = await prisma.software.findUnique({
|
||||||
where: { slug: LAUNCHER_SLUG },
|
where: { slug: LAUNCHER_SLUG },
|
||||||
include: {
|
include: {
|
||||||
versions: {
|
versions: {
|
||||||
where: { isLatest: true },
|
where: { isLatest: true, wowVersion },
|
||||||
take: 1,
|
take: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -21,10 +27,10 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (!software || software.versions.length === 0) {
|
if (!software || software.versions.length === 0) {
|
||||||
if (infoOnly) {
|
if (infoOnly) {
|
||||||
return NextResponse.json({ available: false });
|
return NextResponse.json({ available: false, wowVersion });
|
||||||
}
|
}
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "暂无可下载版本" },
|
{ error: `No release available for WoW ${wowVersion}` },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -49,11 +55,15 @@ export async function GET(request: NextRequest) {
|
|||||||
available: true,
|
available: true,
|
||||||
version: latest.version,
|
version: latest.version,
|
||||||
versionCode: latest.versionCode,
|
versionCode: latest.versionCode,
|
||||||
changelog: latest.changelog,
|
changelog: pickText(latest.changelog, latest.changelogEn, lang),
|
||||||
|
changelogZh: latest.changelog,
|
||||||
|
changelogEn: latest.changelogEn,
|
||||||
fileSize: latest.fileSize,
|
fileSize: latest.fileSize,
|
||||||
createdAt: latest.createdAt,
|
createdAt: latest.createdAt,
|
||||||
downloadUrl,
|
downloadUrl,
|
||||||
downloadType: latest.downloadType,
|
downloadType: latest.downloadType,
|
||||||
|
wowVersion: latest.wowVersion,
|
||||||
|
lang,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +89,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const fileBuffer = await readFile(filePath);
|
const fileBuffer = await readFile(filePath);
|
||||||
const ext = path.extname(latest.filePath);
|
const ext = path.extname(latest.filePath);
|
||||||
const fileName = `nanami-launcher-v${latest.version}${ext}`;
|
const fileName = `nanami-launcher-wow${latest.wowVersion}-v${latest.version}${ext}`;
|
||||||
|
|
||||||
return new NextResponse(fileBuffer, {
|
return new NextResponse(fileBuffer, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -1,19 +1,42 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getApiLang, pickText } from "@/lib/api-locale";
|
||||||
|
import { getApiWowVersion } from "@/lib/wow-versions";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
|
const lang = getApiLang(request);
|
||||||
|
const wowVersion = getApiWowVersion(request);
|
||||||
const software = await prisma.software.findMany({
|
const software = await prisma.software.findMany({
|
||||||
include: {
|
include: {
|
||||||
versions: {
|
versions: {
|
||||||
where: { isLatest: true },
|
where: { isLatest: true, wowVersion },
|
||||||
take: 1,
|
take: 1,
|
||||||
},
|
},
|
||||||
_count: { select: { versions: true } },
|
_count: { select: { versions: true } },
|
||||||
},
|
},
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
});
|
});
|
||||||
return NextResponse.json(software);
|
|
||||||
|
return NextResponse.json(
|
||||||
|
software.map((s) => ({
|
||||||
|
...s,
|
||||||
|
name: pickText(s.name, s.nameEn, lang),
|
||||||
|
description: pickText(s.description, s.descriptionEn, lang),
|
||||||
|
nameZh: s.name,
|
||||||
|
descriptionZh: s.description,
|
||||||
|
nameEn: s.nameEn,
|
||||||
|
descriptionEn: s.descriptionEn,
|
||||||
|
versions: s.versions.map((v) => ({
|
||||||
|
...v,
|
||||||
|
changelog: pickText(v.changelog, v.changelogEn, lang),
|
||||||
|
changelogZh: v.changelog,
|
||||||
|
changelogEn: v.changelogEn,
|
||||||
|
})),
|
||||||
|
lang,
|
||||||
|
wowVersion,
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -22,7 +45,8 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, slug, description } = await request.json();
|
const { name, nameEn, slug, description, descriptionEn } =
|
||||||
|
await request.json();
|
||||||
|
|
||||||
if (!name || !slug) {
|
if (!name || !slug) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -40,7 +64,13 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const software = await prisma.software.create({
|
const software = await prisma.software.create({
|
||||||
data: { name, slug, description: description || "" },
|
data: {
|
||||||
|
name,
|
||||||
|
nameEn: nameEn || "",
|
||||||
|
slug,
|
||||||
|
description: description || "",
|
||||||
|
descriptionEn: descriptionEn || "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(software, { status: 201 });
|
return NextResponse.json(software, { status: 201 });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -21,10 +22,18 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newWow = DEFAULT_WOW_VERSION;
|
||||||
|
|
||||||
const setLatest = body.isLatest === true && !existing.isLatest;
|
const setLatest = body.isLatest === true && !existing.isLatest;
|
||||||
if (setLatest) {
|
if (setLatest) {
|
||||||
|
// Demote previous latest within the same (software, wowVersion) scope.
|
||||||
await prisma.softwareVersion.updateMany({
|
await prisma.softwareVersion.updateMany({
|
||||||
where: { softwareId: existing.softwareId, isLatest: true },
|
where: {
|
||||||
|
softwareId: existing.softwareId,
|
||||||
|
wowVersion: newWow,
|
||||||
|
isLatest: true,
|
||||||
|
NOT: { id: versionId },
|
||||||
|
},
|
||||||
data: { isLatest: false },
|
data: { isLatest: false },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -37,6 +46,7 @@ export async function PUT(
|
|||||||
versionCode: Number(body.versionCode),
|
versionCode: Number(body.versionCode),
|
||||||
}),
|
}),
|
||||||
...(body.changelog !== undefined && { changelog: body.changelog }),
|
...(body.changelog !== undefined && { changelog: body.changelog }),
|
||||||
|
...(body.changelogEn !== undefined && { changelogEn: body.changelogEn }),
|
||||||
...(body.downloadType !== undefined && {
|
...(body.downloadType !== undefined && {
|
||||||
downloadType: body.downloadType,
|
downloadType: body.downloadType,
|
||||||
}),
|
}),
|
||||||
@@ -50,6 +60,7 @@ export async function PUT(
|
|||||||
minVersion: body.minVersion || null,
|
minVersion: body.minVersion || null,
|
||||||
}),
|
}),
|
||||||
...(body.isLatest !== undefined && { isLatest: body.isLatest }),
|
...(body.isLatest !== undefined && { isLatest: body.isLatest }),
|
||||||
|
wowVersion: DEFAULT_WOW_VERSION,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
70
src/app/download/launcher/route.ts
Normal file
70
src/app/download/launcher/route.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { readFile, stat } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { getDownloadWowVersion } from "@/lib/wow-versions";
|
||||||
|
|
||||||
|
const LAUNCHER_SLUG = "nanami-launcher";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
// Canonical public download link for third-party embedding. Cookies
|
||||||
|
// (wow/locale) are ignored — the URL alone determines the binary.
|
||||||
|
const wowVersion = getDownloadWowVersion(request);
|
||||||
|
|
||||||
|
const software = await prisma.software.findUnique({
|
||||||
|
where: { slug: LAUNCHER_SLUG },
|
||||||
|
include: {
|
||||||
|
versions: {
|
||||||
|
where: { isLatest: true, wowVersion },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!software || software.versions.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `No release for WoW ${wowVersion}` },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = software.versions[0];
|
||||||
|
|
||||||
|
await prisma.softwareVersion.update({
|
||||||
|
where: { id: latest.id },
|
||||||
|
data: { downloadCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (latest.downloadType === "url" && latest.externalUrl) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { Location: latest.externalUrl },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latest.filePath) {
|
||||||
|
const filePath = path.join(process.cwd(), latest.filePath);
|
||||||
|
try {
|
||||||
|
await stat(filePath);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "文件不存在" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = await readFile(filePath);
|
||||||
|
const ext = path.extname(latest.filePath);
|
||||||
|
const fileName = `nanami-launcher-wow${latest.wowVersion}-v${latest.version}${ext}`;
|
||||||
|
|
||||||
|
return new NextResponse(fileBuffer, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||||
|
"Content-Length": fileBuffer.length.toString(),
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: "无下载来源" }, { status: 404 });
|
||||||
|
}
|
||||||
@@ -286,6 +286,195 @@
|
|||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Shutdown / Mourning Banner ---- */
|
||||||
|
.shutdown-banner {
|
||||||
|
animation: shutdownFade 1.4s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shutdownFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shutdown-vignette {
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse at center,
|
||||||
|
transparent 40%,
|
||||||
|
rgba(0, 0, 0, 0.55) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shutdown-eyebrow {
|
||||||
|
animation: shutdownSoftPulse 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shutdownSoftPulse {
|
||||||
|
0%, 100% { opacity: 0.55; }
|
||||||
|
50% { opacity: 0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shutdown-title {
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-shadow: 0 0 18px rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shutdown-rule {
|
||||||
|
animation: shutdownRuleBreath 5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shutdownRuleBreath {
|
||||||
|
0%, 100% { opacity: 0.55; transform: scaleX(0.9); }
|
||||||
|
50% { opacity: 1; transform: scaleX(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shutdown-countdown .shutdown-unit {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
animation: shutdownUnitIn 0.9s cubic-bezier(0.2, 0.65, 0.25, 1) forwards;
|
||||||
|
}
|
||||||
|
.shutdown-countdown.is-mounted .shutdown-unit {
|
||||||
|
animation-play-state: running;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shutdownUnitIn {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shutdown-num {
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-shadow: 0 0 24px rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Candle */
|
||||||
|
.shutdown-candle {
|
||||||
|
position: relative;
|
||||||
|
width: 12px;
|
||||||
|
height: 38px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.shutdown-candle-body {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 6px;
|
||||||
|
height: 22px;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(235, 225, 210, 0.85) 0%,
|
||||||
|
rgba(180, 170, 160, 0.7) 50%,
|
||||||
|
rgba(100, 95, 90, 0.6) 100%
|
||||||
|
);
|
||||||
|
border-radius: 1px;
|
||||||
|
box-shadow: 0 0 6px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.shutdown-candle-flame {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 10px;
|
||||||
|
height: 16px;
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse at 50% 70%,
|
||||||
|
rgba(255, 210, 150, 0.95) 0%,
|
||||||
|
rgba(255, 180, 90, 0.6) 40%,
|
||||||
|
rgba(255, 120, 40, 0.2) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
border-radius: 50% 50% 40% 40% / 70% 70% 30% 30%;
|
||||||
|
filter: blur(0.4px);
|
||||||
|
animation: candleFlicker 2.2s ease-in-out infinite;
|
||||||
|
transform-origin: 50% 90%;
|
||||||
|
box-shadow:
|
||||||
|
0 0 12px rgba(255, 180, 90, 0.5),
|
||||||
|
0 0 24px rgba(255, 150, 60, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes candleFlicker {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateX(-50%) scale(1, 1) rotate(-1deg);
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
transform: translateX(-50%) scale(0.96, 1.05) rotate(1.5deg);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: translateX(-50%) scale(1.04, 0.96) rotate(-1.2deg);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translateX(-50%) scale(0.98, 1.03) rotate(0.8deg);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
transform: translateX(-50%) scale(1.02, 0.98) rotate(-0.5deg);
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.shutdown-banner,
|
||||||
|
.shutdown-eyebrow,
|
||||||
|
.shutdown-rule,
|
||||||
|
.shutdown-countdown .shutdown-unit,
|
||||||
|
.shutdown-candle-flame {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.shutdown-countdown .shutdown-unit {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Site-wide grayscale (mourning mode) ---- */
|
||||||
|
.site-grayscale-wrapper {
|
||||||
|
filter: grayscale(100%);
|
||||||
|
-webkit-filter: grayscale(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- BGM Player ---- */
|
||||||
|
.bgm-btn-playing::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
animation: bgmPulse 2.4s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@keyframes bgmPulse {
|
||||||
|
0%, 100% { opacity: 0.25; transform: scale(1); }
|
||||||
|
50% { opacity: 0.7; transform: scale(1.08); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgm-range::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bgm-range::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Page Transition ---- */
|
/* ---- Page Transition ---- */
|
||||||
.page-enter {
|
.page-enter {
|
||||||
animation: pageEnter 0.3s ease-out;
|
animation: pageEnter 0.3s ease-out;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||||
|
import { LocaleProvider } from "@/i18n/LocaleProvider";
|
||||||
|
import { getServerLocale } from "@/i18n/getLocale";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -22,18 +24,21 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const locale = await getServerLocale();
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN" suppressHydrationWarning>
|
<html lang={locale === "en" ? "en" : "zh-CN"} suppressHydrationWarning>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
|
<LocaleProvider initialLocale={locale}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</LocaleProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Upload, X, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ value: "general", label: "通用" },
|
{ value: "general", label: "通用" },
|
||||||
@@ -31,9 +32,12 @@ interface AddonFormProps {
|
|||||||
initialData?: {
|
initialData?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
nameEn?: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
summaryEn?: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
descriptionEn?: string;
|
||||||
iconUrl: string | null;
|
iconUrl: string | null;
|
||||||
category: string;
|
category: string;
|
||||||
published: boolean;
|
published: boolean;
|
||||||
@@ -43,8 +47,29 @@ interface AddonFormProps {
|
|||||||
export function AddonForm({ initialData }: AddonFormProps) {
|
export function AddonForm({ initialData }: AddonFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [iconUrl, setIconUrl] = useState(initialData?.iconUrl || "");
|
||||||
|
const [uploadingIcon, setUploadingIcon] = useState(false);
|
||||||
|
const iconFileRef = useRef<HTMLInputElement>(null);
|
||||||
const isEdit = !!initialData;
|
const isEdit = !!initialData;
|
||||||
|
|
||||||
|
async function handleIconUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploadingIcon(true);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch("/api/upload", { method: "POST", body: fd });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setIconUrl(data.filePath);
|
||||||
|
toast.success("图标上传成功");
|
||||||
|
} else {
|
||||||
|
toast.error("图标上传失败");
|
||||||
|
}
|
||||||
|
setUploadingIcon(false);
|
||||||
|
if (iconFileRef.current) iconFileRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
function generateSlug(name: string) {
|
function generateSlug(name: string) {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -59,10 +84,13 @@ export function AddonForm({ initialData }: AddonFormProps) {
|
|||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const data = {
|
const data = {
|
||||||
name: formData.get("name"),
|
name: formData.get("name"),
|
||||||
|
nameEn: formData.get("nameEn") || "",
|
||||||
slug: formData.get("slug"),
|
slug: formData.get("slug"),
|
||||||
summary: formData.get("summary"),
|
summary: formData.get("summary"),
|
||||||
|
summaryEn: formData.get("summaryEn") || "",
|
||||||
description: formData.get("description"),
|
description: formData.get("description"),
|
||||||
iconUrl: formData.get("iconUrl") || null,
|
descriptionEn: formData.get("descriptionEn") || "",
|
||||||
|
iconUrl: iconUrl || null,
|
||||||
category: formData.get("category"),
|
category: formData.get("category"),
|
||||||
published: formData.get("published") === "on",
|
published: formData.get("published") === "on",
|
||||||
};
|
};
|
||||||
@@ -97,7 +125,7 @@ export function AddonForm({ initialData }: AddonFormProps) {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">插件名称 *</Label>
|
<Label htmlFor="name">插件名称(中文) *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
@@ -113,6 +141,17 @@ export function AddonForm({ initialData }: AddonFormProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nameEn">Name (English)</Label>
|
||||||
|
<Input
|
||||||
|
id="nameEn"
|
||||||
|
name="nameEn"
|
||||||
|
defaultValue={initialData?.nameEn || ""}
|
||||||
|
placeholder="为空则回退到中文"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="slug">Slug (URL 标识) *</Label>
|
<Label htmlFor="slug">Slug (URL 标识) *</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -124,10 +163,10 @@ export function AddonForm({ initialData }: AddonFormProps) {
|
|||||||
title="只能包含小写字母、数字和连字符"
|
title="只能包含小写字母、数字和连字符"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="summary">简介 *</Label>
|
<Label htmlFor="summary">简介(中文) *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="summary"
|
id="summary"
|
||||||
name="summary"
|
name="summary"
|
||||||
@@ -136,9 +175,20 @@ export function AddonForm({ initialData }: AddonFormProps) {
|
|||||||
placeholder="一句话描述插件功能"
|
placeholder="一句话描述插件功能"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">详细描述 (支持 Markdown)</Label>
|
<Label htmlFor="summaryEn">Summary (English)</Label>
|
||||||
|
<Input
|
||||||
|
id="summaryEn"
|
||||||
|
name="summaryEn"
|
||||||
|
defaultValue={initialData?.summaryEn || ""}
|
||||||
|
placeholder="为空则回退到中文 / Falls back to Chinese if empty"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">详细描述(中文,支持 Markdown)</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
name="description"
|
name="description"
|
||||||
@@ -147,17 +197,75 @@ export function AddonForm({ initialData }: AddonFormProps) {
|
|||||||
placeholder="# 插件名称 详细描述..."
|
placeholder="# 插件名称 详细描述..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="descriptionEn">Description (English, Markdown)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="descriptionEn"
|
||||||
|
name="descriptionEn"
|
||||||
|
rows={12}
|
||||||
|
defaultValue={initialData?.descriptionEn || ""}
|
||||||
|
placeholder="# Addon Name Detailed description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="iconUrl">图标 URL</Label>
|
<Label>图标</Label>
|
||||||
<Input
|
<div className="flex items-center gap-3">
|
||||||
id="iconUrl"
|
{iconUrl && (
|
||||||
name="iconUrl"
|
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg border bg-muted">
|
||||||
defaultValue={initialData?.iconUrl || ""}
|
<img
|
||||||
placeholder="https://example.com/icon.png"
|
src={iconUrl}
|
||||||
|
alt="图标预览"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => iconFileRef.current?.click()}
|
||||||
|
disabled={uploadingIcon}
|
||||||
|
>
|
||||||
|
{uploadingIcon ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{iconUrl ? "重新上传" : "上传图标"}
|
||||||
|
</Button>
|
||||||
|
{iconUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIconUrl("")}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
移除图标
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={iconFileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleIconUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!iconUrl && (
|
||||||
|
<Input
|
||||||
|
placeholder="或直接粘贴图片 URL"
|
||||||
|
value={iconUrl}
|
||||||
|
onChange={(e) => setIconUrl(e.target.value)}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="category">分类</Label>
|
<Label htmlFor="category">分类</Label>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
280
src/components/admin/AddonScreenshots.tsx
Normal file
280
src/components/admin/AddonScreenshots.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
Loader2,
|
||||||
|
GripVertical,
|
||||||
|
X,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface Screenshot {
|
||||||
|
id: string;
|
||||||
|
imageUrl: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
addonId: string;
|
||||||
|
initial: Screenshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddonScreenshots({ addonId, initial }: Props) {
|
||||||
|
const [items, setItems] = useState<Screenshot[]>(initial);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [previewIdx, setPreviewIdx] = useState<number | null>(null);
|
||||||
|
const [dragId, setDragId] = useState<string | null>(null);
|
||||||
|
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const endpoint = `/api/addons/${addonId}/screenshots`;
|
||||||
|
|
||||||
|
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 fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const uploadRes = await fetch("/api/upload", { method: "POST", body: fd });
|
||||||
|
if (!uploadRes.ok) throw new Error("上传失败");
|
||||||
|
const { filePath } = await uploadRes.json();
|
||||||
|
|
||||||
|
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("保存失败");
|
||||||
|
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 persistSortOrder = useCallback(
|
||||||
|
async (reordered: Screenshot[]) => {
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
reordered.map((item, idx) =>
|
||||||
|
fetch(`${endpoint}/${item.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ sortOrder: idx }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("排序保存失败");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[endpoint]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.DragEvent, id: string) => {
|
||||||
|
setDragId(id);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent, id: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
if (dragId && dragId !== id) setDragOverId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent, targetId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!dragId || dragId === targetId) {
|
||||||
|
setDragId(null);
|
||||||
|
setDragOverId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromIdx = items.findIndex((it) => it.id === dragId);
|
||||||
|
const toIdx = items.findIndex((it) => it.id === targetId);
|
||||||
|
if (fromIdx === -1 || toIdx === -1) return;
|
||||||
|
|
||||||
|
const newItems = [...items];
|
||||||
|
const [moved] = newItems.splice(fromIdx, 1);
|
||||||
|
newItems.splice(toIdx, 0, moved);
|
||||||
|
|
||||||
|
const updated = newItems.map((item, i) => ({ ...item, sortOrder: i }));
|
||||||
|
setItems(updated);
|
||||||
|
setDragId(null);
|
||||||
|
setDragOverId(null);
|
||||||
|
persistSortOrder(updated);
|
||||||
|
toast.success("排序已更新");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDragId(null);
|
||||||
|
setDragOverId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="gap-2"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{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-xs text-muted-foreground">
|
||||||
|
共 {items.length} 张 · 拖拽可调整顺序
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
暂无截图,点击上方按钮上传
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, item.id)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, item.id)}
|
||||||
|
onDrop={(e) => handleDrop(e, item.id)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className={`flex items-center gap-3 rounded-lg border p-2 transition-all ${
|
||||||
|
dragId === item.id
|
||||||
|
? "scale-[0.98] bg-muted/50 opacity-40"
|
||||||
|
: dragOverId === item.id
|
||||||
|
? "bg-primary/5 ring-2 ring-primary"
|
||||||
|
: "bg-card hover:bg-muted/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="cursor-grab text-muted-foreground/50 hover:text-muted-foreground active:cursor-grabbing">
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span className="w-5 text-center font-mono text-xs text-muted-foreground">
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="relative h-12 w-20 shrink-0 cursor-pointer overflow-hidden rounded border bg-muted"
|
||||||
|
onClick={() => setPreviewIdx(idx)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={item.imageUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-all hover:bg-black/30 hover:opacity-100">
|
||||||
|
<Eye className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="hidden flex-1 truncate text-xs text-muted-foreground/60 md:block">
|
||||||
|
{item.imageUrl}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="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-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lightbox */}
|
||||||
|
{previewIdx !== null && items[previewIdx] && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
|
||||||
|
onClick={() => setPreviewIdx(null)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white"
|
||||||
|
onClick={() => setPreviewIdx(null)}
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
{items.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setPreviewIdx((previewIdx - 1 + items.length) % items.length);
|
||||||
|
}}
|
||||||
|
className="absolute left-4 top-1/2 z-10 -translate-y-1/2 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setPreviewIdx((previewIdx + 1) % items.length);
|
||||||
|
}}
|
||||||
|
className="absolute right-4 top-1/2 z-10 -translate-y-1/2 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center px-16"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={items[previewIdx].imageUrl}
|
||||||
|
alt=""
|
||||||
|
className="max-h-[85vh] max-w-[90vw] rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
<span className="mt-2 text-xs text-white/40">
|
||||||
|
{previewIdx + 1} / {items.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,9 +15,12 @@ const MDEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false });
|
|||||||
interface ArticleData {
|
interface ArticleData {
|
||||||
id?: string;
|
id?: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
titleEn?: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
summaryEn?: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
contentEn?: string;
|
||||||
coverImage: string | null;
|
coverImage: string | null;
|
||||||
published: boolean;
|
published: boolean;
|
||||||
}
|
}
|
||||||
@@ -28,9 +31,13 @@ export function ArticleEditor({ initial }: { initial?: ArticleData }) {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [title, setTitle] = useState(initial?.title ?? "");
|
const [title, setTitle] = useState(initial?.title ?? "");
|
||||||
|
const [titleEn, setTitleEn] = useState(initial?.titleEn ?? "");
|
||||||
const [slug, setSlug] = useState(initial?.slug ?? "");
|
const [slug, setSlug] = useState(initial?.slug ?? "");
|
||||||
const [summary, setSummary] = useState(initial?.summary ?? "");
|
const [summary, setSummary] = useState(initial?.summary ?? "");
|
||||||
|
const [summaryEn, setSummaryEn] = useState(initial?.summaryEn ?? "");
|
||||||
const [content, setContent] = useState(initial?.content ?? "");
|
const [content, setContent] = useState(initial?.content ?? "");
|
||||||
|
const [contentEn, setContentEn] = useState(initial?.contentEn ?? "");
|
||||||
|
const [contentTab, setContentTab] = useState<"zh" | "en">("zh");
|
||||||
const [coverImage, setCoverImage] = useState(initial?.coverImage ?? "");
|
const [coverImage, setCoverImage] = useState(initial?.coverImage ?? "");
|
||||||
const [published, setPublished] = useState(initial?.published ?? false);
|
const [published, setPublished] = useState(initial?.published ?? false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -83,9 +90,12 @@ export function ArticleEditor({ initial }: { initial?: ArticleData }) {
|
|||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
|
titleEn: titleEn.trim(),
|
||||||
slug: slug.trim(),
|
slug: slug.trim(),
|
||||||
summary: summary.trim(),
|
summary: summary.trim(),
|
||||||
|
summaryEn: summaryEn.trim(),
|
||||||
content,
|
content,
|
||||||
|
contentEn,
|
||||||
coverImage: coverImage || null,
|
coverImage: coverImage || null,
|
||||||
published: asDraft ? false : published,
|
published: asDraft ? false : published,
|
||||||
};
|
};
|
||||||
@@ -117,8 +127,9 @@ export function ArticleEditor({ initial }: { initial?: ArticleData }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">标题</Label>
|
<Label htmlFor="title">标题(中文)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
value={title}
|
value={title}
|
||||||
@@ -126,6 +137,16 @@ export function ArticleEditor({ initial }: { initial?: ArticleData }) {
|
|||||||
placeholder="文章标题"
|
placeholder="文章标题"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="titleEn">Title (English)</Label>
|
||||||
|
<Input
|
||||||
|
id="titleEn"
|
||||||
|
value={titleEn}
|
||||||
|
onChange={(e) => setTitleEn(e.target.value)}
|
||||||
|
placeholder="Article title (falls back to Chinese if empty)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Slug */}
|
{/* Slug */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -142,15 +163,26 @@ export function ArticleEditor({ initial }: { initial?: ArticleData }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="summary">摘要</Label>
|
<Label htmlFor="summary">摘要(中文)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="summary"
|
id="summary"
|
||||||
value={summary}
|
value={summary}
|
||||||
onChange={(e) => setSummary(e.target.value)}
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
placeholder="文章摘要(可选,用于列表展示)"
|
placeholder="文章摘要(可选)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="summaryEn">Summary (English)</Label>
|
||||||
|
<Input
|
||||||
|
id="summaryEn"
|
||||||
|
value={summaryEn}
|
||||||
|
onChange={(e) => setSummaryEn(e.target.value)}
|
||||||
|
placeholder="Falls back to Chinese if empty"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Cover Image */}
|
{/* Cover Image */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -201,10 +233,30 @@ export function ArticleEditor({ initial }: { initial?: ArticleData }) {
|
|||||||
<Label htmlFor="published">{published ? "已发布" : "草稿"}</Label>
|
<Label htmlFor="published">{published ? "已发布" : "草稿"}</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Markdown Editor */}
|
{/* Markdown Editor with zh/en tabs */}
|
||||||
<div className="space-y-2" data-color-mode="light">
|
<div className="space-y-2" data-color-mode="light">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Label>内容 (Markdown)</Label>
|
<Label>内容 (Markdown)</Label>
|
||||||
|
<div className="inline-flex rounded-md border bg-muted p-0.5 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setContentTab("zh")}
|
||||||
|
className={`rounded px-3 py-1 transition-colors ${contentTab === "zh" ? "bg-background shadow-sm" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
中文
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setContentTab("en")}
|
||||||
|
className={`rounded px-3 py-1 transition-colors ${contentTab === "en" ? "bg-background shadow-sm" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{contentTab === "zh" ? (
|
||||||
<MDEditor
|
<MDEditor
|
||||||
|
key="content-zh"
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(val) => setContent(val || "")}
|
onChange={(val) => setContent(val || "")}
|
||||||
height={500}
|
height={500}
|
||||||
@@ -237,8 +289,44 @@ export function ArticleEditor({ initial }: { initial?: ArticleData }) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<MDEditor
|
||||||
|
key="content-en"
|
||||||
|
value={contentEn}
|
||||||
|
onChange={(val) => setContentEn(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) {
|
||||||
|
setContentEn((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) {
|
||||||
|
setContentEn(
|
||||||
|
(prev) => prev + `\n\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
支持拖拽或粘贴图片自动上传
|
支持拖拽或粘贴图片自动上传 · 英文为空时前台回退到中文
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface MediaItem {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
titleEn?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -244,12 +245,13 @@ export function MediaManager({ type, initial }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title (gallery only) */}
|
{/* Title (gallery only) - bilingual */}
|
||||||
{type === "gallery" && (
|
{type === "gallery" && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
<Input
|
<Input
|
||||||
value={item.title ?? ""}
|
value={item.title ?? ""}
|
||||||
placeholder="标题(可选)"
|
placeholder="中文标题"
|
||||||
className="h-8 max-w-[180px] text-xs"
|
className="h-7 max-w-[180px] text-xs"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((it) =>
|
prev.map((it) =>
|
||||||
@@ -261,6 +263,24 @@ export function MediaManager({ type, initial }: Props) {
|
|||||||
}
|
}
|
||||||
onBlur={() => handleUpdate(item.id, { title: item.title })}
|
onBlur={() => handleUpdate(item.id, { title: item.title })}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
value={item.titleEn ?? ""}
|
||||||
|
placeholder="English title"
|
||||||
|
className="h-7 max-w-[180px] text-xs"
|
||||||
|
onChange={(e) =>
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((it) =>
|
||||||
|
it.id === item.id
|
||||||
|
? { ...it, titleEn: e.target.value }
|
||||||
|
: it
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onBlur={() =>
|
||||||
|
handleUpdate(item.id, { titleEn: item.titleEn })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* URL display */}
|
{/* URL display */}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Upload } from "lucide-react";
|
import { Upload } from "lucide-react";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
|
||||||
interface ReleaseFormProps {
|
interface ReleaseFormProps {
|
||||||
addons: { id: string; name: string }[];
|
addons: { id: string; name: string }[];
|
||||||
@@ -27,6 +28,7 @@ export function ReleaseForm({ addons }: ReleaseFormProps) {
|
|||||||
const [downloadType, setDownloadType] = useState("local");
|
const [downloadType, setDownloadType] = useState("local");
|
||||||
const [uploadedFilePath, setUploadedFilePath] = useState("");
|
const [uploadedFilePath, setUploadedFilePath] = useState("");
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [selectedAddonId, setSelectedAddonId] = useState("");
|
||||||
|
|
||||||
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -57,9 +59,11 @@ export function ReleaseForm({ addons }: ReleaseFormProps) {
|
|||||||
|
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const data = {
|
const data = {
|
||||||
addonId: formData.get("addonId"),
|
addonId: selectedAddonId || formData.get("addonId"),
|
||||||
version: formData.get("version"),
|
version: formData.get("version"),
|
||||||
changelog: formData.get("changelog"),
|
changelog: formData.get("changelog"),
|
||||||
|
changelogEn: formData.get("changelogEn") || "",
|
||||||
|
wowVersion: DEFAULT_WOW_VERSION,
|
||||||
downloadType,
|
downloadType,
|
||||||
filePath: downloadType === "local" ? uploadedFilePath : null,
|
filePath: downloadType === "local" ? uploadedFilePath : null,
|
||||||
externalUrl:
|
externalUrl:
|
||||||
@@ -67,6 +71,12 @@ export function ReleaseForm({ addons }: ReleaseFormProps) {
|
|||||||
gameVersion: formData.get("gameVersion"),
|
gameVersion: formData.get("gameVersion"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!selectedAddonId) {
|
||||||
|
toast.error("请选择一个插件");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (downloadType === "local" && !uploadedFilePath) {
|
if (downloadType === "local" && !uploadedFilePath) {
|
||||||
toast.error("请先上传文件");
|
toast.error("请先上传文件");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -101,9 +111,18 @@ export function ReleaseForm({ addons }: ReleaseFormProps) {
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="addonId">选择插件 *</Label>
|
<Label htmlFor="addonId">选择插件 *</Label>
|
||||||
<Select name="addonId" required>
|
<input type="hidden" name="addonId" value={selectedAddonId} />
|
||||||
|
<Select
|
||||||
|
value={selectedAddonId}
|
||||||
|
onValueChange={(v) => setSelectedAddonId(v ?? "")}
|
||||||
|
required
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="选择一个插件" />
|
<SelectValue placeholder="选择一个插件">
|
||||||
|
{selectedAddonId
|
||||||
|
? (addons.find((a) => a.id === selectedAddonId)?.name ?? "选择一个插件")
|
||||||
|
: "选择一个插件"}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{addons.map((addon) => (
|
{addons.map((addon) => (
|
||||||
@@ -125,17 +144,20 @@ export function ReleaseForm({ addons }: ReleaseFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="gameVersion">兼容游戏版本</Label>
|
<Label htmlFor="gameVersion">兼容游戏版本(自由文本)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="gameVersion"
|
id="gameVersion"
|
||||||
name="gameVersion"
|
name="gameVersion"
|
||||||
placeholder="11.1.0"
|
placeholder="例如 1.18.1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="changelog">更新日志</Label>
|
<Label htmlFor="changelog">更新日志(中文)</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="changelog"
|
id="changelog"
|
||||||
name="changelog"
|
name="changelog"
|
||||||
@@ -143,6 +165,16 @@ export function ReleaseForm({ addons }: ReleaseFormProps) {
|
|||||||
placeholder="- 新增功能 A - 修复问题 B"
|
placeholder="- 新增功能 A - 修复问题 B"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="changelogEn">Changelog (English)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="changelogEn"
|
||||||
|
name="changelogEn"
|
||||||
|
rows={6}
|
||||||
|
placeholder="- Added feature A - Fixed issue B"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Label>下载方式</Label>
|
<Label>下载方式</Label>
|
||||||
|
|||||||
352
src/components/admin/ReleasesTable.tsx
Normal file
352
src/components/admin/ReleasesTable.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Download, Pencil, Trash2, Check, X, Upload, Star } from "lucide-react";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
|
||||||
|
interface Release {
|
||||||
|
id: string;
|
||||||
|
version: string;
|
||||||
|
changelog: string;
|
||||||
|
changelogEn: string;
|
||||||
|
wowVersion: string;
|
||||||
|
downloadType: string;
|
||||||
|
filePath: string | null;
|
||||||
|
externalUrl: string | null;
|
||||||
|
gameVersion: string;
|
||||||
|
downloadCount: number;
|
||||||
|
isLatest: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
addon: { name: string; slug: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReleasesTable({ releases: initial }: { releases: Release[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSetLatest(releaseId: string) {
|
||||||
|
const res = await fetch(`/api/releases/${releaseId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ isLatest: true }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success("已设为最新版本");
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error("设置失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(r: Release) {
|
||||||
|
if (!confirm(`确定要删除 ${r.addon.name} v${r.version} 吗?此操作不可撤销。`)) return;
|
||||||
|
const res = await fetch(`/api/releases/${r.id}`, { method: "DELETE" });
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success("版本已删除");
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error("删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>插件</TableHead>
|
||||||
|
<TableHead>版本</TableHead>
|
||||||
|
<TableHead>游戏版本</TableHead>
|
||||||
|
<TableHead>下载方式</TableHead>
|
||||||
|
<TableHead>下载量</TableHead>
|
||||||
|
<TableHead>发布时间</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{initial.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||||
|
暂无版本发布
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
initial.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell className="font-medium">{r.addon.name}</TableCell>
|
||||||
|
<TableCell>v{r.version}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{r.gameVersion || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{r.downloadType === "local" ? "本地文件" : "外部链接"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Download className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{r.downloadCount}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{new Date(r.createdAt).toLocaleDateString("zh-CN")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{r.isLatest && <Badge>最新</Badge>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{!r.isLatest && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
title="设为最新版本"
|
||||||
|
onClick={() => handleSetLatest(r.id)}
|
||||||
|
>
|
||||||
|
<Star className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
title="编辑"
|
||||||
|
onClick={() => setEditingId(editingId === r.id ? null : r.id)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
title="删除"
|
||||||
|
onClick={() => handleDelete(r)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{editingId && (
|
||||||
|
<ReleaseEditPanel
|
||||||
|
release={initial.find((r) => r.id === editingId)!}
|
||||||
|
onClose={() => setEditingId(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReleaseEditPanel({
|
||||||
|
release: r,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
release: Release;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [version, setVersion] = useState(r.version);
|
||||||
|
const [changelog, setChangelog] = useState(r.changelog);
|
||||||
|
const [changelogEn, setChangelogEn] = useState(r.changelogEn || "");
|
||||||
|
const [gameVersion, setGameVersion] = useState(r.gameVersion);
|
||||||
|
const [downloadType, setDownloadType] = useState(r.downloadType);
|
||||||
|
const [externalUrl, setExternalUrl] = useState(r.externalUrl || "");
|
||||||
|
const [filePath, setFilePath] = useState(r.filePath || "");
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch("/api/upload", { method: "POST", body: fd });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setFilePath(data.filePath);
|
||||||
|
toast.success(`文件 ${data.originalName} 上传成功`);
|
||||||
|
} else {
|
||||||
|
toast.error("文件上传失败");
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
if (downloadType === "local" && !filePath) {
|
||||||
|
toast.error("请先上传文件");
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (downloadType === "url" && !externalUrl) {
|
||||||
|
toast.error("请输入外部链接");
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/releases/${r.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
version,
|
||||||
|
changelog,
|
||||||
|
changelogEn,
|
||||||
|
wowVersion: DEFAULT_WOW_VERSION,
|
||||||
|
gameVersion,
|
||||||
|
downloadType,
|
||||||
|
filePath: downloadType === "local" ? filePath : null,
|
||||||
|
externalUrl: downloadType === "url" ? externalUrl : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success("版本更新成功");
|
||||||
|
onSaved();
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
toast.error(err.error || "更新失败");
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t bg-muted/30 p-6">
|
||||||
|
<div className="mx-auto max-w-2xl space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold">
|
||||||
|
编辑 {r.addon.name} v{r.version}
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>版本号</Label>
|
||||||
|
<Input value={version} onChange={(e) => setVersion(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>兼容游戏版本</Label>
|
||||||
|
<Input
|
||||||
|
value={gameVersion}
|
||||||
|
onChange={(e) => setGameVersion(e.target.value)}
|
||||||
|
placeholder="1.18.1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>更新日志(中文)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={changelog}
|
||||||
|
onChange={(e) => setChangelog(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Changelog (English)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={changelogEn}
|
||||||
|
onChange={(e) => setChangelogEn(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
placeholder="为空则回退到中文 / Falls back to Chinese if empty"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>下载方式</Label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={downloadType === "local" ? "default" : "outline"}
|
||||||
|
onClick={() => setDownloadType("local")}
|
||||||
|
>
|
||||||
|
上传文件
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={downloadType === "url" ? "default" : "outline"}
|
||||||
|
onClick={() => setDownloadType("url")}
|
||||||
|
>
|
||||||
|
外部链接
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{downloadType === "local" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor={`edit-release-file-${r.id}`}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-lg border-2 border-dashed px-4 py-3 text-sm transition-colors hover:border-primary"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
{uploading ? "上传中..." : filePath ? "重新选择文件" : "选择文件"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`edit-release-file-${r.id}`}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept=".zip,.rar,.7z,.tar.gz"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
/>
|
||||||
|
{filePath && (
|
||||||
|
<p className="text-xs text-muted-foreground">文件: {filePath}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
value={externalUrl}
|
||||||
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||||
|
<Check className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{saving ? "保存中..." : "保存修改"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,17 +10,21 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Pencil, Trash2, Upload, X, Check } from "lucide-react";
|
import { Pencil, Trash2, Upload, X, Check } from "lucide-react";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
|
||||||
interface SoftwareVersion {
|
interface SoftwareVersion {
|
||||||
id: string;
|
id: string;
|
||||||
version: string;
|
version: string;
|
||||||
versionCode: number;
|
versionCode: number;
|
||||||
changelog: string;
|
changelog: string;
|
||||||
|
changelogEn: string;
|
||||||
|
wowVersion: string;
|
||||||
downloadType: string;
|
downloadType: string;
|
||||||
filePath: string | null;
|
filePath: string | null;
|
||||||
externalUrl: string | null;
|
externalUrl: string | null;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
downloadCount: number;
|
downloadCount: number;
|
||||||
|
launcherDownloadCount: number;
|
||||||
isLatest: boolean;
|
isLatest: boolean;
|
||||||
forceUpdate: boolean;
|
forceUpdate: boolean;
|
||||||
minVersion: string | null;
|
minVersion: string | null;
|
||||||
@@ -139,6 +143,7 @@ function VersionItem({ version: v }: { version: SoftwareVersion }) {
|
|||||||
const [version, setVersion] = useState(v.version);
|
const [version, setVersion] = useState(v.version);
|
||||||
const [versionCode, setVersionCode] = useState(v.versionCode.toString());
|
const [versionCode, setVersionCode] = useState(v.versionCode.toString());
|
||||||
const [changelog, setChangelog] = useState(v.changelog);
|
const [changelog, setChangelog] = useState(v.changelog);
|
||||||
|
const [changelogEn, setChangelogEn] = useState(v.changelogEn || "");
|
||||||
const [downloadType, setDownloadType] = useState(v.downloadType);
|
const [downloadType, setDownloadType] = useState(v.downloadType);
|
||||||
const [externalUrl, setExternalUrl] = useState(v.externalUrl || "");
|
const [externalUrl, setExternalUrl] = useState(v.externalUrl || "");
|
||||||
const [forceUpdate, setForceUpdate] = useState(v.forceUpdate);
|
const [forceUpdate, setForceUpdate] = useState(v.forceUpdate);
|
||||||
@@ -184,6 +189,8 @@ function VersionItem({ version: v }: { version: SoftwareVersion }) {
|
|||||||
version,
|
version,
|
||||||
versionCode: Number(versionCode),
|
versionCode: Number(versionCode),
|
||||||
changelog,
|
changelog,
|
||||||
|
changelogEn,
|
||||||
|
wowVersion: DEFAULT_WOW_VERSION,
|
||||||
downloadType,
|
downloadType,
|
||||||
filePath: downloadType === "local" ? filePath : null,
|
filePath: downloadType === "local" ? filePath : null,
|
||||||
externalUrl: downloadType === "url" ? externalUrl : null,
|
externalUrl: downloadType === "url" ? externalUrl : null,
|
||||||
@@ -226,11 +233,14 @@ function VersionItem({ version: v }: { version: SoftwareVersion }) {
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="font-semibold">v{v.version}</span>
|
<span className="font-semibold">v{v.version}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
(code: {v.versionCode})
|
(code: {v.versionCode})
|
||||||
</span>
|
</span>
|
||||||
|
<Badge variant="outline" className="border-amber-500/40 text-amber-300/90 text-xs">
|
||||||
|
WoW {DEFAULT_WOW_VERSION}
|
||||||
|
</Badge>
|
||||||
{v.isLatest && <Badge>最新</Badge>}
|
{v.isLatest && <Badge>最新</Badge>}
|
||||||
{v.forceUpdate && (
|
{v.forceUpdate && (
|
||||||
<Badge variant="destructive">强制更新</Badge>
|
<Badge variant="destructive">强制更新</Badge>
|
||||||
@@ -260,7 +270,7 @@ function VersionItem({ version: v }: { version: SoftwareVersion }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{v.downloadCount} 次下载 ·{" "}
|
直接下载 {v.downloadCount - v.launcherDownloadCount} · 软件更新 {v.launcherDownloadCount} ·{" "}
|
||||||
{new Date(v.createdAt).toLocaleDateString("zh-CN")}
|
{new Date(v.createdAt).toLocaleDateString("zh-CN")}
|
||||||
</p>
|
</p>
|
||||||
{v.changelog && (
|
{v.changelog && (
|
||||||
@@ -306,6 +316,7 @@ function VersionItem({ version: v }: { version: SoftwareVersion }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>最低兼容版本</Label>
|
<Label>最低兼容版本</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -314,15 +325,27 @@ function VersionItem({ version: v }: { version: SoftwareVersion }) {
|
|||||||
placeholder="可选"
|
placeholder="可选"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>更新日志</Label>
|
<Label>更新日志(中文)</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={changelog}
|
value={changelog}
|
||||||
onChange={(e) => setChangelog(e.target.value)}
|
onChange={(e) => setChangelog(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Changelog (English)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={changelogEn}
|
||||||
|
onChange={(e) => setChangelogEn(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="为空则回退到中文 / Falls back to Chinese if empty"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>下载方式</Label>
|
<Label>下载方式</Label>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Upload } from "lucide-react";
|
import { Upload } from "lucide-react";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
|
||||||
export function SoftwareVersionForm({ softwareId }: { softwareId: string }) {
|
export function SoftwareVersionForm({ softwareId }: { softwareId: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -57,12 +58,14 @@ export function SoftwareVersionForm({ softwareId }: { softwareId: string }) {
|
|||||||
version: fd.get("version"),
|
version: fd.get("version"),
|
||||||
versionCode: Number(fd.get("versionCode")),
|
versionCode: Number(fd.get("versionCode")),
|
||||||
changelog: fd.get("changelog"),
|
changelog: fd.get("changelog"),
|
||||||
|
changelogEn: fd.get("changelogEn"),
|
||||||
downloadType,
|
downloadType,
|
||||||
filePath: downloadType === "local" ? uploadedFilePath : null,
|
filePath: downloadType === "local" ? uploadedFilePath : null,
|
||||||
externalUrl: downloadType === "url" ? fd.get("externalUrl") : null,
|
externalUrl: downloadType === "url" ? fd.get("externalUrl") : null,
|
||||||
fileSize,
|
fileSize,
|
||||||
forceUpdate: fd.get("forceUpdate") === "on",
|
forceUpdate: fd.get("forceUpdate") === "on",
|
||||||
minVersion: fd.get("minVersion") || null,
|
minVersion: fd.get("minVersion") || null,
|
||||||
|
wowVersion: DEFAULT_WOW_VERSION,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,9 +101,28 @@ export function SoftwareVersionForm({ softwareId }: { softwareId: string }) {
|
|||||||
<Input id="minVersion" name="minVersion" placeholder="0.9.0(可选,低于此版本需升级)" />
|
<Input id="minVersion" name="minVersion" placeholder="0.9.0(可选,低于此版本需升级)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="changelog">更新日志</Label>
|
<Label htmlFor="changelog">更新日志(中文)</Label>
|
||||||
<Textarea id="changelog" name="changelog" rows={6} placeholder="- 新增xxx功能 - 修复xxx问题" />
|
<Textarea
|
||||||
|
id="changelog"
|
||||||
|
name="changelog"
|
||||||
|
rows={6}
|
||||||
|
placeholder="- 新增xxx功能 - 修复xxx问题"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="changelogEn">Changelog (English)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="changelogEn"
|
||||||
|
name="changelogEn"
|
||||||
|
rows={6}
|
||||||
|
placeholder="- Added xxx feature - Fixed xxx issue"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
英文用户访问时显示;为空则回退到中文。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -25,17 +25,21 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
Star,
|
Star,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
|
||||||
interface Version {
|
interface Version {
|
||||||
id: string;
|
id: string;
|
||||||
version: string;
|
version: string;
|
||||||
versionCode: number;
|
versionCode: number;
|
||||||
changelog: string;
|
changelog: string;
|
||||||
|
changelogEn: string;
|
||||||
|
wowVersion: string;
|
||||||
downloadType: string;
|
downloadType: string;
|
||||||
filePath: string | null;
|
filePath: string | null;
|
||||||
externalUrl: string | null;
|
externalUrl: string | null;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
downloadCount: number;
|
downloadCount: number;
|
||||||
|
launcherDownloadCount: number;
|
||||||
isLatest: boolean;
|
isLatest: boolean;
|
||||||
forceUpdate: boolean;
|
forceUpdate: boolean;
|
||||||
minVersion: string | null;
|
minVersion: string | null;
|
||||||
@@ -85,7 +89,8 @@ export function SoftwareVersionTable({
|
|||||||
<TableHead>版本</TableHead>
|
<TableHead>版本</TableHead>
|
||||||
<TableHead>版本代码</TableHead>
|
<TableHead>版本代码</TableHead>
|
||||||
<TableHead>下载方式</TableHead>
|
<TableHead>下载方式</TableHead>
|
||||||
<TableHead>下载量</TableHead>
|
<TableHead>直接下载</TableHead>
|
||||||
|
<TableHead>软件更新</TableHead>
|
||||||
<TableHead>强制更新</TableHead>
|
<TableHead>强制更新</TableHead>
|
||||||
<TableHead>发布时间</TableHead>
|
<TableHead>发布时间</TableHead>
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
@@ -95,7 +100,7 @@ export function SoftwareVersionTable({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={8}
|
colSpan={9}
|
||||||
className="text-center text-muted-foreground"
|
className="text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
暂无版本,点击上方按钮发布第一个版本
|
暂无版本,点击上方按钮发布第一个版本
|
||||||
@@ -114,7 +119,8 @@ export function SoftwareVersionTable({
|
|||||||
<TableHead>版本</TableHead>
|
<TableHead>版本</TableHead>
|
||||||
<TableHead>版本代码</TableHead>
|
<TableHead>版本代码</TableHead>
|
||||||
<TableHead>下载方式</TableHead>
|
<TableHead>下载方式</TableHead>
|
||||||
<TableHead>下载量</TableHead>
|
<TableHead>直接下载</TableHead>
|
||||||
|
<TableHead>软件更新</TableHead>
|
||||||
<TableHead>强制更新</TableHead>
|
<TableHead>强制更新</TableHead>
|
||||||
<TableHead>发布时间</TableHead>
|
<TableHead>发布时间</TableHead>
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
@@ -136,7 +142,13 @@ export function SoftwareVersionTable({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Download className="h-3.5 w-3.5 text-muted-foreground" />
|
<Download className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
{v.downloadCount}
|
{v.downloadCount - v.launcherDownloadCount}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Download className="h-3.5 w-3.5 text-blue-500" />
|
||||||
|
{v.launcherDownloadCount}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -215,6 +227,7 @@ function VersionEditPanel({
|
|||||||
const [version, setVersion] = useState(v.version);
|
const [version, setVersion] = useState(v.version);
|
||||||
const [versionCode, setVersionCode] = useState(v.versionCode.toString());
|
const [versionCode, setVersionCode] = useState(v.versionCode.toString());
|
||||||
const [changelog, setChangelog] = useState(v.changelog);
|
const [changelog, setChangelog] = useState(v.changelog);
|
||||||
|
const [changelogEn, setChangelogEn] = useState(v.changelogEn || "");
|
||||||
const [downloadType, setDownloadType] = useState(v.downloadType);
|
const [downloadType, setDownloadType] = useState(v.downloadType);
|
||||||
const [externalUrl, setExternalUrl] = useState(v.externalUrl || "");
|
const [externalUrl, setExternalUrl] = useState(v.externalUrl || "");
|
||||||
const [forceUpdate, setForceUpdate] = useState(v.forceUpdate);
|
const [forceUpdate, setForceUpdate] = useState(v.forceUpdate);
|
||||||
@@ -261,6 +274,8 @@ function VersionEditPanel({
|
|||||||
version,
|
version,
|
||||||
versionCode: Number(versionCode),
|
versionCode: Number(versionCode),
|
||||||
changelog,
|
changelog,
|
||||||
|
changelogEn,
|
||||||
|
wowVersion: DEFAULT_WOW_VERSION,
|
||||||
downloadType,
|
downloadType,
|
||||||
filePath: downloadType === "local" ? filePath : null,
|
filePath: downloadType === "local" ? filePath : null,
|
||||||
externalUrl: downloadType === "url" ? externalUrl : null,
|
externalUrl: downloadType === "url" ? externalUrl : null,
|
||||||
@@ -306,6 +321,7 @@ function VersionEditPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>最低兼容版本</Label>
|
<Label>最低兼容版本</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -314,15 +330,27 @@ function VersionEditPanel({
|
|||||||
placeholder="可选"
|
placeholder="可选"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>更新日志</Label>
|
<Label>更新日志(中文)</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={changelog}
|
value={changelog}
|
||||||
onChange={(e) => setChangelog(e.target.value)}
|
onChange={(e) => setChangelog(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Changelog (English)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={changelogEn}
|
||||||
|
onChange={(e) => setChangelogEn(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="为空则回退到中文 / Falls back to Chinese if empty"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>下载方式</Label>
|
<Label>下载方式</Label>
|
||||||
|
|||||||
@@ -1,32 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Download, Package } from "lucide-react";
|
import { Download, Package } from "lucide-react";
|
||||||
|
import { useLocale } from "@/i18n/LocaleProvider";
|
||||||
|
import type { Messages } from "@/i18n/messages";
|
||||||
|
|
||||||
interface AddonCardProps {
|
interface AddonCardProps {
|
||||||
addon: {
|
addon: {
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
nameEn?: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
summaryEn?: string;
|
||||||
iconUrl: string | null;
|
iconUrl: string | null;
|
||||||
category: string;
|
category: string;
|
||||||
totalDownloads: number;
|
totalDownloads: number;
|
||||||
releases: { version: string }[];
|
releases: { version: string }[];
|
||||||
|
screenshots?: { imageUrl: string }[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = {
|
function pickLocale(zh: string, en: string | undefined, locale: "zh" | "en") {
|
||||||
general: "通用",
|
if (locale === "en" && en && en.trim()) return en;
|
||||||
gameplay: "游戏玩法",
|
return zh;
|
||||||
ui: "界面增强",
|
}
|
||||||
combat: "战斗",
|
|
||||||
raid: "团队副本",
|
|
||||||
pvp: "PvP",
|
|
||||||
tradeskill: "专业技能",
|
|
||||||
utility: "实用工具",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AddonCard({ addon }: AddonCardProps) {
|
export function AddonCard({ addon }: AddonCardProps) {
|
||||||
|
const { locale, t } = useLocale();
|
||||||
const latestVersion = addon.releases?.[0]?.version;
|
const latestVersion = addon.releases?.[0]?.version;
|
||||||
|
const displayIcon = addon.iconUrl || addon.screenshots?.[0]?.imageUrl || null;
|
||||||
|
const displayName = pickLocale(addon.name, addon.nameEn, locale);
|
||||||
|
const displaySummary = pickLocale(addon.summary, addon.summaryEn, locale);
|
||||||
|
// t() returns "category.<key>" sentinel when the key isn't in the dictionary.
|
||||||
|
const categoryKey = addon.category as keyof Messages["category"];
|
||||||
|
const labelFromDict = t("category", categoryKey);
|
||||||
|
const categoryLabel = labelFromDict.startsWith("category.")
|
||||||
|
? addon.category
|
||||||
|
: labelFromDict;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -35,11 +46,11 @@ export function AddonCard({ addon }: AddonCardProps) {
|
|||||||
>
|
>
|
||||||
<div className="p-4 sm:p-5">
|
<div className="p-4 sm:p-5">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{addon.iconUrl ? (
|
{displayIcon ? (
|
||||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg ring-1 ring-amber-500/15">
|
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg ring-1 ring-amber-500/15">
|
||||||
<Image
|
<Image
|
||||||
src={addon.iconUrl}
|
src={displayIcon}
|
||||||
alt={addon.name}
|
alt={displayName}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
sizes="48px"
|
sizes="48px"
|
||||||
@@ -52,11 +63,11 @@ export function AddonCard({ addon }: AddonCardProps) {
|
|||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="truncate font-semibold text-amber-100 transition-colors group-hover:text-amber-200">
|
<h3 className="truncate font-semibold text-amber-100 transition-colors group-hover:text-amber-200">
|
||||||
{addon.name}
|
{displayName}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
<span className="rounded-md bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-300/80">
|
<span className="rounded-md bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-300/80">
|
||||||
{categoryLabels[addon.category] || addon.category}
|
{categoryLabel}
|
||||||
</span>
|
</span>
|
||||||
{latestVersion && (
|
{latestVersion && (
|
||||||
<span className="text-[10px] text-gray-500">
|
<span className="text-[10px] text-gray-500">
|
||||||
@@ -68,12 +79,15 @@ export function AddonCard({ addon }: AddonCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-gray-400">
|
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-gray-400">
|
||||||
{addon.summary}
|
{displaySummary}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center gap-1.5 text-xs text-gray-500">
|
<div className="mt-3 flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
<Download className="h-3 w-3" />
|
<Download className="h-3 w-3" />
|
||||||
<span>{addon.totalDownloads.toLocaleString()} 次下载</span>
|
<span>
|
||||||
|
{addon.totalDownloads.toLocaleString()}
|
||||||
|
{t("addons", "downloadsCount")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
247
src/components/public/AddonDetail.tsx
Normal file
247
src/components/public/AddonDetail.tsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Download, Package, Calendar, Tag, ArrowLeft } from "lucide-react";
|
||||||
|
import { DownloadButton } from "@/components/public/DownloadButton";
|
||||||
|
import { MarkdownContent } from "@/components/public/MarkdownContent";
|
||||||
|
import { useLocale } from "@/i18n/LocaleProvider";
|
||||||
|
import type { Messages } from "@/i18n/messages";
|
||||||
|
|
||||||
|
interface ReleaseLite {
|
||||||
|
id: string;
|
||||||
|
version: string;
|
||||||
|
changelog: string;
|
||||||
|
changelogEn: string;
|
||||||
|
gameVersion: string;
|
||||||
|
wowVersion: string;
|
||||||
|
downloadCount: number;
|
||||||
|
isLatest: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScreenshotLite {
|
||||||
|
id: string;
|
||||||
|
imageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddonDetailProps {
|
||||||
|
addon: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
nameEn: string;
|
||||||
|
summary: string;
|
||||||
|
summaryEn: string;
|
||||||
|
description: string;
|
||||||
|
descriptionEn: string;
|
||||||
|
iconUrl: string | null;
|
||||||
|
category: string;
|
||||||
|
totalDownloads: number;
|
||||||
|
wowVersion: string;
|
||||||
|
releases: ReleaseLite[];
|
||||||
|
screenshots: ScreenshotLite[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLocale(zh: string, en: string, locale: "zh" | "en") {
|
||||||
|
if (locale === "en" && en && en.trim()) return en;
|
||||||
|
return zh;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddonDetail({ addon }: AddonDetailProps) {
|
||||||
|
const { locale, t } = useLocale();
|
||||||
|
const dateLocale = locale === "en" ? "en-US" : "zh-CN";
|
||||||
|
|
||||||
|
const displayName = pickLocale(addon.name, addon.nameEn, locale);
|
||||||
|
const displaySummary = pickLocale(addon.summary, addon.summaryEn, locale);
|
||||||
|
const displayDescription = pickLocale(
|
||||||
|
addon.description,
|
||||||
|
addon.descriptionEn,
|
||||||
|
locale
|
||||||
|
);
|
||||||
|
const categoryKey = addon.category as keyof Messages["category"];
|
||||||
|
const fromDict = t("category", categoryKey);
|
||||||
|
const categoryLabel = fromDict.startsWith("category.") ? addon.category : fromDict;
|
||||||
|
|
||||||
|
const latestRelease = addon.releases.find((r) => r.isLatest);
|
||||||
|
const displayIcon =
|
||||||
|
addon.iconUrl || addon.screenshots?.[0]?.imageUrl || null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
|
||||||
|
<Link
|
||||||
|
href="/addons"
|
||||||
|
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" />
|
||||||
|
{t("addons", "back")}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-5 rounded-xl border border-amber-500/10 bg-white/[0.03] p-5 sm:flex-row sm:items-start sm:p-6">
|
||||||
|
{displayIcon ? (
|
||||||
|
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl ring-1 ring-amber-500/20 sm:h-20 sm:w-20">
|
||||||
|
<Image
|
||||||
|
src={displayIcon}
|
||||||
|
alt={displayName}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="80px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 ring-1 ring-amber-500/20 sm:h-20 sm:w-20">
|
||||||
|
<Package className="h-8 w-8 text-amber-400 sm:h-10 sm:w-10" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold text-amber-100 sm:text-3xl">
|
||||||
|
{displayName}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-400 sm:text-base">
|
||||||
|
{displaySummary}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||||
|
<span className="rounded-md bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-300/80">
|
||||||
|
{categoryLabel}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-200">
|
||||||
|
WoW {addon.wowVersion}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-sm text-gray-500">
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
{addon.totalDownloads.toLocaleString()}
|
||||||
|
{t("addons", "downloadsCount")}
|
||||||
|
</span>
|
||||||
|
{latestRelease && (
|
||||||
|
<span className="flex items-center gap-1 text-sm text-gray-500">
|
||||||
|
<Tag className="h-3.5 w-3.5" />
|
||||||
|
v{latestRelease.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{latestRelease && (
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DownloadButton
|
||||||
|
releaseId={latestRelease.id}
|
||||||
|
version={latestRelease.version}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 border-t border-amber-500/10" />
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-6 lg:grid-cols-3 sm:mt-8">
|
||||||
|
{/* Description */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="rounded-xl border border-amber-500/10 bg-white/[0.03] p-5 sm:p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-amber-100">
|
||||||
|
{t("addons", "introduction")}
|
||||||
|
</h2>
|
||||||
|
<MarkdownContent content={displayDescription} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Screenshots */}
|
||||||
|
{addon.screenshots.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-amber-500/10 bg-white/[0.03] p-5 sm:p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-amber-100">
|
||||||
|
{t("addons", "screenshots")}
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{addon.screenshots.map((ss) => (
|
||||||
|
<div
|
||||||
|
key={ss.id}
|
||||||
|
className="overflow-hidden rounded-lg ring-1 ring-amber-500/10"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={ss.imageUrl}
|
||||||
|
alt="Screenshot"
|
||||||
|
width={600}
|
||||||
|
height={340}
|
||||||
|
className="w-full object-cover transition-transform duration-300 hover:scale-105"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar - Releases */}
|
||||||
|
<div>
|
||||||
|
<div className="rounded-xl border border-amber-500/10 bg-white/[0.03] p-5 sm:p-6">
|
||||||
|
<h2 className="mb-1 text-lg font-semibold text-amber-100">
|
||||||
|
{t("addons", "releaseHistory")}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4 text-sm text-gray-500">
|
||||||
|
{t("addons", "releaseCount", { n: addon.releases.length })}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{addon.releases.map((release) => {
|
||||||
|
const cl = pickLocale(
|
||||||
|
release.changelog,
|
||||||
|
release.changelogEn,
|
||||||
|
locale
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={release.id}
|
||||||
|
className="rounded-lg border border-amber-500/10 bg-white/[0.02] p-4 transition-colors hover:border-amber-500/20 hover:bg-white/[0.04]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold text-amber-100">
|
||||||
|
v{release.version}
|
||||||
|
</span>
|
||||||
|
{release.isLatest && (
|
||||||
|
<span className="rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-300">
|
||||||
|
{t("addons", "latestTag")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DownloadButton
|
||||||
|
releaseId={release.id}
|
||||||
|
version={release.version}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{release.gameVersion && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
WoW {release.gameVersion}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex items-center gap-3 text-xs text-gray-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{new Date(release.createdAt).toLocaleDateString(
|
||||||
|
dateLocale
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Download className="h-3 w-3" />
|
||||||
|
{release.downloadCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{cl && (
|
||||||
|
<p className="mt-2 whitespace-pre-line text-sm text-gray-400">
|
||||||
|
{cl}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{addon.releases.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{t("addons", "noReleases")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/public/AddonsCategoryFilter.tsx
Normal file
49
src/components/public/AddonsCategoryFilter.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useLocale } from "@/i18n/LocaleProvider";
|
||||||
|
import type { Messages } from "@/i18n/messages";
|
||||||
|
|
||||||
|
export function AddonsCategoryFilter({
|
||||||
|
active,
|
||||||
|
categories,
|
||||||
|
}: {
|
||||||
|
active: string | null;
|
||||||
|
categories: { slug: string; count: number }[];
|
||||||
|
}) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
function categoryLabel(slug: string) {
|
||||||
|
const key = slug as keyof Messages["category"];
|
||||||
|
const fromDict = t("category", key);
|
||||||
|
return fromDict.startsWith("category.") ? slug : fromDict;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex flex-wrap gap-2 sm:mb-8">
|
||||||
|
<Link
|
||||||
|
href="/addons"
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all sm:text-sm ${
|
||||||
|
!active
|
||||||
|
? "bg-amber-500/20 text-amber-200 ring-1 ring-amber-500/30"
|
||||||
|
: "bg-white/[0.04] text-gray-400 hover:bg-white/[0.08] hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("addons", "all")}
|
||||||
|
</Link>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<Link
|
||||||
|
key={cat.slug}
|
||||||
|
href={`/addons?category=${cat.slug}`}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all sm:text-sm ${
|
||||||
|
active === cat.slug
|
||||||
|
? "bg-amber-500/20 text-amber-200 ring-1 ring-amber-500/30"
|
||||||
|
: "bg-white/[0.04] text-gray-400 hover:bg-white/[0.08] hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{categoryLabel(cat.slug)} ({cat.count})
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/public/ArticleCard.tsx
Normal file
73
src/components/public/ArticleCard.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Calendar } from "lucide-react";
|
||||||
|
import { useLocale } from "@/i18n/LocaleProvider";
|
||||||
|
|
||||||
|
interface ArticleCardProps {
|
||||||
|
article: {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
titleEn: string;
|
||||||
|
summary: string;
|
||||||
|
summaryEn: string;
|
||||||
|
coverImage: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
variant?: "compact" | "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLocale(zh: string, en: string, locale: "zh" | "en") {
|
||||||
|
if (locale === "en" && en && en.trim()) return en;
|
||||||
|
return zh;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArticleCard({ article, variant = "default" }: ArticleCardProps) {
|
||||||
|
const { locale } = useLocale();
|
||||||
|
const dateLocale = locale === "en" ? "en-US" : "zh-CN";
|
||||||
|
const title = pickLocale(article.title, article.titleEn, locale);
|
||||||
|
const summary = pickLocale(article.summary, article.summaryEn, locale);
|
||||||
|
|
||||||
|
const compact = variant === "compact";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/articles/${article.slug}`}
|
||||||
|
className={`group overflow-hidden rounded-${compact ? "xl" : "lg"} border border-amber-500/10 bg-white/[0.03] transition-colors hover:border-amber-500/25`}
|
||||||
|
>
|
||||||
|
{article.coverImage && (
|
||||||
|
<div className="relative aspect-[16/9] overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={article.coverImage}
|
||||||
|
alt={title}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
sizes={compact ? "(max-width: 768px) 100vw, 33vw" : "(max-width: 640px) 100vw, 50vw"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={compact ? "p-4" : "p-4 sm:p-5"}>
|
||||||
|
<h3
|
||||||
|
className={`${
|
||||||
|
compact
|
||||||
|
? "mb-1.5 font-semibold line-clamp-1 text-amber-100 group-hover:text-amber-200"
|
||||||
|
: "mb-2 text-lg font-semibold text-amber-100 group-hover:text-amber-200 sm:text-xl"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{summary && (
|
||||||
|
<p className={`${compact ? "mb-2" : "mb-3"} line-clamp-2 text-sm text-gray-400`}>
|
||||||
|
{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(dateLocale)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/components/public/ArticleDetail.tsx
Normal file
80
src/components/public/ArticleDetail.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Calendar, ArrowLeft } from "lucide-react";
|
||||||
|
import { MarkdownContent } from "@/components/public/MarkdownContent";
|
||||||
|
import { useLocale } from "@/i18n/LocaleProvider";
|
||||||
|
|
||||||
|
interface ArticleDetailProps {
|
||||||
|
article: {
|
||||||
|
title: string;
|
||||||
|
titleEn: string;
|
||||||
|
content: string;
|
||||||
|
contentEn: string;
|
||||||
|
coverImage: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLocale(zh: string, en: string, locale: "zh" | "en") {
|
||||||
|
if (locale === "en" && en && en.trim()) return en;
|
||||||
|
return zh;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArticleDetail({ article }: ArticleDetailProps) {
|
||||||
|
const { locale, t } = useLocale();
|
||||||
|
const dateLocale = locale === "en" ? "en-US" : "zh-CN";
|
||||||
|
const title = pickLocale(article.title, article.titleEn, locale);
|
||||||
|
const content = pickLocale(article.content, article.contentEn, locale);
|
||||||
|
|
||||||
|
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" />
|
||||||
|
{t("articles", "back")}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mb-3 text-2xl font-bold text-amber-100 sm:text-3xl">
|
||||||
|
{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(dateLocale, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{article.coverImage && (
|
||||||
|
<div className="relative mb-8 aspect-[16/9] overflow-hidden rounded-lg">
|
||||||
|
<Image
|
||||||
|
src={article.coverImage}
|
||||||
|
alt={title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 768px) 100vw, 750px"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MarkdownContent content={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" />
|
||||||
|
{t("articles", "back")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
src/components/public/BgmPlayer.tsx
Normal file
159
src/components/public/BgmPlayer.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Play, Pause, Volume2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface BgmPlayerProps {
|
||||||
|
src: string;
|
||||||
|
autoplay: boolean;
|
||||||
|
volume: number; // 0–100
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "nanami.bgm.userPaused";
|
||||||
|
|
||||||
|
export function BgmPlayer({ src, autoplay, volume }: BgmPlayerProps) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [showVolume, setShowVolume] = useState(false);
|
||||||
|
const [vol, setVol] = useState<number>(Math.max(0, Math.min(100, volume)));
|
||||||
|
const [autoplayBlocked, setAutoplayBlocked] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVol(Math.max(0, Math.min(100, volume)));
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
audio.volume = vol / 100;
|
||||||
|
}, [vol]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!src) return;
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
audio.loop = true;
|
||||||
|
audio.volume = vol / 100;
|
||||||
|
|
||||||
|
const userPaused = localStorage.getItem(STORAGE_KEY) === "1";
|
||||||
|
|
||||||
|
if (autoplay && !userPaused) {
|
||||||
|
const p = audio.play();
|
||||||
|
if (p && typeof p.then === "function") {
|
||||||
|
p.then(() => {
|
||||||
|
setPlaying(true);
|
||||||
|
setAutoplayBlocked(false);
|
||||||
|
}).catch(() => {
|
||||||
|
setPlaying(false);
|
||||||
|
setAutoplayBlocked(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPlay = () => setPlaying(true);
|
||||||
|
const onPause = () => setPlaying(false);
|
||||||
|
audio.addEventListener("play", onPlay);
|
||||||
|
audio.addEventListener("pause", onPause);
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener("play", onPlay);
|
||||||
|
audio.removeEventListener("pause", onPause);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [src, autoplay]);
|
||||||
|
|
||||||
|
// If autoplay was blocked, start playback on the first user interaction.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoplayBlocked) return;
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
const onInteract = () => {
|
||||||
|
const userPaused = localStorage.getItem(STORAGE_KEY) === "1";
|
||||||
|
if (userPaused) return;
|
||||||
|
const a = audioRef.current;
|
||||||
|
if (!a) return;
|
||||||
|
a.play()
|
||||||
|
.then(() => {
|
||||||
|
setAutoplayBlocked(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// still blocked, wait for another interaction
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointerdown", onInteract, { once: true });
|
||||||
|
window.addEventListener("keydown", onInteract, { once: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", onInteract);
|
||||||
|
window.removeEventListener("keydown", onInteract);
|
||||||
|
};
|
||||||
|
}, [autoplayBlocked]);
|
||||||
|
|
||||||
|
if (!src) return null;
|
||||||
|
|
||||||
|
const toggle = async () => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
if (audio.paused) {
|
||||||
|
try {
|
||||||
|
await audio.play();
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
setAutoplayBlocked(false);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
audio.pause();
|
||||||
|
localStorage.setItem(STORAGE_KEY, "1");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed bottom-4 right-4 z-[60] flex flex-col items-end gap-2 sm:bottom-6 sm:right-6">
|
||||||
|
<audio ref={audioRef} src={src} preload="auto" />
|
||||||
|
|
||||||
|
{showVolume && (
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/70 px-3 py-2 shadow-lg backdrop-blur">
|
||||||
|
<Volume2 className="h-3.5 w-3.5 text-white/60" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={vol}
|
||||||
|
onChange={(e) => setVol(Number(e.target.value))}
|
||||||
|
className="bgm-range h-1 w-28 cursor-pointer appearance-none rounded-full bg-white/20 accent-white/80"
|
||||||
|
aria-label="音量"
|
||||||
|
/>
|
||||||
|
<span className="w-7 text-right font-mono text-[11px] text-white/60 tabular-nums">
|
||||||
|
{vol}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowVolume((v) => !v);
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => setShowVolume((v) => !v)}
|
||||||
|
aria-label={playing ? "暂停背景音乐" : "播放背景音乐"}
|
||||||
|
title={
|
||||||
|
playing
|
||||||
|
? "暂停背景音乐(双击显示音量)"
|
||||||
|
: "播放背景音乐(双击显示音量)"
|
||||||
|
}
|
||||||
|
className={`bgm-btn pointer-events-auto group relative flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-black/70 text-white/80 shadow-lg backdrop-blur transition-all hover:bg-black/85 hover:text-white sm:h-12 sm:w-12 ${
|
||||||
|
playing ? "bgm-btn-playing" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{playing ? (
|
||||||
|
<Pause className="h-4 w-4 sm:h-4.5 sm:w-4.5" />
|
||||||
|
) : (
|
||||||
|
<Play className="ml-0.5 h-4 w-4 sm:h-4.5 sm:w-4.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
src/components/public/ChangelogTimeline.tsx
Normal file
138
src/components/public/ChangelogTimeline.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Download, Calendar, Tag } from "lucide-react";
|
||||||
|
import { useLocale } from "@/i18n/LocaleProvider";
|
||||||
|
|
||||||
|
interface VersionItem {
|
||||||
|
id: string;
|
||||||
|
version: string;
|
||||||
|
versionCode: number;
|
||||||
|
changelog: string;
|
||||||
|
changelogEn: string;
|
||||||
|
fileSize: number;
|
||||||
|
isLatest: boolean;
|
||||||
|
forceUpdate: boolean;
|
||||||
|
wowVersion: string;
|
||||||
|
downloadCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRINGS = {
|
||||||
|
zh: {
|
||||||
|
title: "版本历史",
|
||||||
|
subtitle: "Nanami 启动器更新日志",
|
||||||
|
empty: "暂无版本记录",
|
||||||
|
latest: "最新版本",
|
||||||
|
forceUpdate: "强制更新",
|
||||||
|
downloads: "次下载",
|
||||||
|
noChangelog: "无更新说明",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "Release History",
|
||||||
|
subtitle: "Nanami Launcher changelog",
|
||||||
|
empty: "No releases yet",
|
||||||
|
latest: "Latest",
|
||||||
|
forceUpdate: "Required",
|
||||||
|
downloads: " downloads",
|
||||||
|
noChangelog: "No release notes",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function ChangelogTimeline({
|
||||||
|
versions,
|
||||||
|
wowVersion,
|
||||||
|
}: {
|
||||||
|
versions: VersionItem[];
|
||||||
|
wowVersion: string;
|
||||||
|
}) {
|
||||||
|
const { locale } = useLocale();
|
||||||
|
const s = STRINGS[locale];
|
||||||
|
const dateLocale = locale === "en" ? "en-US" : "zh-CN";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-3xl px-3 py-10 sm:px-4 sm:py-16">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-amber-100 sm:text-3xl">
|
||||||
|
{s.title}
|
||||||
|
</h1>
|
||||||
|
<span className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-200">
|
||||||
|
WoW {wowVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mb-8 text-sm text-gray-400 sm:mb-12 sm:text-base">
|
||||||
|
{s.subtitle}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{versions.length === 0 ? (
|
||||||
|
<p className="py-16 text-center text-gray-500">{s.empty}</p>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<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) => {
|
||||||
|
const text =
|
||||||
|
locale === "en" && v.changelogEn && v.changelogEn.trim()
|
||||||
|
? v.changelogEn
|
||||||
|
: v.changelog;
|
||||||
|
return (
|
||||||
|
<div key={v.id} className="relative pl-10 sm:pl-12">
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
{s.latest}
|
||||||
|
</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">
|
||||||
|
{s.forceUpdate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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(dateLocale)}
|
||||||
|
</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()}
|
||||||
|
{s.downloads}
|
||||||
|
</span>
|
||||||
|
{v.fileSize > 0 && (
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{(v.fileSize / 1024 / 1024).toFixed(1)} MB
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="whitespace-pre-wrap text-sm leading-relaxed text-gray-300 sm:text-[15px]">
|
||||||
|
{text || s.noChangelog}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Package, Download, FileText, Clock } from "lucide-react";
|
import { Package, Download, FileText, Clock } from "lucide-react";
|
||||||
|
import { useLocale } from "@/i18n/LocaleProvider";
|
||||||
const quickLinks = [
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
{ href: "/addons", label: "插件列表", icon: Package },
|
|
||||||
{ href: "/articles", label: "公告文章", icon: FileText },
|
|
||||||
{ href: "/changelog", label: "更新日志", icon: Clock },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const wowVersion = DEFAULT_WOW_VERSION;
|
||||||
|
|
||||||
|
const quickLinks = [
|
||||||
|
{ href: "/addons", label: t("footer", "addons"), icon: Package },
|
||||||
|
{ href: "/articles", label: t("footer", "articlesFull"), icon: FileText },
|
||||||
|
{ href: "/changelog", label: t("footer", "changelog"), icon: Clock },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-amber-900/20 bg-[#080710]">
|
<footer className="border-t border-amber-900/20 bg-[#080710]">
|
||||||
<div className="mx-auto max-w-6xl px-4 py-10 sm:py-12">
|
<div className="mx-auto max-w-6xl px-4 py-10 sm:py-12">
|
||||||
@@ -19,16 +26,16 @@ export function Footer() {
|
|||||||
<span>Nanami</span>
|
<span>Nanami</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm leading-relaxed text-gray-500">
|
<p className="mt-3 text-sm leading-relaxed text-gray-500">
|
||||||
Turtle WoW 一站式插件管理平台。
|
{t("footer", "tagline1")}
|
||||||
<br />
|
<br />
|
||||||
轻松安装、更新、管理你的游戏插件。
|
{t("footer", "tagline2")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Links */}
|
{/* Quick Links */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-3 text-sm font-semibold text-amber-200/80">
|
<h4 className="mb-3 text-sm font-semibold text-amber-200/80">
|
||||||
快速导航
|
{t("footer", "quickNav")}
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{quickLinks.map((link) => (
|
{quickLinks.map((link) => (
|
||||||
@@ -48,28 +55,29 @@ export function Footer() {
|
|||||||
{/* Download */}
|
{/* Download */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-3 text-sm font-semibold text-amber-200/80">
|
<h4 className="mb-3 text-sm font-semibold text-amber-200/80">
|
||||||
开始使用
|
{t("footer", "getStarted")}
|
||||||
</h4>
|
</h4>
|
||||||
<Link
|
<Link
|
||||||
href="/api/software/latest"
|
href={`/api/software/latest?wow=${wowVersion}`}
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-amber-500/20 bg-amber-500/5 px-4 py-2.5 text-sm font-medium text-amber-200 transition-colors hover:border-amber-500/40 hover:bg-amber-500/10"
|
className="inline-flex items-center gap-2 rounded-lg border border-amber-500/20 bg-amber-500/5 px-4 py-2.5 text-sm font-medium text-amber-200 transition-colors hover:border-amber-500/40 hover:bg-amber-500/10"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
下载 Nanami 启动器
|
{t("footer", "download")}
|
||||||
|
<span className="rounded border border-amber-400/20 bg-amber-500/15 px-1 py-px text-[10px] font-semibold text-amber-200/80">
|
||||||
|
WoW {wowVersion}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-3 text-xs text-gray-600">
|
<p className="mt-3 text-xs text-gray-600">
|
||||||
Windows 10 / 11 · 免费使用
|
{t("footer", "platform")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 border-t border-amber-900/15 pt-6 flex flex-col items-center justify-between gap-3 sm:flex-row">
|
<div className="mt-8 border-t border-amber-900/15 pt-6 flex flex-col items-center justify-between gap-3 sm:flex-row">
|
||||||
<p className="text-xs text-gray-600">
|
<p className="text-xs text-gray-600">
|
||||||
© {new Date().getFullYear()} Nanami. All rights reserved.
|
{t("footer", "copyright", { year: new Date().getFullYear() })}
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-600">
|
|
||||||
Made for Turtle WoW community
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-gray-600">{t("footer", "madeFor")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -2,10 +2,17 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { ChevronLeft, ChevronRight, X, Monitor } from "lucide-react";
|
import { ChevronLeft, ChevronRight, X, Monitor } from "lucide-react";
|
||||||
|
import { useLocale } from "@/i18n/LocaleProvider";
|
||||||
|
|
||||||
interface GalleryItem {
|
interface GalleryItem {
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
titleEn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickTitle(item: GalleryItem, locale: "zh" | "en") {
|
||||||
|
if (locale === "en" && item.titleEn && item.titleEn.trim()) return item.titleEn;
|
||||||
|
return item.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ITEMS: GalleryItem[] = Array.from({ length: 14 }, (_, i) => ({
|
const DEFAULT_ITEMS: GalleryItem[] = Array.from({ length: 14 }, (_, i) => ({
|
||||||
@@ -15,6 +22,7 @@ const DEFAULT_ITEMS: GalleryItem[] = Array.from({ length: 14 }, (_, i) => ({
|
|||||||
const SWIPE_THRESHOLD = 40;
|
const SWIPE_THRESHOLD = 40;
|
||||||
|
|
||||||
export function GameGallery({ items }: { items?: GalleryItem[] }) {
|
export function GameGallery({ items }: { items?: GalleryItem[] }) {
|
||||||
|
const { locale, t } = useLocale();
|
||||||
const gallery = items && items.length > 0 ? items : DEFAULT_ITEMS;
|
const gallery = items && items.length > 0 ? items : DEFAULT_ITEMS;
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const [lightbox, setLightbox] = useState(false);
|
const [lightbox, setLightbox] = useState(false);
|
||||||
@@ -75,7 +83,7 @@ export function GameGallery({ items }: { items?: GalleryItem[] }) {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-4 flex items-center gap-2 sm:mb-8 sm:gap-3">
|
<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" />
|
<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>
|
<h2 className="text-xl font-bold text-amber-100 sm:text-2xl">{t("home", "gameplayShowcase")}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main image */}
|
{/* Main image */}
|
||||||
@@ -89,7 +97,7 @@ export function GameGallery({ items }: { items?: GalleryItem[] }) {
|
|||||||
<img
|
<img
|
||||||
key={item.imageUrl}
|
key={item.imageUrl}
|
||||||
src={item.imageUrl}
|
src={item.imageUrl}
|
||||||
alt={item.title || `实机截图 ${i + 1}`}
|
alt={pickTitle(item, locale) || `screenshot ${i + 1}`}
|
||||||
loading={i <= 1 ? "eager" : "lazy"}
|
loading={i <= 1 ? "eager" : "lazy"}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onClick={() => setLightbox(true)}
|
onClick={() => setLightbox(true)}
|
||||||
@@ -101,10 +109,10 @@ export function GameGallery({ items }: { items?: GalleryItem[] }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title overlay */}
|
{/* Title overlay */}
|
||||||
{gallery[active]?.title && (
|
{pickTitle(gallery[active], locale) && (
|
||||||
<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">
|
<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">
|
<p className="text-xs font-medium text-white/90 sm:text-sm">
|
||||||
{gallery[active].title}
|
{pickTitle(gallery[active], locale)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -146,7 +154,7 @@ export function GameGallery({ items }: { items?: GalleryItem[] }) {
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={item.imageUrl}
|
src={item.imageUrl}
|
||||||
alt={item.title || `缩略图 ${i + 1}`}
|
alt={pickTitle(item, locale) || `thumbnail ${i + 1}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
className="h-12 w-20 object-cover sm:h-16 sm:w-28 md:h-[72px] md:w-32"
|
className="h-12 w-20 object-cover sm:h-16 sm:w-28 md:h-[72px] md:w-32"
|
||||||
@@ -191,9 +199,9 @@ export function GameGallery({ items }: { items?: GalleryItem[] }) {
|
|||||||
alt={gallery[active].title || `实机截图 ${active + 1}`}
|
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]"
|
className="max-h-[80vh] max-w-[95vw] rounded-lg object-contain sm:max-h-[85vh] sm:max-w-[90vw]"
|
||||||
/>
|
/>
|
||||||
{gallery[active].title && (
|
{pickTitle(gallery[active], locale) && (
|
||||||
<p className="mt-2 text-sm font-medium text-white/80 sm:mt-3 sm:text-base">
|
<p className="mt-2 text-sm font-medium text-white/80 sm:mt-3 sm:text-base">
|
||||||
{gallery[active].title}
|
{pickTitle(gallery[active], locale)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import { Download, ChevronRight } from "lucide-react";
|
import { Download, ChevronRight } from "lucide-react";
|
||||||
|
import { useLocale } from "@/i18n/LocaleProvider";
|
||||||
|
import { DEFAULT_WOW_VERSION } from "@/lib/wow-versions";
|
||||||
|
|
||||||
interface Particle {
|
interface Particle {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -45,6 +47,8 @@ export function HeroBanner({
|
|||||||
launcherVersion?: string | null;
|
launcherVersion?: string | null;
|
||||||
banners?: { imageUrl: string }[];
|
banners?: { imageUrl: string }[];
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const wowVersion = DEFAULT_WOW_VERSION;
|
||||||
const slides =
|
const slides =
|
||||||
banners && banners.length > 0
|
banners && banners.length > 0
|
||||||
? banners.map((b) => ({ image: b.imageUrl }))
|
? banners.map((b) => ({ image: b.imageUrl }))
|
||||||
@@ -244,16 +248,17 @@ export function HeroBanner({
|
|||||||
}, [slides.length]);
|
}, [slides.length]);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
|
const qs = `?info=1&track=1&wow=${wowVersion}`;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/software/latest?info=1&track=1");
|
const res = await fetch(`/api/software/latest${qs}`);
|
||||||
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;
|
||||||
} else {
|
} else {
|
||||||
window.location.href = "/api/software/latest";
|
window.location.href = `/api/software/latest?wow=${wowVersion}`;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
window.location.href = "/api/software/latest";
|
window.location.href = `/api/software/latest?wow=${wowVersion}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -347,7 +352,7 @@ export function HeroBanner({
|
|||||||
<div className="absolute inset-x-0 bottom-0 z-20 flex flex-col items-center px-4 pb-5 sm:pb-10">
|
<div className="absolute inset-x-0 bottom-0 z-20 flex flex-col items-center px-4 pb-5 sm:pb-10">
|
||||||
{/* Tagline */}
|
{/* 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">
|
<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 一站式插件管理平台
|
{t("hero", "tagline")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Download button */}
|
{/* Download button */}
|
||||||
@@ -363,13 +368,16 @@ export function HeroBanner({
|
|||||||
<span className="relative flex items-center gap-2.5 sm:gap-3">
|
<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" />
|
<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">
|
<span className="text-sm font-bold tracking-wide text-amber-50 sm:text-base">
|
||||||
下载 Nanami 启动器
|
{t("hero", "download")}
|
||||||
</span>
|
</span>
|
||||||
{launcherVersion && (
|
{launcherVersion && (
|
||||||
<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">
|
<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>
|
||||||
)}
|
)}
|
||||||
|
<span className="hidden rounded-md border border-amber-400/20 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-amber-200/80 sm:inline-block">
|
||||||
|
WoW {wowVersion}
|
||||||
|
</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" />
|
<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>
|
||||||
@@ -382,7 +390,7 @@ export function HeroBanner({
|
|||||||
<span className="font-semibold text-amber-300/60 tabular-nums">
|
<span className="font-semibold text-amber-300/60 tabular-nums">
|
||||||
{totalDownloads.toLocaleString()}
|
{totalDownloads.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
次下载
|
{t("common", "downloads")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -392,7 +400,7 @@ export function HeroBanner({
|
|||||||
{slides.map((_, i) => (
|
{slides.map((_, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
aria-label={`前往第 ${i + 1} 张`}
|
aria-label={t("hero", "goToSlide", { n: i + 1 })}
|
||||||
onClick={() => goTo(i)}
|
onClick={() => goTo(i)}
|
||||||
className={`hero-indicator cursor-pointer rounded-full transition-all duration-500 ${
|
className={`hero-indicator cursor-pointer rounded-full transition-all duration-500 ${
|
||||||
i === active
|
i === active
|
||||||
|
|||||||
43
src/components/public/LanguageSwitcher.tsx
Normal file
43
src/components/public/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Globe } from "lucide-react";
|
||||||
|
import { useLocale } from "@/i18n/LocaleProvider";
|
||||||
|
|
||||||
|
export function LanguageSwitcher({
|
||||||
|
variant = "navbar",
|
||||||
|
}: {
|
||||||
|
variant?: "navbar" | "mobile";
|
||||||
|
}) {
|
||||||
|
const { locale, setLocale } = useLocale();
|
||||||
|
const next = locale === "zh" ? "en" : "zh";
|
||||||
|
const nextLabel = next === "en" ? "EN" : "中";
|
||||||
|
const ariaLabel = locale === "zh" ? "Switch to English" : "切换到中文";
|
||||||
|
|
||||||
|
if (variant === "mobile") {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setLocale(next)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-sm text-gray-400 transition-colors hover:bg-white/[0.04] hover:text-amber-200"
|
||||||
|
>
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
<span>{locale === "zh" ? "English" : "中文"}</span>
|
||||||
|
<span className="ml-auto rounded border border-amber-500/20 px-1.5 py-0.5 text-xs text-amber-200/80">
|
||||||
|
{nextLabel}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setLocale(next)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
title={ariaLabel}
|
||||||
|
className="inline-flex h-7 items-center gap-1 rounded-md border border-amber-500/20 px-2 text-xs text-amber-200/80 transition-colors hover:border-amber-500/40 hover:text-amber-100"
|
||||||
|
>
|
||||||
|
<Globe className="h-3.5 w-3.5" />
|
||||||
|
<span className="font-medium tracking-wide">{nextLabel}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,16 +4,19 @@ import { useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Package, Menu, X } from "lucide-react";
|
import { Package, Menu, X } from "lucide-react";
|
||||||
|
import { useLocale } from "@/i18n/LocaleProvider";
|
||||||
const navLinks = [
|
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||||
{ href: "/addons", label: "插件列表" },
|
|
||||||
{ href: "/articles", label: "公告" },
|
|
||||||
{ href: "/changelog", label: "更新日志" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ href: "/addons", label: t("nav", "addons") },
|
||||||
|
{ href: "/articles", label: t("nav", "articles") },
|
||||||
|
{ href: "/changelog", label: t("nav", "changelog") },
|
||||||
|
];
|
||||||
|
|
||||||
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">
|
||||||
@@ -47,13 +50,14 @@ export function Navbar() {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<LanguageSwitcher />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile menu button */}
|
{/* Mobile menu button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md text-gray-400 transition-colors hover:text-amber-200 sm:hidden"
|
className="flex h-8 w-8 items-center justify-center rounded-md text-gray-400 transition-colors hover:text-amber-200 sm:hidden"
|
||||||
aria-label={open ? "关闭菜单" : "打开菜单"}
|
aria-label={open ? t("nav", "closeMenu") : t("nav", "openMenu")}
|
||||||
>
|
>
|
||||||
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -79,6 +83,9 @@ export function Navbar() {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<div className="mt-1 border-t border-amber-900/15 pt-2">
|
||||||
|
<LanguageSwitcher variant="mobile" />
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
338
src/components/public/ShutdownBanner.tsx
Normal file
338
src/components/public/ShutdownBanner.tsx
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import ReactMarkdown, { type Components } from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { useLocale } from "@/i18n/LocaleProvider";
|
||||||
|
|
||||||
|
interface ShutdownBannerProps {
|
||||||
|
title: string;
|
||||||
|
titleEn?: string;
|
||||||
|
subtitle: string;
|
||||||
|
subtitleEn?: string;
|
||||||
|
shutdownAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCountdown(targetIso: string) {
|
||||||
|
const target = new Date(targetIso).getTime();
|
||||||
|
const [now, setNow] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNow(Date.now());
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const diff = now === null ? target - target : Math.max(0, target - now);
|
||||||
|
const totalSeconds = Math.floor(diff / 1000);
|
||||||
|
const days = Math.floor(totalSeconds / 86400);
|
||||||
|
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return {
|
||||||
|
days,
|
||||||
|
hours,
|
||||||
|
minutes,
|
||||||
|
seconds,
|
||||||
|
ended: now !== null && diff === 0,
|
||||||
|
ready: now !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n: number) {
|
||||||
|
return n.toString().padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert single `\n` into CommonMark hard breaks (two trailing spaces + \n)
|
||||||
|
// so admin-entered line breaks are preserved by ReactMarkdown.
|
||||||
|
function preserveLineBreaks(text: string): string {
|
||||||
|
return text.replace(/\n/g, " \n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const TITLE_MD_COMPONENTS: Components = {
|
||||||
|
p: ({ children }) => <>{children}</>,
|
||||||
|
h1: ({ children }) => <>{children}</>,
|
||||||
|
h2: ({ children }) => <>{children}</>,
|
||||||
|
h3: ({ children }) => <>{children}</>,
|
||||||
|
h4: ({ children }) => <>{children}</>,
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="underline decoration-white/30 underline-offset-4 hover:decoration-white/70"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUBTITLE_MD_COMPONENTS: Components = {
|
||||||
|
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="underline decoration-white/30 underline-offset-4 hover:decoration-white/70"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Ember {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vy: number;
|
||||||
|
vx: number;
|
||||||
|
size: number;
|
||||||
|
alpha: number;
|
||||||
|
life: number;
|
||||||
|
maxLife: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmberCanvas() {
|
||||||
|
const ref = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = ref.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const parent = canvas.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)")
|
||||||
|
.matches;
|
||||||
|
|
||||||
|
let w = 0;
|
||||||
|
let h = 0;
|
||||||
|
let raf = 0;
|
||||||
|
const embers: Ember[] = [];
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = parent.getBoundingClientRect();
|
||||||
|
w = rect.width;
|
||||||
|
h = rect.height;
|
||||||
|
canvas.width = w * dpr;
|
||||||
|
canvas.height = h * dpr;
|
||||||
|
canvas.style.width = w + "px";
|
||||||
|
canvas.style.height = h + "px";
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
|
||||||
|
const spawn = () => {
|
||||||
|
embers.push({
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: h + 4,
|
||||||
|
vx: (Math.random() - 0.5) * 0.15,
|
||||||
|
vy: -(Math.random() * 0.35 + 0.15),
|
||||||
|
size: Math.random() * 1.4 + 0.4,
|
||||||
|
alpha: Math.random() * 0.5 + 0.25,
|
||||||
|
life: 0,
|
||||||
|
maxLife: Math.random() * 260 + 220,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxEmbers = reduced ? 0 : 22;
|
||||||
|
|
||||||
|
const loop = () => {
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
if (embers.length < maxEmbers && Math.random() < 0.3) spawn();
|
||||||
|
|
||||||
|
for (let i = embers.length - 1; i >= 0; i--) {
|
||||||
|
const e = embers[i];
|
||||||
|
e.x += e.vx;
|
||||||
|
e.y += e.vy;
|
||||||
|
e.life++;
|
||||||
|
const t = e.life / e.maxLife;
|
||||||
|
const a = e.alpha * (1 - t);
|
||||||
|
if (t >= 1 || e.y < -10) {
|
||||||
|
embers.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const grad = ctx.createRadialGradient(
|
||||||
|
e.x,
|
||||||
|
e.y,
|
||||||
|
0,
|
||||||
|
e.x,
|
||||||
|
e.y,
|
||||||
|
e.size * 6
|
||||||
|
);
|
||||||
|
grad.addColorStop(0, `rgba(200, 200, 200, ${(a * 0.5).toFixed(3)})`);
|
||||||
|
grad.addColorStop(1, "rgba(200, 200, 200, 0)");
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(e.x, e.y, e.size * 6, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = `rgba(220, 220, 220, ${a.toFixed(3)})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(e.x, e.y, e.size, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
loop();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", resize);
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={ref}
|
||||||
|
className="pointer-events-none absolute inset-0 z-[1]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShutdownBanner({
|
||||||
|
title,
|
||||||
|
titleEn,
|
||||||
|
subtitle,
|
||||||
|
subtitleEn,
|
||||||
|
shutdownAt,
|
||||||
|
}: ShutdownBannerProps) {
|
||||||
|
const { days, hours, minutes, seconds, ended } = useCountdown(shutdownAt);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
const { t, locale } = useLocale();
|
||||||
|
|
||||||
|
// English content with fallback to Chinese
|
||||||
|
const displayTitle =
|
||||||
|
locale === "en" && titleEn && titleEn.trim() ? titleEn : title;
|
||||||
|
const displaySubtitle =
|
||||||
|
locale === "en" && subtitleEn && subtitleEn.trim() ? subtitleEn : subtitle;
|
||||||
|
|
||||||
|
const units = [
|
||||||
|
{ label: t("shutdown", "unitDay"), value: String(days).padStart(2, "0") },
|
||||||
|
{ label: t("shutdown", "unitHour"), value: pad(hours) },
|
||||||
|
{ label: t("shutdown", "unitMinute"), value: pad(minutes) },
|
||||||
|
{ label: t("shutdown", "unitSecond"), value: pad(seconds) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const formattedDate = (() => {
|
||||||
|
const d = new Date(shutdownAt);
|
||||||
|
if (isNaN(d.getTime())) return "";
|
||||||
|
return d.toLocaleString(locale === "en" ? "en-US" : "zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="shutdown-banner relative flex min-h-[100svh] flex-col overflow-hidden border-b border-white/5 bg-[#07060a]"
|
||||||
|
aria-label={t("shutdown", "ariaLabel")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 z-[0]"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(ellipse at center, rgba(255,255,255,0.04) 0%, transparent 60%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 z-[0] opacity-70"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(180deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 40%, rgba(0,0,0,0) 60%, rgba(0,0,0,0.6) 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<EmberCanvas />
|
||||||
|
|
||||||
|
<div className="shutdown-vignette pointer-events-none absolute inset-0 z-[1]" />
|
||||||
|
|
||||||
|
<div className="relative z-[2] mx-auto flex w-full max-w-5xl flex-1 flex-col items-center justify-center px-4 py-16 text-center sm:py-20">
|
||||||
|
<div className="shutdown-candle mb-4 sm:mb-5" aria-hidden>
|
||||||
|
<span className="shutdown-candle-flame" />
|
||||||
|
<span className="shutdown-candle-body" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="shutdown-eyebrow text-[10px] font-medium uppercase tracking-[0.35em] text-white/40 sm:text-xs">
|
||||||
|
{t("shutdown", "eyebrow")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="shutdown-title mt-4 whitespace-pre-line text-2xl font-semibold tracking-wide text-white/85 sm:text-3xl md:text-4xl lg:text-5xl">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={TITLE_MD_COMPONENTS}
|
||||||
|
>
|
||||||
|
{preserveLineBreaks(displayTitle)}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{displaySubtitle && (
|
||||||
|
<div className="mt-4 max-w-2xl text-sm leading-relaxed text-white/55 sm:text-base md:text-lg">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={SUBTITLE_MD_COMPONENTS}
|
||||||
|
>
|
||||||
|
{preserveLineBreaks(displaySubtitle)}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mounted && formattedDate && (
|
||||||
|
<p className="mt-1 text-xs text-white/40 sm:text-sm">
|
||||||
|
{formattedDate}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`shutdown-countdown mt-8 grid grid-cols-4 gap-2 sm:mt-12 sm:gap-4 md:gap-6 ${
|
||||||
|
mounted ? "is-mounted" : ""
|
||||||
|
}`}
|
||||||
|
role="timer"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{units.map((u, idx) => (
|
||||||
|
<div
|
||||||
|
key={u.label}
|
||||||
|
className="shutdown-unit flex flex-col items-center justify-center rounded-md border border-white/10 bg-white/[0.025] px-3 py-4 backdrop-blur-sm sm:px-6 sm:py-6 md:px-8 md:py-8"
|
||||||
|
style={{ animationDelay: `${idx * 120}ms` }}
|
||||||
|
>
|
||||||
|
<span className="shutdown-num font-mono text-3xl font-light tabular-nums text-white/90 sm:text-5xl md:text-6xl lg:text-7xl">
|
||||||
|
{u.value}
|
||||||
|
</span>
|
||||||
|
<span className="mt-2 text-[10px] uppercase tracking-[0.25em] text-white/40 sm:text-xs md:text-sm">
|
||||||
|
{u.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ended && (
|
||||||
|
<p className="mt-5 text-sm italic text-white/60 sm:text-base">
|
||||||
|
{t("shutdown", "ended")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-10 flex max-w-xl flex-col items-center gap-3 rounded-lg border border-amber-500/15 bg-amber-500/[0.04] px-6 py-5 text-center backdrop-blur-sm sm:mt-12 sm:px-8 sm:py-6">
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-[0.3em] text-amber-300/60 sm:text-xs">
|
||||||
|
{t("shutdown", "announceEyebrow")}
|
||||||
|
</p>
|
||||||
|
<h3 className="text-base font-semibold text-amber-100/90 sm:text-lg">
|
||||||
|
{t("shutdown", "announceTitle")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs leading-relaxed text-white/55 sm:text-sm">
|
||||||
|
{t("shutdown", "announceDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shutdown-rule mt-6 h-px w-40 bg-gradient-to-r from-transparent via-white/25 to-transparent sm:mt-8 sm:w-64" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/components/public/T.tsx
Normal file
23
src/components/public/T.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLocale } from "@/i18n/LocaleProvider";
|
||||||
|
import type { Messages } from "@/i18n/messages";
|
||||||
|
|
||||||
|
type Section = keyof Messages;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny inline translation component: <T section="home" k="viewMore" />
|
||||||
|
* Useful inside server components that otherwise can't call hooks.
|
||||||
|
*/
|
||||||
|
export function T<S extends Section>({
|
||||||
|
section,
|
||||||
|
k,
|
||||||
|
vars,
|
||||||
|
}: {
|
||||||
|
section: S;
|
||||||
|
k: keyof Messages[S];
|
||||||
|
vars?: Record<string, string | number>;
|
||||||
|
}) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
return <>{t(section, k, vars)}</>;
|
||||||
|
}
|
||||||
86
src/i18n/LocaleProvider.tsx
Normal file
86
src/i18n/LocaleProvider.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
DEFAULT_LOCALE,
|
||||||
|
LOCALE_COOKIE,
|
||||||
|
messages,
|
||||||
|
type Locale,
|
||||||
|
type Messages,
|
||||||
|
} from "./messages";
|
||||||
|
|
||||||
|
type Section = keyof Messages;
|
||||||
|
type Key<S extends Section> = keyof Messages[S];
|
||||||
|
|
||||||
|
interface LocaleContextValue {
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (next: Locale) => void;
|
||||||
|
t: <S extends Section>(
|
||||||
|
section: S,
|
||||||
|
key: Key<S>,
|
||||||
|
vars?: Record<string, string | number>
|
||||||
|
) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||||
|
|
||||||
|
function interpolate(template: string, vars?: Record<string, string | number>) {
|
||||||
|
if (!vars) return template;
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, k) =>
|
||||||
|
vars[k] !== undefined ? String(vars[k]) : `{${k}}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocaleProvider({
|
||||||
|
initialLocale,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
initialLocale: Locale;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [locale, setLocaleState] = useState<Locale>(initialLocale);
|
||||||
|
|
||||||
|
const setLocale = useCallback((next: Locale) => {
|
||||||
|
setLocaleState(next);
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
// 1 year, root path
|
||||||
|
document.cookie = `${LOCALE_COOKIE}=${next}; path=/; max-age=${
|
||||||
|
60 * 60 * 24 * 365
|
||||||
|
}; samesite=lax`;
|
||||||
|
document.documentElement.lang = next === "en" ? "en" : "zh-CN";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<LocaleContextValue>(() => {
|
||||||
|
const dict = messages[locale] ?? messages[DEFAULT_LOCALE];
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
setLocale,
|
||||||
|
t: (section, key, vars) => {
|
||||||
|
const sectionDict = dict[section] as Record<string, string>;
|
||||||
|
const tpl = sectionDict?.[key as string];
|
||||||
|
if (typeof tpl !== "string") return `${String(section)}.${String(key)}`;
|
||||||
|
return interpolate(tpl, vars);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [locale, setLocale]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocale() {
|
||||||
|
const ctx = useContext(LocaleContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useLocale must be used inside <LocaleProvider>");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
30
src/i18n/getLocale.ts
Normal file
30
src/i18n/getLocale.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { cookies, headers } from "next/headers";
|
||||||
|
import { DEFAULT_LOCALE, LOCALES, LOCALE_COOKIE, type Locale } from "./messages";
|
||||||
|
|
||||||
|
function isLocale(v: string | undefined | null): v is Locale {
|
||||||
|
return !!v && (LOCALES as string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current locale on the server.
|
||||||
|
* 1. Explicit cookie
|
||||||
|
* 2. Accept-Language header (en* → en, otherwise zh)
|
||||||
|
* 3. Default
|
||||||
|
*/
|
||||||
|
export async function getServerLocale(): Promise<Locale> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value;
|
||||||
|
if (isLocale(cookieLocale)) return cookieLocale;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const h = await headers();
|
||||||
|
const accept = h.get("accept-language") || "";
|
||||||
|
const first = accept.split(",")[0]?.trim().toLowerCase() || "";
|
||||||
|
if (first.startsWith("en")) return "en";
|
||||||
|
if (first.startsWith("zh")) return "zh";
|
||||||
|
} catch {
|
||||||
|
// headers() can throw outside request scope
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
201
src/i18n/messages.ts
Normal file
201
src/i18n/messages.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
export type Locale = "zh" | "en";
|
||||||
|
|
||||||
|
export const LOCALES: Locale[] = ["zh", "en"];
|
||||||
|
export const DEFAULT_LOCALE: Locale = "zh";
|
||||||
|
export const LOCALE_COOKIE = "locale";
|
||||||
|
|
||||||
|
export const messages = {
|
||||||
|
zh: {
|
||||||
|
common: {
|
||||||
|
switchToEn: "EN",
|
||||||
|
switchToZh: "中",
|
||||||
|
language: "语言",
|
||||||
|
downloads: "次下载",
|
||||||
|
version: "版本",
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
addons: "插件列表",
|
||||||
|
articles: "公告",
|
||||||
|
changelog: "更新日志",
|
||||||
|
openMenu: "打开菜单",
|
||||||
|
closeMenu: "关闭菜单",
|
||||||
|
home: "首页",
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
tagline: "Turtle WoW 一站式插件管理平台",
|
||||||
|
download: "下载 Nanami 启动器",
|
||||||
|
goToSlide: "前往第 {n} 张",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
tagline1: "Turtle WoW 一站式插件管理平台。",
|
||||||
|
tagline2: "轻松安装、更新、管理你的游戏插件。",
|
||||||
|
quickNav: "快速导航",
|
||||||
|
addons: "插件列表",
|
||||||
|
articlesFull: "公告文章",
|
||||||
|
changelog: "更新日志",
|
||||||
|
getStarted: "开始使用",
|
||||||
|
download: "下载 Nanami 启动器",
|
||||||
|
platform: "Windows 10 / 11 · 免费使用",
|
||||||
|
copyright: "© {year} Nanami. All rights reserved.",
|
||||||
|
madeFor: "Made for Turtle WoW community",
|
||||||
|
},
|
||||||
|
articles: {
|
||||||
|
title: "公告与文章",
|
||||||
|
subtitle: "最新动态与更新公告",
|
||||||
|
empty: "暂无文章",
|
||||||
|
back: "返回文章列表",
|
||||||
|
},
|
||||||
|
addons: {
|
||||||
|
title: "插件列表",
|
||||||
|
subtitle: "浏览和下载 World of Warcraft 插件",
|
||||||
|
all: "全部",
|
||||||
|
downloadsCount: "次下载",
|
||||||
|
emptyTitle: "暂无插件",
|
||||||
|
emptySubtitle: "稍后再来查看吧",
|
||||||
|
notFoundTitle: "没有找到“{q}”相关的插件",
|
||||||
|
notFoundSubtitle: "尝试更换关键词搜索",
|
||||||
|
back: "返回插件列表",
|
||||||
|
introduction: "介绍",
|
||||||
|
screenshots: "截图",
|
||||||
|
releaseHistory: "版本历史",
|
||||||
|
releaseCount: "共 {n} 个版本",
|
||||||
|
noReleases: "暂无版本发布",
|
||||||
|
latestTag: "最新",
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
general: "通用",
|
||||||
|
gameplay: "游戏玩法",
|
||||||
|
ui: "界面增强",
|
||||||
|
combat: "战斗",
|
||||||
|
raid: "团队副本",
|
||||||
|
pvp: "PvP",
|
||||||
|
tradeskill: "专业技能",
|
||||||
|
utility: "实用工具",
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
gameplayShowcase: "实机演示",
|
||||||
|
featureDeepTitle: "深度适配",
|
||||||
|
featureDeepDesc: "专为乌龟服 1.18.0 打造,兼容自定义内容与新种族,稳定流畅",
|
||||||
|
featureInstallTitle: "一键安装管理",
|
||||||
|
featureInstallDesc: "通过 Nanami 启动器自动安装、更新,告别手动拖拽文件夹",
|
||||||
|
featureAITitle: "内置 AI 翻译",
|
||||||
|
featureAIDesc: "自带智能翻译引擎,轻松畅玩英文服务器,语言不再是障碍",
|
||||||
|
latestArticles: "最新公告",
|
||||||
|
viewMore: "查看更多",
|
||||||
|
hotAddons: "热门插件",
|
||||||
|
viewAll: "查看全部",
|
||||||
|
},
|
||||||
|
shutdown: {
|
||||||
|
eyebrow: "In Memoriam · Turtle WoW",
|
||||||
|
ended: "旅程已至终点,感谢一路同行。",
|
||||||
|
announceEyebrow: "Launcher Announcement",
|
||||||
|
announceTitle: "Nanami 启动器·Nanami 服特殊版即将发布",
|
||||||
|
announceDesc:
|
||||||
|
"原版「Nanami 启动器」将更名为「乌龟服亚服启动器」,继续为亚服玩家提供服务;同时,面向 Nanami 服的特殊版即将上线,专为新服特性深度适配。敬请期待。",
|
||||||
|
unitDay: "天",
|
||||||
|
unitHour: "时",
|
||||||
|
unitMinute: "分",
|
||||||
|
unitSecond: "秒",
|
||||||
|
ariaLabel: "服务器关闭倒计时",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
common: {
|
||||||
|
switchToEn: "EN",
|
||||||
|
switchToZh: "中",
|
||||||
|
language: "Language",
|
||||||
|
downloads: " downloads",
|
||||||
|
version: "Version",
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
addons: "Addons",
|
||||||
|
articles: "News",
|
||||||
|
changelog: "Changelog",
|
||||||
|
openMenu: "Open menu",
|
||||||
|
closeMenu: "Close menu",
|
||||||
|
home: "Home",
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
tagline: "All-in-one Addon Platform for Turtle WoW",
|
||||||
|
download: "Download Nanami Launcher",
|
||||||
|
goToSlide: "Go to slide {n}",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
tagline1: "All-in-one addon platform for Turtle WoW.",
|
||||||
|
tagline2: "Install, update, and manage your game addons effortlessly.",
|
||||||
|
quickNav: "Quick Links",
|
||||||
|
addons: "Addons",
|
||||||
|
articlesFull: "Announcements",
|
||||||
|
changelog: "Changelog",
|
||||||
|
getStarted: "Get Started",
|
||||||
|
download: "Download Nanami Launcher",
|
||||||
|
platform: "Windows 10 / 11 · Free",
|
||||||
|
copyright: "© {year} Nanami. All rights reserved.",
|
||||||
|
madeFor: "Made for the Turtle WoW community",
|
||||||
|
},
|
||||||
|
articles: {
|
||||||
|
title: "News & Announcements",
|
||||||
|
subtitle: "Latest updates and release notes",
|
||||||
|
empty: "No articles yet",
|
||||||
|
back: "Back to articles",
|
||||||
|
},
|
||||||
|
addons: {
|
||||||
|
title: "Addons",
|
||||||
|
subtitle: "Browse and download World of Warcraft addons",
|
||||||
|
all: "All",
|
||||||
|
downloadsCount: " downloads",
|
||||||
|
emptyTitle: "No addons yet",
|
||||||
|
emptySubtitle: "Check back later",
|
||||||
|
notFoundTitle: "No addons matched “{q}”",
|
||||||
|
notFoundSubtitle: "Try a different keyword",
|
||||||
|
back: "Back to addons",
|
||||||
|
introduction: "About",
|
||||||
|
screenshots: "Screenshots",
|
||||||
|
releaseHistory: "Release history",
|
||||||
|
releaseCount: "{n} release(s)",
|
||||||
|
noReleases: "No releases yet",
|
||||||
|
latestTag: "Latest",
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
general: "General",
|
||||||
|
gameplay: "Gameplay",
|
||||||
|
ui: "Interface",
|
||||||
|
combat: "Combat",
|
||||||
|
raid: "Raid & Dungeon",
|
||||||
|
pvp: "PvP",
|
||||||
|
tradeskill: "Trade Skills",
|
||||||
|
utility: "Utility",
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
gameplayShowcase: "Gameplay Showcase",
|
||||||
|
featureDeepTitle: "Deeply Tailored",
|
||||||
|
featureDeepDesc:
|
||||||
|
"Built for Turtle WoW 1.18.0 — fully compatible with custom content and new races. Stable and smooth.",
|
||||||
|
featureInstallTitle: "One-click Install",
|
||||||
|
featureInstallDesc:
|
||||||
|
"Auto-install and update via the Nanami Launcher. No more dragging folders by hand.",
|
||||||
|
featureAITitle: "Built-in AI Translation",
|
||||||
|
featureAIDesc:
|
||||||
|
"Smart translation engine built in — enjoy English servers without a language barrier.",
|
||||||
|
latestArticles: "Latest News",
|
||||||
|
viewMore: "View more",
|
||||||
|
hotAddons: "Featured Addons",
|
||||||
|
viewAll: "View all",
|
||||||
|
},
|
||||||
|
shutdown: {
|
||||||
|
eyebrow: "In Memoriam · Turtle WoW",
|
||||||
|
ended: "The journey ends here. Thank you for walking it with us.",
|
||||||
|
announceEyebrow: "Launcher Announcement",
|
||||||
|
announceTitle: "Nanami Launcher · Nanami-Server edition coming soon",
|
||||||
|
announceDesc:
|
||||||
|
"The original Nanami Launcher will be renamed to the Turtle WoW Asia Launcher and continue serving Asia players. A new Nanami-Server edition, deeply tailored for the new server, is launching soon. Stay tuned.",
|
||||||
|
unitDay: "Days",
|
||||||
|
unitHour: "Hours",
|
||||||
|
unitMinute: "Mins",
|
||||||
|
unitSecond: "Secs",
|
||||||
|
ariaLabel: "Server shutdown countdown",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Messages = typeof messages.zh;
|
||||||
50
src/lib/api-locale.ts
Normal file
50
src/lib/api-locale.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Helpers for serving public APIs in either Chinese or English.
|
||||||
|
*
|
||||||
|
* Convention: response field names stay canonical (e.g. `name`, `summary`,
|
||||||
|
* `description`, `changelog`, `title`). When `?lang=en` is requested, those
|
||||||
|
* fields contain English content; if the English field is empty, we fall back
|
||||||
|
* to the Chinese value so clients always get something readable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
export type ApiLang = "zh" | "en";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the requested language from the request.
|
||||||
|
*
|
||||||
|
* 1. ?lang=en|zh (preferred)
|
||||||
|
* 2. ?locale=en|zh (alias)
|
||||||
|
* 3. Accept-Language header (en* → en, zh* → zh)
|
||||||
|
* 4. Default zh
|
||||||
|
*/
|
||||||
|
export function getApiLang(request: NextRequest | URL): ApiLang {
|
||||||
|
const url = request instanceof URL ? request : new URL(request.url);
|
||||||
|
const explicit =
|
||||||
|
url.searchParams.get("lang") || url.searchParams.get("locale");
|
||||||
|
if (explicit === "en") return "en";
|
||||||
|
if (explicit === "zh") return "zh";
|
||||||
|
|
||||||
|
if (request instanceof URL) return "zh";
|
||||||
|
const accept = request.headers.get("accept-language") || "";
|
||||||
|
const first = accept.split(",")[0]?.trim().toLowerCase() || "";
|
||||||
|
if (first.startsWith("en")) return "en";
|
||||||
|
return "zh";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the appropriate text for the requested language. Falls back to the
|
||||||
|
* Chinese value when the English field is empty/missing.
|
||||||
|
*/
|
||||||
|
export function pickText(
|
||||||
|
zh: string | null | undefined,
|
||||||
|
en: string | null | undefined,
|
||||||
|
lang: ApiLang
|
||||||
|
): string {
|
||||||
|
if (lang === "en") {
|
||||||
|
if (en && en.trim()) return en;
|
||||||
|
return zh ?? "";
|
||||||
|
}
|
||||||
|
return zh ?? "";
|
||||||
|
}
|
||||||
105
src/lib/site-settings.ts
Normal file
105
src/lib/site-settings.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export type SiteSettings = {
|
||||||
|
grayscale: boolean;
|
||||||
|
shutdownBannerEnabled: boolean;
|
||||||
|
shutdownTitle: string;
|
||||||
|
shutdownTitleEn: string;
|
||||||
|
shutdownSubtitle: string;
|
||||||
|
shutdownSubtitleEn: string;
|
||||||
|
shutdownAt: Date | null;
|
||||||
|
bgmUrl: string;
|
||||||
|
bgmAutoplay: boolean;
|
||||||
|
bgmVolume: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULTS: SiteSettings = {
|
||||||
|
grayscale: false,
|
||||||
|
shutdownBannerEnabled: false,
|
||||||
|
shutdownTitle: "",
|
||||||
|
shutdownTitleEn: "",
|
||||||
|
shutdownSubtitle: "",
|
||||||
|
shutdownSubtitleEn: "",
|
||||||
|
shutdownAt: null,
|
||||||
|
bgmUrl: "",
|
||||||
|
bgmAutoplay: false,
|
||||||
|
bgmVolume: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getSiteSettings(): Promise<SiteSettings> {
|
||||||
|
try {
|
||||||
|
const row = await prisma.siteSetting.findUnique({ where: { id: 1 } });
|
||||||
|
if (!row) return DEFAULTS;
|
||||||
|
return {
|
||||||
|
grayscale: row.grayscale,
|
||||||
|
shutdownBannerEnabled: row.shutdownBannerEnabled,
|
||||||
|
shutdownTitle: row.shutdownTitle,
|
||||||
|
shutdownTitleEn: row.shutdownTitleEn,
|
||||||
|
shutdownSubtitle: row.shutdownSubtitle,
|
||||||
|
shutdownSubtitleEn: row.shutdownSubtitleEn,
|
||||||
|
shutdownAt: row.shutdownAt,
|
||||||
|
bgmUrl: row.bgmUrl,
|
||||||
|
bgmAutoplay: row.bgmAutoplay,
|
||||||
|
bgmVolume: row.bgmVolume,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return DEFAULTS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertSiteSettings(
|
||||||
|
data: Partial<SiteSettings>
|
||||||
|
): Promise<SiteSettings> {
|
||||||
|
const row = await prisma.siteSetting.upsert({
|
||||||
|
where: { id: 1 },
|
||||||
|
create: {
|
||||||
|
id: 1,
|
||||||
|
grayscale: data.grayscale ?? DEFAULTS.grayscale,
|
||||||
|
shutdownBannerEnabled:
|
||||||
|
data.shutdownBannerEnabled ?? DEFAULTS.shutdownBannerEnabled,
|
||||||
|
shutdownTitle: data.shutdownTitle ?? DEFAULTS.shutdownTitle,
|
||||||
|
shutdownTitleEn: data.shutdownTitleEn ?? DEFAULTS.shutdownTitleEn,
|
||||||
|
shutdownSubtitle: data.shutdownSubtitle ?? DEFAULTS.shutdownSubtitle,
|
||||||
|
shutdownSubtitleEn:
|
||||||
|
data.shutdownSubtitleEn ?? DEFAULTS.shutdownSubtitleEn,
|
||||||
|
shutdownAt: data.shutdownAt ?? null,
|
||||||
|
bgmUrl: data.bgmUrl ?? DEFAULTS.bgmUrl,
|
||||||
|
bgmAutoplay: data.bgmAutoplay ?? DEFAULTS.bgmAutoplay,
|
||||||
|
bgmVolume: data.bgmVolume ?? DEFAULTS.bgmVolume,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...(data.grayscale !== undefined && { grayscale: data.grayscale }),
|
||||||
|
...(data.shutdownBannerEnabled !== undefined && {
|
||||||
|
shutdownBannerEnabled: data.shutdownBannerEnabled,
|
||||||
|
}),
|
||||||
|
...(data.shutdownTitle !== undefined && {
|
||||||
|
shutdownTitle: data.shutdownTitle,
|
||||||
|
}),
|
||||||
|
...(data.shutdownTitleEn !== undefined && {
|
||||||
|
shutdownTitleEn: data.shutdownTitleEn,
|
||||||
|
}),
|
||||||
|
...(data.shutdownSubtitle !== undefined && {
|
||||||
|
shutdownSubtitle: data.shutdownSubtitle,
|
||||||
|
}),
|
||||||
|
...(data.shutdownSubtitleEn !== undefined && {
|
||||||
|
shutdownSubtitleEn: data.shutdownSubtitleEn,
|
||||||
|
}),
|
||||||
|
...(data.shutdownAt !== undefined && { shutdownAt: data.shutdownAt }),
|
||||||
|
...(data.bgmUrl !== undefined && { bgmUrl: data.bgmUrl }),
|
||||||
|
...(data.bgmAutoplay !== undefined && { bgmAutoplay: data.bgmAutoplay }),
|
||||||
|
...(data.bgmVolume !== undefined && { bgmVolume: data.bgmVolume }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
grayscale: row.grayscale,
|
||||||
|
shutdownBannerEnabled: row.shutdownBannerEnabled,
|
||||||
|
shutdownTitle: row.shutdownTitle,
|
||||||
|
shutdownTitleEn: row.shutdownTitleEn,
|
||||||
|
shutdownSubtitle: row.shutdownSubtitle,
|
||||||
|
shutdownSubtitleEn: row.shutdownSubtitleEn,
|
||||||
|
shutdownAt: row.shutdownAt,
|
||||||
|
bgmUrl: row.bgmUrl,
|
||||||
|
bgmAutoplay: row.bgmAutoplay,
|
||||||
|
bgmVolume: row.bgmVolume,
|
||||||
|
};
|
||||||
|
}
|
||||||
37
src/lib/wow-versions.ts
Normal file
37
src/lib/wow-versions.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Turtle WoW client major-version constants and request resolvers.
|
||||||
|
*
|
||||||
|
* The public site, admin console, and download APIs currently expose only the
|
||||||
|
* 1.18 channel. Historical rows may still carry a wowVersion tag in the
|
||||||
|
* database, but request resolvers intentionally collapse every request to 1.18.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
export const WOW_VERSIONS = ["1.18"] as const;
|
||||||
|
export type WowVersion = (typeof WOW_VERSIONS)[number];
|
||||||
|
|
||||||
|
export const DEFAULT_WOW_VERSION: WowVersion = "1.18";
|
||||||
|
|
||||||
|
export function isWowVersion(v: unknown): v is WowVersion {
|
||||||
|
return typeof v === "string" && (WOW_VERSIONS as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the requested wow version on a public listing/browsing API. Only
|
||||||
|
* the canonical 1.18 channel is supported, so explicit legacy values are
|
||||||
|
* ignored instead of changing the query scope.
|
||||||
|
*/
|
||||||
|
export function getApiWowVersion(_request: NextRequest | URL): WowVersion {
|
||||||
|
return DEFAULT_WOW_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the wow version for download endpoints. Download URLs are now pinned
|
||||||
|
* to 1.18 regardless of query params, cookies, or historical client channels.
|
||||||
|
*/
|
||||||
|
export function getDownloadWowVersion(
|
||||||
|
_request: NextRequest | URL
|
||||||
|
): WowVersion {
|
||||||
|
return DEFAULT_WOW_VERSION;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user