Compare commits

..

2 Commits

Author SHA1 Message Date
rucky
557ecebee6 乌龟服官服 2026-05-15 19:17:31 +08:00
rucky
fa7aedb8e7 feat: add localization and site settings 2026-05-12 09:58:25 +08:00
67 changed files with 4833 additions and 886 deletions

534
API.md
View File

@@ -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'
```

View File

@@ -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 && \

View File

@@ -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
}

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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>
);
} }

View File

@@ -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}
/>
)}
</>
); );
} }

View File

@@ -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">

View File

@@ -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>
); );
} }

View File

@@ -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>
); );

View File

@@ -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

View File

@@ -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();

View File

@@ -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>

View File

@@ -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 }),

View 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 });
}

View 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);
}

View File

@@ -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,

View 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);
}

View File

@@ -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,
}), }),

View File

@@ -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,
}, },

View File

@@ -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 } } },
}); });

View File

@@ -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 });

View File

@@ -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,
}, },

View 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 });
}

View File

@@ -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,
}, },
}); });

View File

@@ -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,
}),
}, },
}); });

View File

@@ -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,
}, },
}); });

View File

@@ -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(),
})), })),
}); });

View File

@@ -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,
}, },
}); });

View File

@@ -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 } } },
}); });

View File

@@ -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: {

View File

@@ -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 });

View File

@@ -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,
}, },
}); });

View 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 });
}

View File

@@ -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;

View File

@@ -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>
); );

View File

@@ -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="# 插件名称&#10;&#10;详细描述..." placeholder="# 插件名称&#10;&#10;详细描述..."
/> />
</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&#10;&#10;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

View 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>
);
}

View File

@@ -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![${file.name}](${path})\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![pasted-image](${path})\n`
);
}
break;
}
}
}}
/>
)}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
· 退
</p> </p>
</div> </div>

View File

@@ -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 */}

View File

@@ -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&#10;- 修复问题 B" placeholder="- 新增功能 A&#10;- 修复问题 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&#10;- Fixed issue B"
/>
</div>
</div>
<div className="space-y-4"> <div className="space-y-4">
<Label></Label> <Label></Label>

View 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>
);
}

View File

@@ -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>

View File

@@ -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功能&#10;- 修复xxx问题" /> <Textarea
id="changelog"
name="changelog"
rows={6}
placeholder="- 新增xxx功能&#10;- 修复xxx问题"
/>
</div>
<div className="space-y-2">
<Label htmlFor="changelogEn">Changelog (English)</Label>
<Textarea
id="changelogEn"
name="changelogEn"
rows={6}
placeholder="- Added xxx feature&#10;- Fixed xxx issue"
/>
<p className="text-xs text-muted-foreground">
访退
</p>
</div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -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>

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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; // 0100
}
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>
);
}

View 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>
);
}

View File

@@ -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">
&copy; {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>

View File

@@ -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>

View File

@@ -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

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View 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)}</>;
}

View 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
View 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
View 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
View 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
View 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
View 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;
}