feat: Banner UI美化 & 新增文章/公告/图库/媒体管理等功能

- Banner: Ken Burns缩放动效、左右导航箭头、进度条指示器、hover暂停、暗角遮罩、shimmer按钮动画
- 新增文章管理(CRUD)与公开文章页
- 新增Banner/Gallery图片管理API
- 新增媒体管理页面
- 新增更新日志页面
- 新增页面访问追踪
- 新增Markdown渲染组件
- .gitignore排除.cursor目录

Made-with: Cursor
This commit is contained in:
rucky
2026-03-25 09:17:35 +08:00
parent 241a76caeb
commit bf92a69332
66 changed files with 5241 additions and 155 deletions

3
.gitignore vendored
View File

@@ -48,3 +48,6 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
/src/generated/prisma /src/generated/prisma
# cursor ide
.cursor/

641
API.md Normal file
View File

@@ -0,0 +1,641 @@
# Nanami Web API 文档
Base URL: `https://nui.rucky.cn`
---
## 目录
- [公开接口](#公开接口)
- [服务器时间](#服务器时间)
- [检查更新](#检查更新)
- [启动器最新版本](#启动器最新版本)
- [软件版本下载](#软件版本下载)
- [插件列表](#插件列表)
- [插件详情](#插件详情)
- [插件版本下载](#插件版本下载)
- [Banner 列表](#banner-列表)
- [画廊图片列表](#画廊图片列表)
- [管理接口(需认证)](#管理接口需认证)
- [软件管理](#软件管理)
- [软件版本管理](#软件版本管理)
- [插件管理](#插件管理)
- [插件版本发布](#插件版本发布)
- [Banner 管理](#banner-管理)
- [画廊管理](#画廊管理)
- [文件上传](#文件上传)
- [修改密码](#修改密码)
---
## 公开接口
### 服务器时间
获取服务器当前时间。
```
GET /api/server-time
```
**响应示例:**
```json
{
"timestamp": 1710748800000,
"iso": "2026-03-18T08:00:00.000Z",
"timezone": "Asia/Shanghai"
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `timestamp` | `number` | Unix 毫秒时间戳 |
| `iso` | `string` | ISO 8601 格式时间 |
| `timezone` | `string` | 服务器时区 |
---
### 检查更新
客户端检查软件是否有新版本可用。
```
GET /api/software/check-update?slug={slug}&versionCode={versionCode}
```
**查询参数:**
| 参数 | 必填 | 说明 |
|------|------|------|
| `slug` | 是 | 软件标识符,如 `nanami-launcher` |
| `versionCode` | 否 | 当前客户端版本号(整数),默认 `0` |
**响应示例:**
```json
{
"hasUpdate": true,
"forceUpdate": false,
"latest": {
"version": "0.8.15",
"versionCode": 815,
"changelog": "修复了若干问题...",
"downloadUrl": "https://cdn.example.com/launcher-setup.exe",
"fileSize": 52428800,
"minVersion": "0.7.0",
"createdAt": "2026-03-15T10:00:00.000Z"
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `hasUpdate` | `boolean` | 是否有新版本 |
| `forceUpdate` | `boolean` | 是否需要强制更新 |
| `latest.version` | `string` | 最新版本号 |
| `latest.versionCode` | `number` | 最新版本编码 |
| `latest.changelog` | `string` | 更新日志 |
| `latest.downloadUrl` | `string` | 下载地址 |
| `latest.fileSize` | `number` | 文件大小(字节) |
| `latest.minVersion` | `string\|null` | 最低兼容版本 |
| `latest.createdAt` | `string` | 发布时间 |
---
### 启动器最新版本
获取启动器最新版本信息或直接下载。
#### 获取信息
```
GET /api/software/latest?info=1
```
添加 `&track=1` 可同时记录一次下载计数。
**响应示例:**
```json
{
"available": true,
"version": "0.8.15",
"versionCode": 815,
"changelog": "更新内容...",
"fileSize": 52428800,
"createdAt": "2026-03-15T10:00:00.000Z",
"downloadUrl": "https://cdn.example.com/launcher-setup.exe",
"downloadType": "url"
}
```
当无可用版本时返回:
```json
{ "available": false }
```
#### 直接下载
```
GET /api/software/latest
```
- 外部链接类型302 重定向到外部 URL
- 本地文件类型:返回文件流
- 自动记录下载次数
---
### 软件版本下载
按版本 ID 下载特定版本。
```
GET /api/software/download/{id}
```
**路径参数:**
| 参数 | 说明 |
|------|------|
| `id` | 软件版本 ID |
- 外部链接类型302 重定向
- 本地文件类型:返回文件流(`application/octet-stream`
- 自动记录下载次数
**错误响应:**
| 状态码 | 说明 |
|--------|------|
| `404` | 版本不存在或文件不存在 |
---
### 插件列表
获取已发布的插件列表。
```
GET /api/addons
```
**查询参数:**
| 参数 | 必填 | 说明 |
|------|------|------|
| `category` | 否 | 按分类筛选 |
| `search` | 否 | 按名称/简介搜索(模糊匹配) |
| `published` | 否 | 默认 `true`,设为 `false` 查询所有 |
**响应示例:**
```json
[
{
"id": "clxx...",
"name": "插件名",
"slug": "addon-slug",
"summary": "插件简介",
"description": "详细描述...",
"iconUrl": "/uploads/icon.png",
"category": "ui",
"published": true,
"totalDownloads": 1024,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-03-01T00:00:00.000Z",
"releases": [{ "...最新版本..." }],
"_count": { "releases": 5 }
}
]
```
---
### 插件详情
获取单个插件详细信息,包含所有版本和截图。
```
GET /api/addons/{id}
```
**路径参数:** `id` 可以是插件 ID 或 slug。
**响应:** 插件完整信息,含 `releases`(按时间倒序)和 `screenshots`(按排序序号)。
---
### 插件版本下载
```
GET /api/download/{id}
```
**路径参数:**
| 参数 | 说明 |
|------|------|
| `id` | Release 版本 ID |
- 自动增加 Release 和 Addon 的下载计数
- 外部链接类型302 重定向
- 本地文件类型:返回文件流
---
### Banner 列表
```
GET /api/banners
```
**查询参数:**
| 参数 | 说明 |
|------|------|
| `enabled` | 设为 `1` 仅返回已启用的 Banner |
**响应示例:**
```json
[
{
"id": "clxx...",
"imageUrl": "/banners/banner_1.png",
"sortOrder": 0,
"enabled": true,
"createdAt": "2026-01-01T00:00:00.000Z"
}
]
```
---
### 画廊图片列表
```
GET /api/gallery
```
**查询参数:**
| 参数 | 说明 |
|------|------|
| `enabled` | 设为 `1` 仅返回已启用的图片 |
**响应示例:**
```json
[
{
"id": "clxx...",
"imageUrl": "/views/view_1.png",
"title": "主界面",
"sortOrder": 0,
"enabled": true,
"createdAt": "2026-01-01T00:00:00.000Z"
}
]
```
---
## 管理接口(需认证)
以下接口需要管理员登录后的 Session 认证。未认证请求返回 `401 Unauthorized`
### 软件管理
#### 获取软件列表
```
GET /api/software
```
返回所有软件及其最新版本和版本数量。
#### 创建软件
```
POST /api/software
Content-Type: application/json
{
"name": "Nanami Launcher",
"slug": "nanami-launcher",
"description": "启动器描述"
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `name` | 是 | 软件名称 |
| `slug` | 是 | 唯一标识符 |
| `description` | 否 | 描述 |
#### 获取软件详情
```
GET /api/software/{id}
```
`id` 可以是软件 ID 或 slug返回软件信息及所有版本。
#### 更新软件
```
PUT /api/software/{id}
Content-Type: application/json
{
"name": "新名称",
"slug": "new-slug",
"description": "新描述"
}
```
#### 删除软件
```
DELETE /api/software/{id}
```
---
### 软件版本管理
#### 创建新版本
```
POST /api/software/{id}/versions
Content-Type: application/json
{
"version": "1.0.0",
"versionCode": 100,
"changelog": "首次发布",
"downloadType": "url",
"externalUrl": "https://cdn.example.com/file.exe",
"fileSize": 52428800,
"forceUpdate": false,
"minVersion": null
}
```
| 字段 | 必填 | 类型 | 说明 |
|------|------|------|------|
| `version` | 是 | `string` | 版本号 |
| `versionCode` | 是 | `number` | 版本编码(用于比较) |
| `changelog` | 否 | `string` | 更新日志 |
| `downloadType` | 否 | `string` | `"local"``"url"`,默认 `"local"` |
| `filePath` | 否 | `string` | 本地文件路径 |
| `externalUrl` | 否 | `string` | 外部下载链接 |
| `fileSize` | 否 | `number` | 文件大小(字节) |
| `forceUpdate` | 否 | `boolean` | 是否强制更新 |
| `minVersion` | 否 | `string` | 最低兼容版本 |
新版本自动设为 `isLatest: true`,之前的最新版本会被取消。
#### 更新版本
```
PUT /api/software/versions/{versionId}
Content-Type: application/json
{
"version": "1.0.1",
"changelog": "修复问题",
"isLatest": true
}
```
所有字段均为可选,仅更新传入的字段。设置 `isLatest: true` 时自动取消其他版本的最新标记。
#### 删除版本
```
DELETE /api/software/versions/{versionId}
```
---
### 插件管理
#### 创建插件
```
POST /api/addons
Content-Type: application/json
{
"name": "插件名",
"slug": "addon-slug",
"summary": "简介",
"description": "详细描述",
"iconUrl": "/uploads/icon.png",
"category": "ui"
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `name` | 是 | 插件名称 |
| `slug` | 是 | 唯一标识符 |
| `summary` | 是 | 简短描述 |
| `description` | 否 | 详细描述 |
| `iconUrl` | 否 | 图标 URL |
| `category` | 否 | 分类,默认 `"general"` |
#### 更新插件
```
PUT /api/addons/{id}
Content-Type: application/json
{
"name": "新名称",
"published": true
}
```
#### 删除插件
```
DELETE /api/addons/{id}
```
---
### 插件版本发布
```
POST /api/releases
Content-Type: application/json
{
"addonId": "插件ID",
"version": "1.0.0",
"changelog": "首次发布",
"downloadType": "url",
"externalUrl": "https://cdn.example.com/addon.zip",
"gameVersion": "11.1.0"
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `addonId` | 是 | 所属插件 ID |
| `version` | 是 | 版本号 |
| `changelog` | 否 | 更新日志 |
| `downloadType` | 否 | `"local"``"url"` |
| `filePath` | 否 | 本地文件路径 |
| `externalUrl` | 否 | 外部链接(`downloadType``"url"` 时必填) |
| `gameVersion` | 否 | 适配的游戏版本 |
#### 获取版本列表
```
GET /api/releases?addonId={addonId}
```
---
### Banner 管理
#### 创建 Banner
```
POST /api/banners
Content-Type: application/json
{
"imageUrl": "/uploads/banner.png",
"sortOrder": 0,
"enabled": true
}
```
#### 更新 Banner
```
PUT /api/banners/{id}
Content-Type: application/json
{
"sortOrder": 1,
"enabled": false
}
```
#### 删除 Banner
```
DELETE /api/banners/{id}
```
---
### 画廊管理
#### 创建画廊图片
```
POST /api/gallery
Content-Type: application/json
{
"imageUrl": "/uploads/screenshot.png",
"title": "主界面截图",
"sortOrder": 0,
"enabled": true
}
```
#### 更新画廊图片
```
PUT /api/gallery/{id}
Content-Type: application/json
{
"title": "新标题",
"sortOrder": 2,
"enabled": true
}
```
#### 删除画廊图片
```
DELETE /api/gallery/{id}
```
---
### 文件上传
上传文件到服务器。
```
POST /api/upload
Content-Type: multipart/form-data
file: (二进制文件)
```
**响应示例:**
```json
{
"filePath": "/uploads/1710748800000-image.png",
"originalName": "image.png",
"size": 204800
}
```
返回的 `filePath` 可用于其他接口的 `imageUrl``filePath` 等字段。
---
### 修改密码
```
POST /api/admin/change-password
Content-Type: application/json
{
"currentPassword": "当前密码",
"newPassword": "新密码≥6位"
}
```
**错误响应:**
| 状态码 | 说明 |
|--------|------|
| `400` | 参数缺失或新密码过短 |
| `403` | 当前密码错误 |
| `404` | 用户不存在 |
---
## 通用错误格式
所有 API 错误均以 JSON 格式返回:
```json
{
"error": "错误描述信息"
}
```
| 状态码 | 说明 |
|--------|------|
| `400` | 请求参数错误 |
| `401` | 未认证 |
| `404` | 资源不存在 |
| `409` | 资源冲突(如 slug 重复) |
| `500` | 服务器内部错误 |

2282
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
"@prisma/adapter-pg": "^7.5.0", "@prisma/adapter-pg": "^7.5.0",
"@prisma/client": "^7.5.0", "@prisma/client": "^7.5.0",
"@uiw/react-md-editor": "^4.0.11",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -30,6 +31,9 @@
"pg": "^8.20.0", "pg": "^8.20.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"shadcn": "^4.0.8", "shadcn": "^4.0.8",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",

View File

@@ -87,3 +87,45 @@ model SoftwareVersion {
@@unique([softwareId, version]) @@unique([softwareId, version])
@@index([softwareId]) @@index([softwareId])
} }
model BannerImage {
id String @id @default(cuid())
imageUrl String
sortOrder Int @default(0)
enabled Boolean @default(true)
createdAt DateTime @default(now())
}
model GalleryImage {
id String @id @default(cuid())
imageUrl String
title String @default("")
sortOrder Int @default(0)
enabled Boolean @default(true)
createdAt DateTime @default(now())
}
model Article {
id String @id @default(cuid())
title String
slug String @unique
summary String @default("")
content String @db.Text
coverImage String?
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model PageView {
id String @id @default(cuid())
path String
referrer String @default("")
userAgent String @default("")
ip String @default("")
date String
createdAt DateTime @default(now())
@@index([date])
@@index([path])
}

BIN
public/views/view_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
public/views/view_10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
public/views/view_11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
public/views/view_12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
public/views/view_13.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
public/views/view_14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/views/view_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

BIN
public/views/view_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
public/views/view_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
public/views/view_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

BIN
public/views/view_6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
public/views/view_7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
public/views/view_8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
public/views/view_9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@@ -0,0 +1,67 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Calendar, ArrowLeft } from "lucide-react";
import { MarkdownContent } from "@/components/public/MarkdownContent";
export const dynamic = "force-dynamic";
export default async function ArticleDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const article = await prisma.article.findUnique({
where: { slug, published: true },
});
if (!article) notFound();
return (
<article className="mx-auto max-w-3xl px-3 py-10 sm:px-4 sm:py-16">
<Link
href="/articles"
className="mb-6 inline-flex items-center gap-1.5 text-sm text-gray-400 transition-colors hover:text-amber-200"
>
<ArrowLeft className="h-3.5 w-3.5" />
</Link>
<h1 className="mb-3 text-2xl font-bold text-amber-100 sm:text-3xl">
{article.title}
</h1>
<div className="mb-6 flex items-center gap-1.5 text-sm text-gray-500">
<Calendar className="h-3.5 w-3.5" />
{new Date(article.createdAt).toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
{article.coverImage && (
<div className="mb-8 overflow-hidden rounded-lg">
<img
src={article.coverImage}
alt={article.title}
className="w-full object-cover"
/>
</div>
)}
<MarkdownContent content={article.content} />
<div className="mt-12 border-t border-amber-500/10 pt-6">
<Link
href="/articles"
className="inline-flex items-center gap-1.5 text-sm text-gray-400 transition-colors hover:text-amber-200"
>
<ArrowLeft className="h-3.5 w-3.5" />
</Link>
</div>
</article>
);
}

View File

@@ -0,0 +1,61 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Calendar } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function ArticlesPage() {
const articles = await prisma.article.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
});
return (
<section className="mx-auto max-w-4xl px-3 py-10 sm:px-4 sm:py-16">
<h1 className="mb-2 text-2xl font-bold text-amber-100 sm:text-3xl">
</h1>
<p className="mb-8 text-sm text-gray-400 sm:mb-12 sm:text-base">
</p>
{articles.length === 0 ? (
<p className="py-16 text-center text-gray-500"></p>
) : (
<div className="grid gap-6 sm:grid-cols-2">
{articles.map((article) => (
<Link
key={article.id}
href={`/articles/${article.slug}`}
className="group overflow-hidden rounded-lg border border-amber-500/10 bg-white/[0.03] transition-colors hover:border-amber-500/25"
>
{article.coverImage && (
<div className="aspect-[16/9] overflow-hidden">
<img
src={article.coverImage}
alt={article.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
)}
<div className="p-4 sm:p-5">
<h2 className="mb-2 text-lg font-semibold text-amber-100 group-hover:text-amber-200 sm:text-xl">
{article.title}
</h2>
{article.summary && (
<p className="mb-3 line-clamp-2 text-sm text-gray-400">
{article.summary}
</p>
)}
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Calendar className="h-3 w-3" />
{new Date(article.createdAt).toLocaleDateString("zh-CN")}
</div>
</div>
</Link>
))}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,97 @@
import { prisma } from "@/lib/db";
import { Download, Calendar, Tag } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function ChangelogPage() {
const software = await prisma.software.findUnique({
where: { slug: "nanami-launcher" },
include: {
versions: {
orderBy: { versionCode: "desc" },
},
},
});
const versions = software?.versions ?? [];
return (
<section className="mx-auto max-w-3xl px-3 py-10 sm:px-4 sm:py-16">
<h1 className="mb-2 text-2xl font-bold text-amber-100 sm:text-3xl">
</h1>
<p className="mb-8 text-sm text-gray-400 sm:mb-12 sm:text-base">
Nanami
</p>
{versions.length === 0 ? (
<p className="py-16 text-center text-gray-500"></p>
) : (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-[15px] top-2 bottom-0 w-px bg-amber-500/20 sm:left-[19px]" />
<div className="space-y-8 sm:space-y-10">
{versions.map((v, idx) => (
<div key={v.id} className="relative pl-10 sm:pl-12">
{/* Timeline dot */}
<div
className={`absolute left-[10px] top-1.5 h-3 w-3 rounded-full border-2 sm:left-[13px] sm:h-3.5 sm:w-3.5 ${
idx === 0
? "border-amber-400 bg-amber-400 shadow-[0_0_8px_rgba(251,191,36,0.5)]"
: "border-amber-500/40 bg-[#0d0b15]"
}`}
/>
<div className="rounded-lg border border-amber-500/10 bg-white/[0.03] p-4 sm:p-5">
{/* Header */}
<div className="mb-3 flex flex-wrap items-center gap-2 sm:gap-3">
<h2 className="text-lg font-semibold text-amber-100 sm:text-xl">
v{v.version}
</h2>
{v.isLatest && (
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-300 sm:text-xs">
</span>
)}
{v.forceUpdate && (
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-medium text-red-400 sm:text-xs">
</span>
)}
</div>
{/* Meta */}
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-gray-500 sm:gap-4 sm:text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
{new Date(v.createdAt).toLocaleDateString("zh-CN")}
</span>
<span className="flex items-center gap-1">
<Tag className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
Build {v.versionCode}
</span>
<span className="flex items-center gap-1">
<Download className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
{v.downloadCount.toLocaleString()}
</span>
{v.fileSize > 0 && (
<span className="text-gray-600">
{(v.fileSize / 1024 / 1024).toFixed(1)} MB
</span>
)}
</div>
{/* Changelog */}
<div className="whitespace-pre-wrap text-sm leading-relaxed text-gray-300 sm:text-[15px]">
{v.changelog || "无更新说明"}
</div>
</div>
</div>
))}
</div>
</div>
)}
</section>
);
}

View File

@@ -1,5 +1,6 @@
import { Navbar } from "@/components/public/Navbar"; import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer"; import { Footer } from "@/components/public/Footer";
import { PageTracker } from "@/components/public/PageTracker";
export default function PublicLayout({ export default function PublicLayout({
children, children,
@@ -11,6 +12,7 @@ export default function PublicLayout({
<Navbar /> <Navbar />
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>
<Footer /> <Footer />
<PageTracker />
</div> </div>
); );
} }

View File

@@ -3,12 +3,14 @@ import { prisma } from "@/lib/db";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AddonCard } from "@/components/public/AddonCard"; import { AddonCard } from "@/components/public/AddonCard";
import { HeroBanner } from "@/components/public/HeroBanner"; import { HeroBanner } from "@/components/public/HeroBanner";
import { Sparkles, Shield, Zap } from "lucide-react"; import { GameGallery } from "@/components/public/GameGallery";
import { Sparkles, Shield, Zap, Calendar } from "lucide-react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function HomePage() { export default async function HomePage() {
const [featuredAddons, totalDownloads, launcher] = await Promise.all([ const [featuredAddons, launcher, launcherDownloads, banners, galleryImages, latestArticles] =
await Promise.all([
prisma.addon.findMany({ prisma.addon.findMany({
where: { published: true }, where: { published: true },
include: { include: {
@@ -20,9 +22,6 @@ export default async function HomePage() {
orderBy: { totalDownloads: "desc" }, orderBy: { totalDownloads: "desc" },
take: 6, take: 6,
}), }),
prisma.addon.aggregate({
_sum: { totalDownloads: true },
}),
prisma.software.findUnique({ prisma.software.findUnique({
where: { slug: "nanami-launcher" }, where: { slug: "nanami-launcher" },
include: { include: {
@@ -32,22 +31,45 @@ export default async function HomePage() {
}, },
}, },
}), }),
prisma.softwareVersion.aggregate({
where: { software: { slug: "nanami-launcher" } },
_sum: { downloadCount: true },
}),
prisma.bannerImage.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
select: { imageUrl: true },
}),
prisma.galleryImage.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
select: { imageUrl: true, title: true },
}),
prisma.article.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
take: 3,
}),
]); ]);
const launcherVersion = launcher?.versions[0]?.version ?? null; const launcherVersion = launcher?.versions[0]?.version ?? null;
const totalDownloads = launcherDownloads._sum.downloadCount ?? 0;
return ( return (
<> <>
<HeroBanner <HeroBanner
totalDownloads={totalDownloads._sum.totalDownloads ?? undefined} totalDownloads={totalDownloads || undefined}
launcherVersion={launcherVersion} launcherVersion={launcherVersion}
banners={banners}
/> />
<GameGallery items={galleryImages} />
{/* Features */} {/* Features */}
<section className="relative border-t border-amber-900/20 bg-gradient-to-b from-[#0d0b15] to-[#110f1a] dark:from-[#0d0b15] dark:to-[#110f1a]"> <section className="relative border-t border-amber-900/20 bg-gradient-to-b from-[#0d0b15] to-[#110f1a] dark:from-[#0d0b15] dark:to-[#110f1a]">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(168,85,247,0.06)_0%,transparent_70%)]" /> <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(168,85,247,0.06)_0%,transparent_70%)]" />
<div className="relative mx-auto max-w-6xl px-4 py-16"> <div className="relative mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
<div className="grid gap-8 md:grid-cols-3"> <div className="grid gap-4 sm:gap-8 md:grid-cols-3">
<div className="flex flex-col items-center rounded-xl border border-amber-500/10 bg-white/5 p-6 text-center backdrop-blur transition-colors hover:border-amber-500/25"> <div className="flex flex-col items-center rounded-xl border border-amber-500/10 bg-white/5 p-6 text-center backdrop-blur transition-colors hover:border-amber-500/25">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-500/10"> <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-500/10">
<Sparkles className="h-6 w-6 text-amber-400" /> <Sparkles className="h-6 w-6 text-amber-400" />
@@ -83,11 +105,62 @@ export default async function HomePage() {
</div> </div>
</section> </section>
{/* Latest Articles */}
{latestArticles.length > 0 && (
<section className="border-t border-amber-900/20 bg-gradient-to-b from-[#110f1a] to-[#0d0b15] dark:from-[#110f1a] dark:to-[#0d0b15]">
<div className="mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
<div className="mb-6 flex items-center justify-between sm:mb-8">
<h2 className="text-2xl font-bold text-amber-100"></h2>
<Button
variant="outline"
className="border-amber-500/20 text-amber-200 hover:border-amber-500/40 hover:bg-amber-500/10 hover:text-amber-100"
render={<Link href="/articles" />}
>
</Button>
</div>
<div className="grid gap-4 sm:gap-6 md:grid-cols-3">
{latestArticles.map((article) => (
<Link
key={article.id}
href={`/articles/${article.slug}`}
className="group overflow-hidden rounded-xl border border-amber-500/10 bg-white/[0.03] transition-colors hover:border-amber-500/25"
>
{article.coverImage && (
<div className="aspect-[16/9] overflow-hidden">
<img
src={article.coverImage}
alt={article.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
)}
<div className="p-4">
<h3 className="mb-1.5 font-semibold text-amber-100 group-hover:text-amber-200 line-clamp-1">
{article.title}
</h3>
{article.summary && (
<p className="mb-2 line-clamp-2 text-sm text-gray-400">
{article.summary}
</p>
)}
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Calendar className="h-3 w-3" />
{new Date(article.createdAt).toLocaleDateString("zh-CN")}
</div>
</div>
</Link>
))}
</div>
</div>
</section>
)}
{/* Featured Addons */} {/* Featured Addons */}
{featuredAddons.length > 0 && ( {featuredAddons.length > 0 && (
<section className="border-t border-amber-900/20 bg-gradient-to-b from-[#110f1a] to-[#0d0b15] dark:from-[#110f1a] dark:to-[#0d0b15]"> <section className="border-t border-amber-900/20 bg-gradient-to-b from-[#110f1a] to-[#0d0b15] dark:from-[#110f1a] dark:to-[#0d0b15]">
<div className="mx-auto max-w-6xl px-4 py-16"> <div className="mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
<div className="mb-8 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between sm:mb-8">
<h2 className="text-2xl font-bold text-amber-100"></h2> <h2 className="text-2xl font-bold text-amber-100"></h2>
<Button <Button
variant="outline" variant="outline"

View File

@@ -0,0 +1,33 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { ArticleEditor } from "@/components/admin/ArticleEditor";
export const dynamic = "force-dynamic";
export default async function EditArticlePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const article = await prisma.article.findUnique({ where: { id } });
if (!article) notFound();
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold"></h1>
<ArticleEditor
initial={{
id: article.id,
title: article.title,
slug: article.slug,
summary: article.summary,
content: article.content,
coverImage: article.coverImage,
published: article.published,
}}
/>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { ArticleEditor } from "@/components/admin/ArticleEditor";
export default function NewArticlePage() {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold"></h1>
<ArticleEditor />
</div>
);
}

View File

@@ -0,0 +1,73 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Plus } from "lucide-react";
import { ArticleActions } from "@/components/admin/ArticleActions";
export const dynamic = "force-dynamic";
export default async function ArticlesPage() {
const articles = await prisma.article.findMany({
orderBy: { createdAt: "desc" },
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold"></h1>
<Button render={<Link href="/admin/articles/new" />}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{articles.length === 0 ? (
<p className="py-10 text-center text-muted-foreground">
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{articles.map((article) => (
<TableRow key={article.id}>
<TableCell className="font-medium">{article.title}</TableCell>
<TableCell>
<Badge variant={article.published ? "default" : "secondary"}>
{article.published ? "已发布" : "草稿"}
</Badge>
</TableCell>
<TableCell>
{new Date(article.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
<TableCell>
{new Date(article.updatedAt).toLocaleDateString("zh-CN")}
</TableCell>
<TableCell className="text-right">
<ArticleActions id={article.id} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { prisma } from "@/lib/db";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MediaManager } from "@/components/admin/MediaManager";
export const dynamic = "force-dynamic";
export default async function MediaPage() {
const [banners, gallery] = await Promise.all([
prisma.bannerImage.findMany({ orderBy: { sortOrder: "asc" } }),
prisma.galleryImage.findMany({ orderBy: { sortOrder: "asc" } }),
]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground">
Banner
</p>
</div>
<Tabs defaultValue="banner">
<TabsList>
<TabsTrigger value="banner">Banner </TabsTrigger>
<TabsTrigger value="gallery"></TabsTrigger>
</TabsList>
<TabsContent value="banner" className="mt-6">
<MediaManager type="banner" initial={JSON.parse(JSON.stringify(banners))} />
</TabsContent>
<TabsContent value="gallery" className="mt-6">
<MediaManager type="gallery" initial={JSON.parse(JSON.stringify(gallery))} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -6,13 +6,34 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Package, Download, FileUp } from "lucide-react"; import { Package, Download, FileUp, Eye, Users, TrendingUp } from "lucide-react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
function getLast7Days() {
const days: string[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
days.push(d.toISOString().slice(0, 10));
}
return days;
}
export default async function DashboardPage() { export default async function DashboardPage() {
const [addonCount, totalDownloads, releaseCount, recentReleases] = const today = new Date().toISOString().slice(0, 10);
await Promise.all([ const days = getLast7Days();
const [
addonCount,
totalDownloads,
releaseCount,
recentReleases,
todayPV,
totalPV,
todayUV,
pvByDay,
] = await Promise.all([
prisma.addon.count(), prisma.addon.count(),
prisma.addon.aggregate({ _sum: { totalDownloads: true } }), prisma.addon.aggregate({ _sum: { totalDownloads: true } }),
prisma.release.count(), prisma.release.count(),
@@ -21,24 +42,34 @@ export default async function DashboardPage() {
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { addon: { select: { name: true } } }, include: { addon: { select: { name: true } } },
}), }),
prisma.pageView.count({ where: { date: today } }),
prisma.pageView.count(),
prisma.pageView.groupBy({
by: ["ip"],
where: { date: today, ip: { not: "" } },
}).then((r) => r.length),
prisma.pageView.groupBy({
by: ["date"],
where: { date: { in: days } },
_count: true,
orderBy: { date: "asc" },
}),
]); ]);
const pvMap = new Map(pvByDay.map((d) => [d.date, d._count]));
const chartData = days.map((d) => ({
date: d.slice(5),
pv: pvMap.get(d) || 0,
}));
const maxPV = Math.max(...chartData.map((d) => d.pv), 1);
const stats = [ const stats = [
{ { title: "插件总数", value: addonCount, icon: Package },
title: "插件总数", { title: "总下载量", value: totalDownloads._sum.totalDownloads || 0, icon: Download },
value: addonCount, { title: "版本发布数", value: releaseCount, icon: FileUp },
icon: Package, { title: "今日访问 (PV)", value: todayPV, icon: Eye },
}, { title: "今日独立访客 (UV)", value: todayUV, icon: Users },
{ { title: "累计访问量", value: totalPV, icon: TrendingUp },
title: "总下载量",
value: totalDownloads._sum.totalDownloads || 0,
icon: Download,
},
{
title: "版本发布数",
value: releaseCount,
icon: FileUp,
},
]; ];
return ( return (
@@ -55,12 +86,42 @@ export default async function DashboardPage() {
<stat.icon className="h-5 w-5 text-muted-foreground" /> <stat.icon className="h-5 w-5 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-3xl font-bold">{stat.value}</div> <div className="text-3xl font-bold">
{stat.value.toLocaleString()}
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
{/* 7-day PV chart */}
<Card>
<CardHeader>
<CardTitle> 7 访</CardTitle>
<CardDescription> (PV)</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-48 items-end gap-2">
{chartData.map((d) => (
<div key={d.date} className="flex flex-1 flex-col items-center gap-1">
<span className="text-xs font-medium text-muted-foreground">
{d.pv}
</span>
<div
className="w-full rounded-t bg-primary/80 transition-all"
style={{
height: `${Math.max((d.pv / maxPV) * 160, 4)}px`,
}}
/>
<span className="text-[10px] text-muted-foreground">
{d.date}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const article = await prisma.article.findFirst({
where: { OR: [{ id }, { slug: id }] },
});
if (!article) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(article);
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
if (body.slug) {
const existing = await prisma.article.findFirst({
where: { slug: body.slug, NOT: { id } },
});
if (existing) {
return NextResponse.json(
{ error: "Slug already exists" },
{ status: 409 }
);
}
}
const article = await prisma.article.update({
where: { id },
data: {
...(body.title !== undefined && { title: body.title }),
...(body.slug !== undefined && { slug: body.slug }),
...(body.summary !== undefined && { summary: body.summary }),
...(body.content !== undefined && { content: body.content }),
...(body.coverImage !== undefined && {
coverImage: body.coverImage || null,
}),
...(body.published !== undefined && { published: body.published }),
},
});
return NextResponse.json(article);
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
await prisma.article.delete({ where: { id } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const publishedOnly = searchParams.get("published") !== "false";
const limit = searchParams.get("limit");
const where = publishedOnly ? { published: true } : {};
const articles = await prisma.article.findMany({
where,
orderBy: { createdAt: "desc" },
...(limit ? { take: parseInt(limit, 10) } : {}),
});
return NextResponse.json(articles);
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { title, slug, summary, content, coverImage, published } = body;
if (!title || !slug) {
return NextResponse.json(
{ error: "title and slug are required" },
{ status: 400 }
);
}
const existing = await prisma.article.findUnique({ where: { slug } });
if (existing) {
return NextResponse.json(
{ error: "Slug already exists" },
{ status: 409 }
);
}
const article = await prisma.article.create({
data: {
title,
slug,
summary: summary || "",
content: content || "",
coverImage: coverImage || null,
published: published ?? false,
},
});
return NextResponse.json(article, { status: 201 });
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const data: Record<string, unknown> = {};
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder;
if (body.enabled !== undefined) data.enabled = body.enabled;
if (body.imageUrl !== undefined) data.imageUrl = body.imageUrl;
const updated = await prisma.bannerImage.update({ where: { id }, data });
return NextResponse.json(updated);
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
await prisma.bannerImage.delete({ where: { id } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
export async function GET(request: NextRequest) {
const enabledOnly = request.nextUrl.searchParams.get("enabled") === "1";
const where = enabledOnly ? { enabled: true } : {};
const banners = await prisma.bannerImage.findMany({
where,
orderBy: { sortOrder: "asc" },
});
return NextResponse.json(banners);
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const banner = await prisma.bannerImage.create({
data: {
imageUrl: body.imageUrl,
sortOrder: body.sortOrder ?? 0,
enabled: body.enabled ?? true,
},
});
return NextResponse.json(banner);
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const data: Record<string, unknown> = {};
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder;
if (body.enabled !== undefined) data.enabled = body.enabled;
if (body.title !== undefined) data.title = body.title;
if (body.imageUrl !== undefined) data.imageUrl = body.imageUrl;
const updated = await prisma.galleryImage.update({ where: { id }, data });
return NextResponse.json(updated);
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
await prisma.galleryImage.delete({ where: { id } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
export async function GET(request: NextRequest) {
const enabledOnly = request.nextUrl.searchParams.get("enabled") === "1";
const where = enabledOnly ? { enabled: true } : {};
const images = await prisma.galleryImage.findMany({
where,
orderBy: { sortOrder: "asc" },
});
return NextResponse.json(images);
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const image = await prisma.galleryImage.create({
data: {
imageUrl: body.imageUrl,
title: body.title ?? "",
sortOrder: body.sortOrder ?? 0,
enabled: body.enabled ?? true,
},
});
return NextResponse.json(image);
}

36
src/app/api/seed/route.ts Normal file
View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function POST(request: NextRequest) {
const key = request.nextUrl.searchParams.get("key");
if (key !== "nanami-init-2024") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const bannerCount = await prisma.bannerImage.count();
const galleryCount = await prisma.galleryImage.count();
let bannersCreated = 0;
let galleryCreated = 0;
if (bannerCount === 0) {
const banners = [
{ imageUrl: "/banners/banner_2.png", sortOrder: 0 },
{ imageUrl: "/banners/banner_1.png", sortOrder: 1 },
{ imageUrl: "/banners/banner_3.png", sortOrder: 2 },
];
await prisma.bannerImage.createMany({ data: banners });
bannersCreated = banners.length;
}
if (galleryCount === 0) {
const gallery = Array.from({ length: 14 }, (_, i) => ({
imageUrl: `/views/view_${i + 1}.png`,
sortOrder: i,
}));
await prisma.galleryImage.createMany({ data: gallery });
galleryCreated = gallery.length;
}
return NextResponse.json({ bannersCreated, galleryCreated });
}

View File

@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
export async function GET() {
const now = new Date();
return NextResponse.json({
timestamp: now.getTime(),
iso: now.toISOString(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
}

View File

@@ -37,9 +37,15 @@ export async function GET(request: NextRequest) {
const hasUpdate = latest.versionCode > clientVersionCode; const hasUpdate = latest.versionCode > clientVersionCode;
const downloadUrl = latest.downloadType === "url" && latest.externalUrl const origin =
request.headers.get("x-forwarded-proto") && request.headers.get("host")
? `${request.headers.get("x-forwarded-proto")}://${request.headers.get("host")}`
: request.nextUrl.origin;
const downloadUrl =
latest.downloadType === "url" && latest.externalUrl
? latest.externalUrl ? latest.externalUrl
: `${request.nextUrl.origin}/api/software/download/${latest.id}`; : `${origin}/api/software/download/${latest.id}`;
return NextResponse.json({ return NextResponse.json({
hasUpdate, hasUpdate,

View File

@@ -32,10 +32,19 @@ export async function GET(request: NextRequest) {
const latest = software.versions[0]; const latest = software.versions[0];
if (infoOnly) { if (infoOnly) {
const track = searchParams.get("track") === "1";
if (track) {
await prisma.softwareVersion.update({
where: { id: latest.id },
data: { downloadCount: { increment: 1 } },
});
}
const downloadUrl = const downloadUrl =
latest.downloadType === "url" && latest.externalUrl latest.downloadType === "url" && latest.externalUrl
? latest.externalUrl ? latest.externalUrl
: `/api/software/download/${latest.id}`; : `/api/software/download/${latest.id}`;
return NextResponse.json({ return NextResponse.json({
available: true, available: true,
version: latest.version, version: latest.version,

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const path = body.path || "/";
const referrer = body.referrer || "";
const ua = request.headers.get("user-agent") || "";
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"";
const date = new Date().toISOString().slice(0, 10);
await prisma.pageView.create({
data: { path, referrer, userAgent: ua, ip, date },
});
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ ok: false }, { status: 500 });
}
}

View File

@@ -138,59 +138,275 @@
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
height: 80px; height: 100px;
z-index: 30; z-index: 30;
pointer-events: none; pointer-events: none;
filter: blur(30px); filter: blur(40px);
opacity: 0.7; opacity: 0.7;
} }
.hero-aurora--top { .hero-aurora--top {
top: -30px; top: -40px;
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
transparent 0%, transparent 0%,
rgba(168, 85, 247, 0.4) 15%, rgba(168, 85, 247, 0.45) 12%,
rgba(59, 130, 246, 0.3) 30%, rgba(59, 130, 246, 0.35) 28%,
rgba(236, 72, 153, 0.35) 50%, rgba(236, 72, 153, 0.4) 45%,
rgba(139, 92, 246, 0.4) 70%, rgba(245, 158, 11, 0.3) 60%,
rgba(6, 182, 212, 0.3) 85%, rgba(139, 92, 246, 0.45) 75%,
rgba(6, 182, 212, 0.35) 88%,
transparent 100% transparent 100%
); );
animation: auroraShift 8s ease-in-out infinite alternate; animation: auroraShift 10s ease-in-out infinite alternate;
} }
.hero-aurora--bottom { .hero-aurora--bottom {
bottom: -30px; bottom: -40px;
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
transparent 0%, transparent 0%,
rgba(245, 158, 11, 0.35) 15%, rgba(245, 158, 11, 0.4) 12%,
rgba(168, 85, 247, 0.3) 35%, rgba(168, 85, 247, 0.35) 30%,
rgba(6, 182, 212, 0.35) 55%, rgba(6, 182, 212, 0.4) 48%,
rgba(236, 72, 153, 0.3) 75%, rgba(236, 72, 153, 0.35) 65%,
rgba(245, 158, 11, 0.35) 90%, rgba(139, 92, 246, 0.3) 80%,
rgba(245, 158, 11, 0.4) 92%,
transparent 100% transparent 100%
); );
animation: auroraShift 8s ease-in-out infinite alternate-reverse; animation: auroraShift 10s ease-in-out infinite alternate-reverse;
} }
@keyframes auroraShift { @keyframes auroraShift {
0% { 0% {
background-position: 0% 50%; background-position: 0% 50%;
opacity: 0.5; opacity: 0.45;
filter: blur(35px);
} }
50% { 33% {
opacity: 0.8; opacity: 0.8;
filter: blur(45px);
}
66% {
opacity: 0.6;
filter: blur(38px);
} }
100% { 100% {
background-position: 100% 50%; background-position: 100% 50%;
opacity: 0.5; opacity: 0.45;
filter: blur(35px);
} }
} }
/* Force background-size for aurora animation */
.hero-aurora--top, .hero-aurora--top,
.hero-aurora--bottom { .hero-aurora--bottom {
background-size: 200% 100%; background-size: 200% 100%;
} }
/* ---- Hero Banner Effects ---- */
.hero-banner {
border-bottom: 1px solid rgba(245, 158, 11, 0.08);
}
.hero-scanlines {
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.015) 2px,
rgba(0, 0, 0, 0.015) 4px
);
mix-blend-mode: multiply;
}
.hero-tagline {
text-shadow: 0 0 20px rgba(245, 158, 11, 0.2);
animation: taglineFade 1.5s ease-out;
}
@keyframes taglineFade {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hero-shimmer {
background: linear-gradient(
105deg,
transparent 40%,
rgba(255, 255, 255, 0.06) 45%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0.06) 55%,
transparent 60%
);
background-size: 250% 100%;
animation: shimmerSweep 4s ease-in-out infinite;
}
@keyframes shimmerSweep {
0%, 100% {
background-position: 200% 0;
}
50% {
background-position: -50% 0;
}
}
.hero-download-btn::before {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
135deg,
rgba(245, 158, 11, 0.3),
rgba(168, 85, 247, 0.15),
rgba(245, 158, 11, 0.3)
);
background-size: 200% 200%;
animation: borderGlow 4s ease-in-out infinite;
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
@keyframes borderGlow {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.hero-indicator {
backdrop-filter: blur(4px);
}
@media (prefers-reduced-motion: reduce) {
.hero-shimmer,
.hero-tagline {
animation: none;
}
.hero-download-btn::before {
animation: none;
}
.hero-scanlines {
display: none;
}
}
/* Download button shimmer */
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
/* ---- Gallery Thumbnail Scrollbar ---- */
.gallery-thumbs {
scrollbar-width: thin;
scrollbar-color: rgba(251, 191, 36, 0.25) transparent;
}
.gallery-thumbs::-webkit-scrollbar {
height: 6px;
}
.gallery-thumbs::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.04);
border-radius: 3px;
}
.gallery-thumbs::-webkit-scrollbar-thumb {
background: rgba(251, 191, 36, 0.2);
border-radius: 3px;
transition: background 0.2s;
}
.gallery-thumbs::-webkit-scrollbar-thumb:hover {
background: rgba(251, 191, 36, 0.45);
}
/* ---- Article Markdown Prose ---- */
.prose-article {
color: #d1d5db;
line-height: 1.8;
font-size: 15px;
}
.prose-article h1,
.prose-article h2,
.prose-article h3,
.prose-article h4 {
color: #fef3c7;
font-weight: 700;
margin-top: 1.6em;
margin-bottom: 0.6em;
}
.prose-article h1 { font-size: 1.75em; }
.prose-article h2 { font-size: 1.4em; border-bottom: 1px solid rgba(251,191,36,0.15); padding-bottom: 0.3em; }
.prose-article h3 { font-size: 1.15em; }
.prose-article p { margin-bottom: 1em; }
.prose-article a {
color: #fbbf24;
text-decoration: underline;
text-underline-offset: 2px;
transition: color 0.15s;
}
.prose-article a:hover { color: #fde68a; }
.prose-article strong { color: #fef3c7; }
.prose-article blockquote {
border-left: 3px solid rgba(251,191,36,0.3);
padding: 0.5em 1em;
margin: 1em 0;
color: #9ca3af;
background: rgba(255,255,255,0.02);
border-radius: 0 6px 6px 0;
}
.prose-article ul, .prose-article ol { padding-left: 1.5em; margin-bottom: 1em; }
.prose-article li { margin-bottom: 0.3em; }
.prose-article ul { list-style-type: disc; }
.prose-article ol { list-style-type: decimal; }
.prose-article code {
background: rgba(255,255,255,0.06);
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.9em;
color: #fbbf24;
}
.prose-article pre {
background: rgba(0,0,0,0.4);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 8px;
padding: 1em;
overflow-x: auto;
margin-bottom: 1em;
}
.prose-article pre code {
background: none;
padding: 0;
color: #d1d5db;
}
.prose-article img {
max-width: 100%;
border-radius: 8px;
margin: 1em 0;
}
.prose-article table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1em;
}
.prose-article th, .prose-article td {
border: 1px solid rgba(255,255,255,0.1);
padding: 0.5em 0.75em;
text-align: left;
}
.prose-article th {
background: rgba(255,255,255,0.04);
color: #fef3c7;
font-weight: 600;
}
.prose-article hr {
border: none;
border-top: 1px solid rgba(251,191,36,0.15);
margin: 2em 0;
}

View File

@@ -0,0 +1,49 @@
import { NextRequest } from "next/server";
import { readFile, stat } from "fs/promises";
import path from "path";
const MIME: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".zip": "application/zip",
".exe": "application/octet-stream",
};
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const segments = await params;
const filePath = path.join(process.cwd(), "uploads", ...segments.path);
const resolved = path.resolve(filePath);
const uploadsDir = path.resolve(path.join(process.cwd(), "uploads"));
if (!resolved.startsWith(uploadsDir)) {
return new Response("Forbidden", { status: 403 });
}
try {
const info = await stat(resolved);
if (!info.isFile()) {
return new Response("Not Found", { status: 404 });
}
const buffer = await readFile(resolved);
const ext = path.extname(resolved).toLowerCase();
const contentType = MIME[ext] || "application/octet-stream";
return new Response(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch {
return new Response("Not Found", { status: 404 });
}
}

View File

@@ -0,0 +1,37 @@
"use client";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { Pencil, Trash2 } from "lucide-react";
export function ArticleActions({ id }: { id: string }) {
const router = useRouter();
const handleDelete = async () => {
if (!confirm("确定要删除这篇文章吗?")) return;
try {
const res = await fetch(`/api/articles/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error();
toast.success("文章已删除");
router.refresh();
} catch {
toast.error("删除失败");
}
};
return (
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/articles/${id}/edit`)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={handleDelete}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,268 @@
"use client";
import { useState, useRef } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
import { ImagePlus, Trash2 } from "lucide-react";
const MDEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false });
interface ArticleData {
id?: string;
title: string;
slug: string;
summary: string;
content: string;
coverImage: string | null;
published: boolean;
}
export function ArticleEditor({ initial }: { initial?: ArticleData }) {
const router = useRouter();
const isEdit = !!initial?.id;
const fileInputRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState(initial?.title ?? "");
const [slug, setSlug] = useState(initial?.slug ?? "");
const [summary, setSummary] = useState(initial?.summary ?? "");
const [content, setContent] = useState(initial?.content ?? "");
const [coverImage, setCoverImage] = useState(initial?.coverImage ?? "");
const [published, setPublished] = useState(initial?.published ?? false);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [slugEdited, setSlugEdited] = useState(false);
const generateSlug = (t: string) =>
t
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
.replace(/^-|-$/g, "");
const handleTitleChange = (val: string) => {
setTitle(val);
if (!slugEdited && !isEdit) {
setSlug(generateSlug(val));
}
};
const uploadFile = async (file: File): Promise<string | null> => {
const form = new FormData();
form.append("file", file);
try {
const res = await fetch("/api/upload", { method: "POST", body: form });
if (!res.ok) throw new Error();
const data = await res.json();
return data.filePath as string;
} catch {
toast.error("图片上传失败");
return null;
}
};
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const path = await uploadFile(file);
if (path) setCoverImage(path);
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSave = async (asDraft = false) => {
if (!title.trim() || !slug.trim()) {
toast.error("标题和 Slug 不能为空");
return;
}
setSaving(true);
try {
const body = {
title: title.trim(),
slug: slug.trim(),
summary: summary.trim(),
content,
coverImage: coverImage || null,
published: asDraft ? false : published,
};
const url = isEdit ? `/api/articles/${initial!.id}` : "/api/articles";
const method = isEdit ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "保存失败");
}
toast.success(isEdit ? "文章已更新" : "文章已创建");
router.push("/admin/articles");
router.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<div className="space-y-6">
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title"></Label>
<Input
id="title"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="文章标题"
/>
</div>
{/* Slug */}
<div className="space-y-2">
<Label htmlFor="slug">Slug (URL )</Label>
<Input
id="slug"
value={slug}
onChange={(e) => {
setSlug(e.target.value);
setSlugEdited(true);
}}
placeholder="article-url-slug"
/>
</div>
{/* Summary */}
<div className="space-y-2">
<Label htmlFor="summary"></Label>
<Input
id="summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder="文章摘要(可选,用于列表展示)"
/>
</div>
{/* Cover Image */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-3">
{coverImage ? (
<div className="relative">
<img
src={coverImage}
alt="封面"
className="h-24 w-40 rounded border object-cover"
/>
<button
type="button"
onClick={() => setCoverImage("")}
className="absolute -right-2 -top-2 rounded-full bg-destructive p-1 text-destructive-foreground"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex h-24 w-40 items-center justify-center rounded border-2 border-dashed border-muted-foreground/25 text-muted-foreground transition-colors hover:border-muted-foreground/50"
>
{uploading ? "上传中..." : <ImagePlus className="h-6 w-6" />}
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleCoverUpload}
/>
</div>
</div>
{/* Published */}
<div className="flex items-center gap-3">
<Switch
checked={published}
onCheckedChange={setPublished}
id="published"
/>
<Label htmlFor="published">{published ? "已发布" : "草稿"}</Label>
</div>
{/* Markdown Editor */}
<div className="space-y-2" data-color-mode="light">
<Label> (Markdown)</Label>
<MDEditor
value={content}
onChange={(val) => setContent(val || "")}
height={500}
preview="live"
onDrop={async (e) => {
const file = e.dataTransfer?.files?.[0];
if (!file || !file.type.startsWith("image/")) return;
e.preventDefault();
const path = await uploadFile(file);
if (path) {
setContent((prev) => prev + `\n![${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) {
setContent(
(prev) => prev + `\n![pasted-image](${path})\n`
);
}
break;
}
}
}}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button onClick={() => handleSave()} disabled={saving}>
{saving ? "保存中..." : isEdit ? "更新文章" : "发布文章"}
</Button>
{!isEdit && (
<Button
variant="outline"
onClick={() => handleSave(true)}
disabled={saving}
>
稿
</Button>
)}
<Button
variant="ghost"
onClick={() => router.push("/admin/articles")}
>
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,214 @@
"use client";
import { useState, useRef } from "react";
import { toast } from "sonner";
import { Trash2, Upload, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
interface MediaItem {
id: string;
imageUrl: string;
sortOrder: number;
enabled: boolean;
title?: string;
}
interface Props {
type: "banner" | "gallery";
initial: MediaItem[];
}
export function MediaManager({ type, initial }: Props) {
const [items, setItems] = useState<MediaItem[]>(initial);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const endpoint = type === "banner" ? "/api/banners" : "/api/gallery";
const uploadFile = async (file: File) => {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: fd });
if (!res.ok) throw new Error("Upload failed");
return (await res.json()) as { filePath: string };
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files?.length) return;
setUploading(true);
try {
for (const file of Array.from(files)) {
const { filePath } = await uploadFile(file);
const maxSort = items.reduce((m, it) => Math.max(m, it.sortOrder), -1);
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageUrl: filePath, sortOrder: maxSort + 1 }),
});
if (!res.ok) throw new Error("Create failed");
const created = await res.json();
setItems((prev) => [...prev, created]);
}
toast.success("上传成功");
} catch {
toast.error("上传失败");
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
const handleDelete = async (id: string) => {
if (!confirm("确认删除?")) return;
const res = await fetch(`${endpoint}/${id}`, { method: "DELETE" });
if (res.ok) {
setItems((prev) => prev.filter((it) => it.id !== id));
toast.success("已删除");
} else {
toast.error("删除失败");
}
};
const handleUpdate = async (id: string, data: Partial<MediaItem>) => {
const res = await fetch(`${endpoint}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
const updated = await res.json();
setItems((prev) => prev.map((it) => (it.id === id ? updated : it)));
} else {
toast.error("更新失败");
}
};
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="gap-2"
>
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
</Button>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleUpload}
/>
<span className="text-sm text-muted-foreground">
{items.length}
</span>
</div>
{items.length === 0 ? (
<p className="py-12 text-center text-muted-foreground">
</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<div
key={item.id}
className="overflow-hidden rounded-lg border bg-card"
>
<div className="relative aspect-video bg-muted">
<img
src={item.imageUrl}
alt=""
className="h-full w-full object-cover"
/>
{!item.enabled && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<span className="text-sm font-medium text-white/80">
</span>
</div>
)}
</div>
<div className="space-y-3 p-4">
{type === "gallery" && (
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={item.title ?? ""}
placeholder="可选标题"
onChange={(e) =>
setItems((prev) =>
prev.map((it) =>
it.id === item.id
? { ...it, title: e.target.value }
: it
)
)
}
onBlur={() =>
handleUpdate(item.id, { title: item.title })
}
/>
</div>
)}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label className="text-xs"></Label>
<Input
type="number"
value={item.sortOrder}
className="h-8 w-20"
onChange={(e) =>
setItems((prev) =>
prev.map((it) =>
it.id === item.id
? { ...it, sortOrder: parseInt(e.target.value) || 0 }
: it
)
)
}
onBlur={() =>
handleUpdate(item.id, { sortOrder: item.sortOrder })
}
/>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs"></Label>
<Switch
checked={item.enabled}
onCheckedChange={(checked) =>
handleUpdate(item.id, { enabled: checked })
}
/>
</div>
<Button
variant="ghost"
size="icon"
className="ml-auto h-8 w-8 text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -8,6 +8,8 @@ import {
Package, Package,
Upload, Upload,
Monitor, Monitor,
ImageIcon,
FileText,
Settings, Settings,
LogOut, LogOut,
ChevronLeft, ChevronLeft,
@@ -21,6 +23,8 @@ const navItems = [
{ href: "/admin/addons", label: "插件管理", icon: Package }, { href: "/admin/addons", label: "插件管理", icon: Package },
{ href: "/admin/releases", label: "插件版本", icon: Upload }, { href: "/admin/releases", label: "插件版本", icon: Upload },
{ href: "/admin/software", label: "软件管理", icon: Monitor }, { href: "/admin/software", label: "软件管理", icon: Monitor },
{ href: "/admin/media", label: "媒体管理", icon: ImageIcon },
{ href: "/admin/articles", label: "文章管理", icon: FileText },
{ href: "/admin/settings", label: "系统设置", icon: Settings }, { href: "/admin/settings", label: "系统设置", icon: Settings },
]; ];

View File

@@ -0,0 +1,215 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { ChevronLeft, ChevronRight, X, Monitor } from "lucide-react";
interface GalleryItem {
imageUrl: string;
title?: string;
}
const DEFAULT_ITEMS: GalleryItem[] = Array.from({ length: 14 }, (_, i) => ({
imageUrl: `/views/view_${i + 1}.png`,
}));
const SWIPE_THRESHOLD = 40;
export function GameGallery({ items }: { items?: GalleryItem[] }) {
const gallery = items && items.length > 0 ? items : DEFAULT_ITEMS;
const [active, setActive] = useState(0);
const [lightbox, setLightbox] = useState(false);
const thumbsRef = useRef<HTMLDivElement>(null);
const touchStartX = useRef(0);
const empty = items && items.length === 0;
const go = useCallback(
(dir: 1 | -1) => {
setActive((prev) => (prev + dir + gallery.length) % gallery.length);
},
[gallery.length]
);
useEffect(() => {
const el = thumbsRef.current?.children[active] as HTMLElement | undefined;
el?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
}, [active]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft") go(-1);
if (e.key === "ArrowRight") go(1);
if (e.key === "Escape") setLightbox(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [go]);
useEffect(() => {
if (lightbox) {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}
}, [lightbox]);
if (empty) return null;
const onTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
};
const onTouchEnd = (e: React.TouchEvent) => {
const dx = e.changedTouches[0].clientX - touchStartX.current;
if (Math.abs(dx) < SWIPE_THRESHOLD) return;
if (dx < 0) go(1);
else go(-1);
};
return (
<>
<section className="border-t border-amber-900/20 bg-gradient-to-b from-[#0d0b15] to-[#110f1a]">
<div className="mx-auto max-w-6xl px-3 py-8 sm:px-4 sm:py-14">
{/* Title */}
<div className="mb-4 flex items-center gap-2 sm:mb-8 sm:gap-3">
<Monitor className="h-4 w-4 text-amber-400 sm:h-5 sm:w-5" />
<h2 className="text-xl font-bold text-amber-100 sm:text-2xl"></h2>
</div>
{/* Main image */}
<div
className="group relative overflow-hidden rounded-lg border border-amber-500/15 bg-black/30 sm:rounded-xl"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
{gallery.map((item, i) => (
<img
key={item.imageUrl}
src={item.imageUrl}
alt={item.title || `实机截图 ${i + 1}`}
loading={i <= 1 ? "eager" : "lazy"}
draggable={false}
onClick={() => setLightbox(true)}
className={`absolute inset-0 h-full w-full cursor-pointer object-contain transition-opacity duration-500 ${
i === active ? "opacity-100" : "pointer-events-none opacity-0"
}`}
/>
))}
</div>
{/* Title overlay */}
{gallery[active]?.title && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-3 pb-2 pt-6 sm:px-4 sm:pb-3 sm:pt-8">
<p className="text-xs font-medium text-white/90 sm:text-sm">
{gallery[active].title}
</p>
</div>
)}
{/* Counter */}
<span className="absolute right-2 top-2 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/70 backdrop-blur sm:right-3 sm:top-3 sm:rounded-md sm:px-2.5 sm:py-1 sm:text-xs">
{active + 1} / {gallery.length}
</span>
{/* Arrows - always visible on mobile, hover on desktop */}
<button
onClick={() => go(-1)}
className="absolute left-1.5 top-1/2 -translate-y-1/2 cursor-pointer rounded-full bg-black/40 p-1.5 text-white/60 backdrop-blur transition-all active:scale-90 sm:left-3 sm:bg-black/50 sm:p-2.5 sm:text-white/70 sm:opacity-0 sm:hover:bg-black/70 sm:hover:text-white sm:group-hover:opacity-100"
>
<ChevronLeft className="h-4 w-4 sm:h-5 sm:w-5" />
</button>
<button
onClick={() => go(1)}
className="absolute right-1.5 top-1/2 -translate-y-1/2 cursor-pointer rounded-full bg-black/40 p-1.5 text-white/60 backdrop-blur transition-all active:scale-90 sm:right-3 sm:bg-black/50 sm:p-2.5 sm:text-white/70 sm:opacity-0 sm:hover:bg-black/70 sm:hover:text-white sm:group-hover:opacity-100"
>
<ChevronRight className="h-4 w-4 sm:h-5 sm:w-5" />
</button>
</div>
{/* Thumbnails */}
<div
ref={thumbsRef}
className="gallery-thumbs mt-3 flex gap-1.5 overflow-x-auto pb-1 sm:mt-4 sm:gap-2 sm:pb-2"
>
{gallery.map((item, i) => (
<button
key={item.imageUrl}
onClick={() => setActive(i)}
className={`flex-none cursor-pointer overflow-hidden rounded border-2 transition-all duration-300 sm:rounded-lg ${
i === active
? "border-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.3)]"
: "border-transparent opacity-50 hover:opacity-80"
}`}
>
<img
src={item.imageUrl}
alt={item.title || `缩略图 ${i + 1}`}
loading="lazy"
draggable={false}
className="h-12 w-20 object-cover sm:h-16 sm:w-28 md:h-[72px] md:w-32"
/>
</button>
))}
</div>
</div>
</section>
{/* Lightbox */}
{lightbox && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 sm:bg-black/90 sm:backdrop-blur-sm"
onClick={() => setLightbox(false)}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{/* Close */}
<button
className="absolute right-3 top-3 z-10 cursor-pointer rounded-full bg-white/10 p-2.5 text-white/70 transition-colors hover:bg-white/20 hover:text-white sm:right-4 sm:top-4 sm:p-2"
onClick={() => setLightbox(false)}
>
<X className="h-5 w-5 sm:h-6 sm:w-6" />
</button>
{/* Left arrow */}
<button
onClick={(e) => {
e.stopPropagation();
go(-1);
}}
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 cursor-pointer rounded-full bg-white/10 p-2 text-white/70 transition-colors active:scale-90 sm:left-4 sm:p-3 sm:hover:bg-white/20 sm:hover:text-white"
>
<ChevronLeft className="h-5 w-5 sm:h-6 sm:w-6" />
</button>
{/* Image + title */}
<div className="flex flex-col items-center px-10 sm:px-16" onClick={(e) => e.stopPropagation()}>
<img
src={gallery[active].imageUrl}
alt={gallery[active].title || `实机截图 ${active + 1}`}
className="max-h-[80vh] max-w-[95vw] rounded-lg object-contain sm:max-h-[85vh] sm:max-w-[90vw]"
/>
{gallery[active].title && (
<p className="mt-2 text-sm font-medium text-white/80 sm:mt-3 sm:text-base">
{gallery[active].title}
</p>
)}
</div>
{/* Right arrow */}
<button
onClick={(e) => {
e.stopPropagation();
go(1);
}}
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 cursor-pointer rounded-full bg-white/10 p-2 text-white/70 transition-colors active:scale-90 sm:right-4 sm:p-3 sm:hover:bg-white/20 sm:hover:text-white"
>
<ChevronRight className="h-5 w-5 sm:h-6 sm:w-6" />
</button>
{/* Counter */}
<span className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-md bg-black/60 px-2.5 py-1 text-xs text-white/70 backdrop-blur sm:bottom-6 sm:px-3 sm:py-1.5 sm:text-sm">
{active + 1} / {gallery.length}
</span>
</div>
)}
</>
);
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import { Download } from "lucide-react"; import { Download, ChevronRight } from "lucide-react";
interface Particle { interface Particle {
x: number; x: number;
@@ -15,27 +15,70 @@ interface Particle {
twinklePhase: number; twinklePhase: number;
} }
const slides = [ interface Nebula {
x: number;
y: number;
radius: number;
color: string;
alpha: number;
drift: number;
phase: number;
}
const DEFAULT_SLIDES = [
{ image: "/banners/banner_2.png" }, { image: "/banners/banner_2.png" },
{ image: "/banners/banner_1.png" }, { image: "/banners/banner_1.png" },
{ image: "/banners/banner_3.png" }, { image: "/banners/banner_3.png" },
]; ];
const PARTICLE_COUNT = 50; const PARTICLE_COUNT = 60;
const NEBULA_COUNT = 5;
const AUTO_PLAY_MS = 6000; const AUTO_PLAY_MS = 6000;
const SWIPE_THRESHOLD = 50;
export function HeroBanner({ export function HeroBanner({
totalDownloads, totalDownloads,
launcherVersion, launcherVersion,
banners,
}: { }: {
totalDownloads?: number; totalDownloads?: number;
launcherVersion?: string | null; launcherVersion?: string | null;
banners?: { imageUrl: string }[];
}) { }) {
const slides =
banners && banners.length > 0
? banners.map((b) => ({ image: b.imageUrl }))
: DEFAULT_SLIDES;
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const particlesRef = useRef<Particle[]>([]); const particlesRef = useRef<Particle[]>([]);
const nebulaeRef = useRef<Nebula[]>([]);
const rafRef = useRef(0); const rafRef = useRef(0);
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const [prev, setPrev] = useState(-1);
const [transitioning, setTransitioning] = useState(false);
const touchStartX = useRef(0);
const prefersReducedMotion = useRef(false);
useEffect(() => {
prefersReducedMotion.current = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
}, []);
const goTo = useCallback(
(next: number) => {
if (transitioning || next === active) return;
setPrev(active);
setActive(next);
setTransitioning(true);
setTimeout(() => {
setPrev(-1);
setTransitioning(false);
}, 1000);
},
[active, transitioning]
);
const createParticles = useCallback((w: number, h: number) => { const createParticles = useCallback((w: number, h: number) => {
const palettes = [ const palettes = [
@@ -45,25 +88,53 @@ export function HeroBanner({
"rgba(255,255,220,", "rgba(255,255,220,",
"rgba(200,160,255,", "rgba(200,160,255,",
"rgba(120,200,255,", "rgba(120,200,255,",
"rgba(255,180,120,",
]; ];
const out: Particle[] = []; const out: Particle[] = [];
for (let i = 0; i < PARTICLE_COUNT; i++) { const count = prefersReducedMotion.current
? Math.floor(PARTICLE_COUNT / 3)
: PARTICLE_COUNT;
for (let i = 0; i < count; i++) {
const z = Math.random(); const z = Math.random();
out.push({ out.push({
x: Math.random() * w, x: Math.random() * w,
y: Math.random() * h, y: Math.random() * h,
z, z,
vx: (Math.random() - 0.5) * 0.3, vx: (Math.random() - 0.5) * 0.25,
vy: -(Math.random() * 0.3 + 0.05), vy: -(Math.random() * 0.25 + 0.04),
baseSize: z * 2 + 0.6, baseSize: z * 2.2 + 0.5,
color: palettes[Math.floor(Math.random() * palettes.length)], color: palettes[Math.floor(Math.random() * palettes.length)],
twinkleSpeed: Math.random() * 0.025 + 0.006, twinkleSpeed: Math.random() * 0.02 + 0.005,
twinklePhase: Math.random() * Math.PI * 2, twinklePhase: Math.random() * Math.PI * 2,
}); });
} }
return out; return out;
}, []); }, []);
const createNebulae = useCallback((w: number, h: number) => {
if (prefersReducedMotion.current) return [];
const colors = [
"rgba(168,85,247,",
"rgba(59,130,246,",
"rgba(245,158,11,",
"rgba(236,72,153,",
"rgba(6,182,212,",
];
const out: Nebula[] = [];
for (let i = 0; i < NEBULA_COUNT; i++) {
out.push({
x: Math.random() * w,
y: Math.random() * h * 0.8 + h * 0.1,
radius: Math.random() * 120 + 60,
color: colors[i % colors.length],
alpha: Math.random() * 0.03 + 0.015,
drift: (Math.random() - 0.5) * 0.15,
phase: Math.random() * Math.PI * 2,
});
}
return out;
}, []);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
const container = containerRef.current; const container = containerRef.current;
@@ -85,6 +156,7 @@ export function HeroBanner({
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (particlesRef.current.length === 0) { if (particlesRef.current.length === 0) {
particlesRef.current = createParticles(w, h); particlesRef.current = createParticles(w, h);
nebulaeRef.current = createNebulae(w, h);
} }
}; };
@@ -96,6 +168,23 @@ export function HeroBanner({
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
tick++; tick++;
for (const n of nebulaeRef.current) {
const pulse = Math.sin(tick * 0.008 + n.phase) * 0.5 + 0.5;
const a = n.alpha * (0.6 + pulse * 0.4);
n.x += n.drift;
if (n.x < -n.radius) n.x = w + n.radius;
if (n.x > w + n.radius) n.x = -n.radius;
const grad = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, n.radius);
grad.addColorStop(0, n.color + a.toFixed(4) + ")");
grad.addColorStop(0.5, n.color + (a * 0.4).toFixed(4) + ")");
grad.addColorStop(1, n.color + "0)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
ctx.fill();
}
for (const p of particlesRef.current) { for (const p of particlesRef.current) {
p.x += p.vx; p.x += p.vx;
p.y += p.vy; p.y += p.vy;
@@ -108,17 +197,14 @@ export function HeroBanner({
const twinkle = const twinkle =
Math.sin(tick * p.twinkleSpeed + p.twinklePhase) * 0.5 + 0.5; Math.sin(tick * p.twinkleSpeed + p.twinklePhase) * 0.5 + 0.5;
const alpha = (p.z * 0.45 + 0.1) * twinkle; const alpha = (p.z * 0.5 + 0.12) * twinkle;
const glowR = p.baseSize * 5; const glowR = p.baseSize * 6;
const grad = ctx.createRadialGradient( const grad = ctx.createRadialGradient(
p.x, p.x, p.y, 0,
p.y, p.x, p.y, glowR
0,
p.x,
p.y,
glowR
); );
grad.addColorStop(0, p.color + (alpha * 0.4).toFixed(3) + ")"); grad.addColorStop(0, p.color + (alpha * 0.5).toFixed(3) + ")");
grad.addColorStop(0.3, p.color + (alpha * 0.2).toFixed(3) + ")");
grad.addColorStop(1, p.color + "0)"); grad.addColorStop(1, p.color + "0)");
ctx.fillStyle = grad; ctx.fillStyle = grad;
ctx.beginPath(); ctx.beginPath();
@@ -139,20 +225,27 @@ export function HeroBanner({
window.removeEventListener("resize", resize); window.removeEventListener("resize", resize);
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
}; };
}, [createParticles]); }, [createParticles, createNebulae]);
useEffect(() => { useEffect(() => {
const id = setInterval(() => { const id = setInterval(() => {
setActive((prev) => (prev + 1) % slides.length); setActive((cur) => {
const next = (cur + 1) % slides.length;
setPrev(cur);
setTransitioning(true);
setTimeout(() => {
setPrev(-1);
setTransitioning(false);
}, 1000);
return next;
});
}, AUTO_PLAY_MS); }, AUTO_PLAY_MS);
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, [slides.length]);
const goTo = (i: number) => setActive(i);
const handleDownload = async () => { const handleDownload = async () => {
try { try {
const res = await fetch("/api/software/latest?info=1"); const res = await fetch("/api/software/latest?info=1&track=1");
const data = await res.json(); const data = await res.json();
if (data.available && data.downloadUrl) { if (data.available && data.downloadUrl) {
window.location.href = data.downloadUrl; window.location.href = data.downloadUrl;
@@ -164,20 +257,51 @@ export function HeroBanner({
} }
}; };
const onTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
};
const onTouchEnd = (e: React.TouchEvent) => {
const dx = e.changedTouches[0].clientX - touchStartX.current;
if (Math.abs(dx) < SWIPE_THRESHOLD) return;
const next = dx < 0
? (active + 1) % slides.length
: (active - 1 + slides.length) % slides.length;
goTo(next);
};
return ( return (
<div className="hero-aurora-wrapper"> <div className="hero-aurora-wrapper">
<div className="hero-aurora hero-aurora--top" /> <div className="hero-aurora hero-aurora--top" />
<section <section
ref={containerRef} ref={containerRef}
className="relative overflow-hidden bg-[#0a0912]" className="hero-banner relative overflow-hidden bg-[#0a0912]"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
> >
{/* Slide images */} {/* Slide images with parallax + zoom transition */}
{slides.map((s, i) => ( {slides.map((s, i) => {
const isActive = i === active;
const isPrev = i === prev;
const isVisible = isActive || isPrev;
return (
<div <div
key={i} key={i}
className="absolute inset-0 transition-opacity duration-[1200ms] ease-in-out" className="absolute inset-0"
style={{ opacity: i === active ? 1 : 0 }} style={{
opacity: isActive ? 1 : isPrev ? 0 : 0,
transform: isActive
? "scale(1) translateX(0)"
: isPrev
? "scale(1.08) translateX(-3%)"
: "scale(1.05) translateX(3%)",
transition: isVisible
? "opacity 1s cubic-bezier(0.4, 0, 0.2, 1), transform 1.2s cubic-bezier(0.4, 0, 0.2, 1)"
: "none",
zIndex: isActive ? 2 : isPrev ? 1 : 0,
willChange: isVisible ? "opacity, transform" : "auto",
}}
> >
<img <img
src={s.image} src={s.image}
@@ -186,65 +310,104 @@ export function HeroBanner({
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
</div> </div>
))} );
})}
{/* Spacer to maintain 21:9 aspect ratio */} {/* Mobile spacer: ~16:9 */}
<div className="pointer-events-none w-full" style={{ paddingBottom: "min(42.86%, 720px)" }} /> <div
className="pointer-events-none relative z-10 w-full sm:hidden"
{/* Bottom gradient for button readability */} style={{ paddingBottom: "clamp(220px, 56.25%, 420px)" }}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-[#0a0912] via-[#0a0912]/60 to-transparent" /> />
{/* Desktop spacer: 21:9, max 720px */}
{/* Particle canvas */} <div
<canvas className="pointer-events-none relative z-10 hidden w-full sm:block"
ref={canvasRef} style={{ paddingBottom: "min(42.86%, 720px)" }}
className="pointer-events-none absolute inset-0 z-10"
/> />
{/* Bottom content */} {/* Multi-layer gradient overlays */}
<div className="absolute inset-x-0 bottom-0 z-20 flex flex-col items-center pb-6 sm:pb-8"> <div className="pointer-events-none absolute inset-0 z-[3] bg-gradient-to-t from-[#0a0912] via-[#0a0912]/40 to-transparent" />
<div className="pointer-events-none absolute inset-0 z-[3] bg-gradient-to-r from-[#0a0912]/30 via-transparent to-[#0a0912]/30" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[3] h-44 bg-gradient-to-t from-[#0a0912] via-[#0a0912]/80 to-transparent sm:h-52" />
{/* Vignette effect */}
<div className="pointer-events-none absolute inset-0 z-[3]" style={{
background: "radial-gradient(ellipse at center, transparent 50%, rgba(10,9,18,0.4) 100%)"
}} />
{/* Particle + nebula canvas */}
<canvas
ref={canvasRef}
className="pointer-events-none absolute inset-0 z-[4]"
/>
{/* Scan line overlay for texture */}
<div className="hero-scanlines pointer-events-none absolute inset-0 z-[4]" />
{/* Bottom content area */}
<div className="absolute inset-x-0 bottom-0 z-20 flex flex-col items-center px-4 pb-5 sm:pb-10">
{/* Tagline */}
<p className="hero-tagline mb-3 text-center text-xs font-medium tracking-widest text-amber-200/60 uppercase sm:mb-4 sm:text-sm">
Turtle WoW
</p>
{/* Download button */}
<button <button
onClick={handleDownload} onClick={handleDownload}
className="group relative cursor-pointer overflow-hidden rounded-lg border border-amber-500/60 bg-gradient-to-b from-amber-900/80 to-amber-950/90 px-8 py-3.5 backdrop-blur transition-all duration-300 hover:border-amber-400/80 hover:shadow-[0_0_30px_rgba(255,191,0,0.3)] active:scale-[0.97]" className="hero-download-btn group relative cursor-pointer overflow-hidden rounded-xl border border-amber-500/50 bg-gradient-to-b from-amber-800/70 via-amber-900/80 to-amber-950/90 px-6 py-3 shadow-[0_0_20px_rgba(245,158,11,0.15)] backdrop-blur-sm transition-all duration-400 hover:border-amber-400/80 hover:shadow-[0_0_40px_rgba(255,191,0,0.35)] active:scale-[0.97] sm:px-10 sm:py-4 sm:rounded-2xl"
> >
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-amber-500/10 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" /> {/* Shimmer sweep */}
<span className="relative flex items-center gap-2.5 text-amber-100"> <div className="hero-shimmer absolute inset-0" />
<Download className="h-5 w-5" /> {/* Inner glow on hover */}
<span className="text-base font-semibold tracking-wide"> <div className="absolute inset-0 bg-gradient-to-r from-transparent via-amber-500/8 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
<span className="relative flex items-center gap-2.5 sm:gap-3">
<Download className="h-4.5 w-4.5 transition-transform duration-300 group-hover:-translate-y-0.5 sm:h-5 sm:w-5" />
<span className="text-sm font-bold tracking-wide text-amber-50 sm:text-base">
Nanami Nanami
</span> </span>
{launcherVersion && ( {launcherVersion && (
<span className="rounded border border-amber-500/30 bg-amber-500/15 px-2 py-0.5 text-xs font-medium text-amber-300"> <span className="hidden rounded-md border border-amber-400/30 bg-amber-500/15 px-2 py-0.5 text-xs font-semibold text-amber-200 sm:inline-block">
v{launcherVersion} v{launcherVersion}
</span> </span>
)} )}
<ChevronRight className="h-4 w-4 text-amber-300/60 transition-transform duration-300 group-hover:translate-x-0.5 sm:h-4.5 sm:w-4.5" />
</span> </span>
</button> </button>
{/* Stats row */}
<div className="mt-2.5 flex items-center gap-3 sm:mt-4">
{totalDownloads != null && totalDownloads > 0 && ( {totalDownloads != null && totalDownloads > 0 && (
<p className="mt-2.5 text-sm text-white/40"> <p className="flex items-center gap-1.5 text-xs text-white/35 sm:text-sm">
{" "} <span className="inline-block h-1 w-1 rounded-full bg-green-400/60" />
<span className="font-semibold text-amber-300/70"> <span className="font-semibold text-amber-300/60 tabular-nums">
{totalDownloads.toLocaleString()} {totalDownloads.toLocaleString()}
</span>{" "} </span>
</p> </p>
)} )}
</div>
<div className="mt-3 flex gap-2.5"> {/* Carousel indicators */}
<div className="mt-3 flex gap-2 sm:mt-4 sm:gap-2.5">
{slides.map((_, i) => ( {slides.map((_, i) => (
<button <button
key={i} key={i}
aria-label={`前往第 ${i + 1}`} aria-label={`前往第 ${i + 1}`}
onClick={() => goTo(i)} onClick={() => goTo(i)}
className={`cursor-pointer h-1.5 rounded-full transition-all duration-500 ${ className={`hero-indicator cursor-pointer rounded-full transition-all duration-500 ${
i === active i === active
? "w-8 bg-amber-400" ? "h-1.5 w-7 bg-amber-400 shadow-[0_0_8px_rgba(251,191,36,0.5)] sm:h-2 sm:w-8"
: "w-1.5 bg-white/30 hover:bg-white/60" : "h-1.5 w-1.5 bg-white/25 hover:bg-white/50 sm:h-2 sm:w-2"
}`} }`}
/> />
))} ))}
</div> </div>
</div> </div>
{/* Top-left subtle glow */}
<div className="pointer-events-none absolute -left-20 -top-20 z-[3] h-80 w-80 rounded-full bg-purple-500/[0.04] blur-3xl" />
{/* Bottom-right subtle glow */}
<div className="pointer-events-none absolute -bottom-20 -right-20 z-[3] h-80 w-80 rounded-full bg-amber-500/[0.04] blur-3xl" />
</section> </section>
<div className="hero-aurora hero-aurora--bottom" /> <div className="hero-aurora hero-aurora--bottom" />

View File

@@ -0,0 +1,18 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
export function MarkdownContent({ content }: { content: string }) {
return (
<div className="prose-article">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
>
{content}
</ReactMarkdown>
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { ThemeToggle } from "@/components/ThemeToggle";
export function Navbar() { export function Navbar() {
return ( return (
<header className="sticky top-0 z-50 border-b border-amber-900/20 bg-[#0a0912]/95 backdrop-blur supports-[backdrop-filter]:bg-[#0a0912]/80"> <header className="sticky top-0 z-50 border-b border-amber-900/20 bg-[#0a0912]/95 backdrop-blur supports-[backdrop-filter]:bg-[#0a0912]/80">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4"> <div className="mx-auto flex h-12 max-w-6xl items-center justify-between px-3 sm:h-14 sm:px-4">
<Link <Link
href="/" href="/"
className="flex items-center gap-2 text-lg font-bold text-amber-100" className="flex items-center gap-2 text-lg font-bold text-amber-100"
@@ -14,13 +14,25 @@ export function Navbar() {
<span>Nanami</span> <span>Nanami</span>
</Link> </Link>
<nav className="flex items-center gap-4"> <nav className="flex items-center gap-2 sm:gap-4">
<Link <Link
href="/addons" href="/addons"
className="text-sm text-gray-400 transition-colors hover:text-amber-200" className="text-xs text-gray-400 transition-colors hover:text-amber-200 sm:text-sm"
> >
</Link> </Link>
<Link
href="/articles"
className="text-xs text-gray-400 transition-colors hover:text-amber-200 sm:text-sm"
>
</Link>
<Link
href="/changelog"
className="text-xs text-gray-400 transition-colors hover:text-amber-200 sm:text-sm"
>
</Link>
<ThemeToggle /> <ThemeToggle />
</nav> </nav>
</div> </div>

View File

@@ -0,0 +1,24 @@
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
export function PageTracker() {
const pathname = usePathname();
useEffect(() => {
const controller = new AbortController();
fetch("/api/track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: pathname,
referrer: document.referrer,
}),
signal: controller.signal,
}).catch(() => {});
return () => controller.abort();
}, [pathname]);
return null;
}

View File

@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

BIN
src/img/view_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
src/img/view_10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
src/img/view_11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
src/img/view_12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
src/img/view_13.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
src/img/view_14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
src/img/view_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

BIN
src/img/view_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
src/img/view_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
src/img/view_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

BIN
src/img/view_6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
src/img/view_7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
src/img/view_8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
src/img/view_9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB