官网 初版

This commit is contained in:
rucky
2026-03-18 17:13:27 +08:00
parent 879c4bdfc8
commit 241a76caeb
95 changed files with 8889 additions and 113 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
Dockerfile
.dockerignore
node_modules
.next
.git
.gitignore
*.md
.env*
!.env.example
uploads

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/nanami_web?schema=public"
NEXTAUTH_SECRET="change-me-in-production-use-a-random-string"
NEXTAUTH_URL="http://localhost:3000"
ADMIN_USERNAME="admin"
ADMIN_PASSWORD="admin123"

9
.gitignore vendored
View File

@@ -32,6 +32,13 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# deploy credentials
.deploy-config
# uploads
/uploads
# vercel
.vercel
@@ -39,3 +46,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

43
Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
FROM node:20-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/src/generated ./src/generated
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -1,36 +1,74 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Nanami Web - WoW Addon Platform
## Getting Started
World of Warcraft 插件发布与下载平台。
First, run the development server:
## 技术栈
- **框架**: Next.js 16 (App Router)
- **语言**: TypeScript
- **UI**: TailwindCSS + shadcn/ui
- **数据库**: PostgreSQL + Prisma ORM
- **认证**: NextAuth.js (Credentials Provider)
- **部署**: Docker + docker-compose
## 本地开发
### 前置条件
- Node.js 20+
- PostgreSQL 16+
### 安装
```bash
npm install
cp .env.example .env
# 编辑 .env 中的 DATABASE_URL 和其他配置
```
### 数据库设置
```bash
# 推送 schema 到数据库
npm run db:push
# 初始化管理员账号和示例数据
npm run db:seed
```
### 启动开发服务器
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
访问 http://localhost:3000 查看前台,http://localhost:3000/admin 进入后台管理。
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
默认管理员账号:`admin` / `admin123`
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Docker 部署
## Learn More
```bash
# 启动所有服务
docker compose up -d
To learn more about Next.js, take a look at the following resources:
# 初始化数据库
docker compose exec app npx prisma db push
docker compose exec app npx tsx prisma/seed.ts
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## 项目结构
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
```
src/
├── app/
│ ├── (public)/ # 前台页面
│ ├── admin/ # 后台管理
│ └── api/ # API 路由
├── components/
│ ├── ui/ # shadcn/ui 组件
│ ├── public/ # 前台组件
│ └── admin/ # 后台组件
├── lib/ # 工具库
└── types/ # 类型定义
```

65
deploy-init.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/.deploy-config"
if [ ! -f "$CONFIG_FILE" ]; then
echo "❌ 缺少 .deploy-config 文件"
exit 1
fi
source "$CONFIG_FILE"
if ! command -v sshpass &> /dev/null; then
echo "❌ 未安装 sshpass请先运行: brew install hudochenkov/sshpass/sshpass"
exit 1
fi
PASS_FILE=$(mktemp)
echo "$SERVER_PASS" > "$PASS_FILE"
trap "rm -f $PASS_FILE" EXIT
SSH_OPTS="-o StrictHostKeyChecking=no"
run_ssh() {
sshpass -f "$PASS_FILE" ssh $SSH_OPTS "${SERVER_USER}@${SERVER_HOST}" "$@"
}
NEXTAUTH_SECRET="$(openssl rand -base64 32)"
echo ""
echo "========================================="
echo " 首次初始化服务器 ${SERVER_HOST}"
echo "========================================="
echo ""
echo "=> 创建项目目录和 uploads..."
run_ssh "mkdir -p ${REMOTE_DIR}/uploads"
echo ""
echo "=> 创建 .env 文件..."
run_ssh "cat > ${REMOTE_DIR}/.env << 'EOF'
DATABASE_URL=postgresql://nanami:Scl%40qq.com1@localhost:5432/nanamiweb?schema=public
NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
NEXTAUTH_URL=http://${SERVER_HOST}:3000
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
EOF"
echo ""
echo "=> 验证 .env 文件..."
run_ssh "cat ${REMOTE_DIR}/.env"
echo ""
echo "========================================="
echo " ✅ 初始化完成!"
echo "========================================="
echo ""
echo "接下来运行: ./deploy-remote.sh"
echo ""
echo "⚠️ 部署成功后记得修改管理员密码:"
echo " 访问 http://${SERVER_HOST}:3000/admin/login"
echo " 默认账号: admin / admin123"
echo " 登录后在「系统设置」中修改密码"
echo ""

104
deploy-remote.sh Executable file
View File

@@ -0,0 +1,104 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/.deploy-config"
if [ ! -f "$CONFIG_FILE" ]; then
echo "❌ 缺少配置文件 .deploy-config"
exit 1
fi
source "$CONFIG_FILE"
if ! command -v sshpass &> /dev/null; then
echo "❌ 未安装 sshpass请先运行: brew install hudochenkov/sshpass/sshpass"
exit 1
fi
PASS_FILE=$(mktemp)
echo "$SERVER_PASS" > "$PASS_FILE"
trap "rm -f $PASS_FILE /tmp/nanami-web-deploy.tar.gz" EXIT
SSH_OPTS="-o StrictHostKeyChecking=no"
run_ssh() {
sshpass -f "$PASS_FILE" ssh $SSH_OPTS "${SERVER_USER}@${SERVER_HOST}" "source /etc/profile 2>/dev/null; source ~/.bashrc 2>/dev/null; source ~/.bash_profile 2>/dev/null; $*"
}
run_scp() {
sshpass -f "$PASS_FILE" scp $SSH_OPTS "$@"
}
echo ""
echo "========================================="
echo " 部署到 ${SERVER_HOST}"
echo "========================================="
echo ""
echo "=> [1/6] 本地安装依赖..."
cd "$SCRIPT_DIR"
npm install --silent
echo ""
echo "=> [2/6] 本地同步 Prisma..."
npx prisma generate
echo ""
echo "=> [3/6] 本地构建项目..."
npm run build
echo ""
echo "=> [4/6] 打包构建产物..."
TAR_FILE="/tmp/nanami-web-deploy.tar.gz"
STAGE_DIR="/tmp/nanami-web-stage"
rm -rf "$STAGE_DIR"
STANDALONE_ROOT="$SCRIPT_DIR/.next/standalone/gitLib/nanami-web"
if [ ! -f "$STANDALONE_ROOT/server.js" ]; then
STANDALONE_ROOT="$SCRIPT_DIR/.next/standalone"
fi
mkdir -p "$STAGE_DIR/.next/standalone"
cp -r "$STANDALONE_ROOT/"* "$STAGE_DIR/.next/standalone/"
cp -r "$STANDALONE_ROOT/.next" "$STAGE_DIR/.next/standalone/"
mkdir -p "$STAGE_DIR/.next/standalone/.next/static"
cp -r "$SCRIPT_DIR/.next/static/"* "$STAGE_DIR/.next/standalone/.next/static/"
cp -r "$SCRIPT_DIR/public" "$STAGE_DIR/.next/standalone/"
cp -r "$SCRIPT_DIR/prisma" "$STAGE_DIR/"
cp "$SCRIPT_DIR/package.json" "$STAGE_DIR/"
cp "$SCRIPT_DIR/package-lock.json" "$STAGE_DIR/"
cp "$SCRIPT_DIR/ecosystem.config.js" "$STAGE_DIR/"
tar -czf "$TAR_FILE" -C "$STAGE_DIR" .
rm -rf "$STAGE_DIR"
echo " 打包完成: $(du -h "$TAR_FILE" | cut -f1)"
echo ""
echo "=> [5/6] 上传到服务器..."
run_ssh "mkdir -p ${REMOTE_DIR} && rm -rf ${REMOTE_DIR}/.next"
run_scp "$TAR_FILE" "${SERVER_USER}@${SERVER_HOST}:/tmp/nanami-web-deploy.tar.gz"
run_ssh "cd ${REMOTE_DIR} && tar -xzf /tmp/nanami-web-deploy.tar.gz && rm -f /tmp/nanami-web-deploy.tar.gz"
echo ""
echo "=> [6/6] 服务器安装生产依赖 & 同步数据库 & 重启..."
run_ssh "cd ${REMOTE_DIR} && \
npm install --omit=dev --registry=https://registry.npmjs.org && \
npx prisma generate && \
npx prisma db push && \
mkdir -p 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}/src/generated ${REMOTE_DIR}/.next/standalone/src/generated 2>/dev/null; \
cd ${REMOTE_DIR} && (pm2 delete nanami-web 2>/dev/null; pm2 start ecosystem.config.js)"
echo ""
echo "========================================="
echo " ✅ 部署完成!"
echo "========================================="
echo ""
echo "网站地址: http://${SERVER_HOST}:3000"
echo "管理后台: http://${SERVER_HOST}:3000/admin/login"
echo ""

27
deploy.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
echo "=> 拉取最新代码..."
git pull
echo "=> 安装依赖..."
npm install
echo "=> 同步数据库..."
npx prisma generate
npx prisma db push
echo "=> 构建项目..."
npm run build
echo "=> 准备运行目录..."
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
mkdir -p uploads
ln -sf "$(pwd)/uploads" .next/standalone/uploads
echo "=> 重启应用..."
pm2 restart nanami-web || pm2 start ecosystem.config.js
echo "=> 部署完成!"

36
docker-compose.yml Normal file
View File

@@ -0,0 +1,36 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: nanami_web
volumes:
- pg_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
app:
build: .
restart: unless-stopped
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/nanami_web?schema=public
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-change-me-in-production}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
volumes:
- uploads_data:/app/uploads
depends_on:
db:
condition: service_healthy
volumes:
pg_data:
uploads_data:

529
docs/API.md Normal file
View File

@@ -0,0 +1,529 @@
# Nanami API 文档
所有公开接口均无需认证,供客户端软件直接调用。
基础 URL: `https://nui.rucky.cn`
---
# 一、插件市场 API
供启动器拉取插件列表、详情、版本和下载。
---
## 1.1 获取插件列表
### 请求
```
GET /api/addons
```
### 参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| category | string | 否 | 按分类筛选(如 `ui`, `quest`, `combat`, `general` |
| search | string | 否 | 按名称或简介模糊搜索 |
| published | string | 否 | 默认 `true` 只返回已发布插件,传 `false` 返回全部 |
### 响应示例
```json
[
{
"id": "cxxx001",
"name": "Nanami",
"slug": "nanami",
"summary": "一站式乌龟服插件管理器",
"description": "详细描述...",
"iconUrl": "/uploads/nanami-icon.png",
"category": "general",
"published": true,
"totalDownloads": 1234,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-03-18T10:00:00.000Z",
"releases": [
{
"id": "rel001",
"version": "1.0.0",
"isLatest": true
}
],
"_count": {
"releases": 3
}
}
]
```
### 响应字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| id | string | 插件唯一 ID |
| name | string | 插件名称 |
| slug | string | 插件标识URL 友好) |
| summary | string | 简短描述 |
| description | string | 详细描述(支持长文本) |
| iconUrl | string\|null | 图标地址 |
| category | string | 分类标签 |
| published | boolean | 是否已发布 |
| totalDownloads | int | 累计下载次数 |
| releases | array | 最新版本信息(仅包含 isLatest=true 的版本) |
| _count.releases | int | 版本总数 |
---
## 1.2 获取插件详情
通过 ID 或 slug 获取单个插件的完整信息,包含所有版本和截图。
### 请求
```
GET /api/addons/{id_or_slug}
```
### 参数
| 参数 | 说明 |
|---|---|
| id_or_slug | 插件 ID 或 slug`nanami` |
### 响应示例
```json
{
"id": "cxxx001",
"name": "Nanami",
"slug": "nanami",
"summary": "一站式乌龟服插件管理器",
"description": "详细的 Markdown 描述...",
"iconUrl": "/uploads/nanami-icon.png",
"category": "general",
"published": true,
"totalDownloads": 1234,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-03-18T10:00:00.000Z",
"releases": [
{
"id": "rel003",
"addonId": "cxxx001",
"version": "1.2.0",
"changelog": "- 新增XX功能\n- 修复YY问题",
"downloadType": "local",
"filePath": "uploads/nanami-1.2.0.zip",
"externalUrl": null,
"gameVersion": "1.12.1",
"downloadCount": 500,
"isLatest": true,
"createdAt": "2026-03-18T10:00:00.000Z"
},
{
"id": "rel002",
"addonId": "cxxx001",
"version": "1.1.0",
"changelog": "- 修复界面问题",
"downloadType": "local",
"filePath": "uploads/nanami-1.1.0.zip",
"externalUrl": null,
"gameVersion": "1.12.1",
"downloadCount": 300,
"isLatest": false,
"createdAt": "2026-02-15T10:00:00.000Z"
}
],
"screenshots": [
{
"id": "ss001",
"addonId": "cxxx001",
"imageUrl": "/uploads/screenshot1.png",
"sortOrder": 0
}
]
}
```
### 错误响应
| HTTP 状态码 | 说明 |
|---|---|
| 404 | 插件不存在 |
---
## 1.3 获取版本列表
获取所有版本,可按插件 ID 筛选。
### 请求
```
GET /api/releases?addonId={addonId}
```
### 参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| addonId | string | 否 | 按插件 ID 筛选 |
### 响应示例
```json
[
{
"id": "rel003",
"addonId": "cxxx001",
"version": "1.2.0",
"changelog": "- 新增XX功能",
"downloadType": "local",
"filePath": "uploads/nanami-1.2.0.zip",
"externalUrl": null,
"gameVersion": "1.12.1",
"downloadCount": 500,
"isLatest": true,
"createdAt": "2026-03-18T10:00:00.000Z",
"addon": {
"id": "cxxx001",
"name": "Nanami",
"slug": "nanami"
}
}
]
```
### 版本字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| id | string | 版本唯一 ID |
| addonId | string | 所属插件 ID |
| version | string | 版本号(如 "1.2.0" |
| changelog | string | 更新日志 |
| downloadType | string | `local` 本地文件 / `url` 外部链接 |
| gameVersion | string | 适配的游戏版本(如 "1.12.1" |
| downloadCount | int | 下载次数 |
| isLatest | boolean | 是否为最新版本 |
| addon | object | 所属插件基本信息 |
---
## 1.4 下载插件
通过版本 ID 下载对应的插件压缩包。
### 请求
```
GET /api/download/{releaseId}
```
### 行为
- **本地文件**:返回文件二进制流,`Content-Disposition` 设为 `attachment`
- **外部链接**302 重定向到外部下载地址
- 每次调用自动递增版本下载计数和插件总下载计数
### 响应头(本地文件时)
```
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="nanami-1.2.0.zip"
Content-Length: 1048576
```
### 错误响应
| HTTP 状态码 | 说明 |
|---|---|
| 404 | 版本不存在或文件丢失 |
---
# 二、软件更新 API
供启动器检查自身版本更新和热更新。
---
## 软件标识slug
| slug | 用途 | 文件类型 |
|---|---|---|
| `nanami-launcher` | 启动器全量安装包 | Nanami-Launcher-x.x.x.exe |
| `nanami-launcher-patch` | 热更新补丁包 | app.asar |
---
## 2.1 检查更新
客户端启动时调用此接口,传入当前版本号以检测是否有新版本可用。
### 请求
```
GET /api/software/check-update?slug={slug}&versionCode={versionCode}
```
### 参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| slug | string | 是 | 软件标识 |
| versionCode | int | 否 | 当前客户端的版本代码(整数),不传默认为 0 |
### 响应示例
**有更新:**
```json
{
"hasUpdate": true,
"forceUpdate": false,
"latest": {
"version": "1.2.0",
"versionCode": 120,
"changelog": "- 新增XX功能\n- 修复YY问题",
"downloadUrl": "https://nui.rucky.cn/api/software/download/cxxx123",
"fileSize": 5242880,
"minVersion": "1.0.0",
"createdAt": "2026-03-18T10:00:00.000Z"
}
}
```
**无更新:**
```json
{
"hasUpdate": false,
"forceUpdate": false,
"latest": {
"version": "1.0.0",
"versionCode": 100,
"changelog": "...",
"downloadUrl": "https://nui.rucky.cn/api/software/download/cxxx123",
"fileSize": 5242880,
"minVersion": null,
"createdAt": "2026-03-01T10:00:00.000Z"
}
}
```
### 响应字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| hasUpdate | boolean | 是否有新版本 |
| forceUpdate | boolean | 是否强制更新true 时客户端应阻止用户继续使用旧版) |
| latest.version | string | 最新版本号(如 "1.2.0" |
| latest.versionCode | int | 最新版本代码(整数,用于比较大小) |
| latest.changelog | string | 更新日志 |
| latest.downloadUrl | string | 下载地址(直接用于下载) |
| latest.fileSize | int | 文件大小(字节) |
| latest.minVersion | string\|null | 最低兼容版本号 |
| latest.createdAt | string | 发布时间 (ISO 8601) |
### 错误响应
| HTTP 状态码 | 说明 |
|---|---|
| 400 | 缺少 slug 参数 |
| 404 | 软件不存在或暂无版本 |
---
## 2.2 下载软件包
通过版本 ID 下载对应的软件安装包。一般从"检查更新"接口返回的 `downloadUrl` 直接获取。
### 请求
```
GET /api/software/download/{versionId}
```
### 行为
- **本地文件**:返回文件二进制流,`Content-Disposition` 设为 `attachment`
- **外部链接**302 重定向到外部下载地址
- 每次调用自动递增下载计数
### 响应头(本地文件时)
```
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="nanami-launcher-v1.2.0.exe"
Content-Length: 5242880
```
### 错误响应
| HTTP 状态码 | 说明 |
|---|---|
| 404 | 版本不存在或文件丢失 |
---
## 2.3 首页启动器最新版本下载
直接下载 `nanami-launcher` 的最新版本,用于首页下载按钮。
### 请求
```
GET /api/software/latest
```
下载最新全量包(自动递增下载计数)。
```
GET /api/software/latest?info=1
```
仅返回版本信息 JSON不下载。
---
## 2.4 客户端更新检查流程
Nanami 启动器在启动后应按以下顺序检查更新:
```
客户端启动(当前 versionCode = 76
├── 步骤 1: 检查全量更新
│ GET /api/software/check-update?slug=nanami-launcher&versionCode=76
│ │
│ ├── forceUpdate=true → 弹出强制更新对话框,下载完整安装包
│ │ 不再检查热更新
│ │
│ └── hasUpdate=true, forceUpdate=false → 显示"新版本"徽章(用户自行决定)
│ │
│ └── 继续步骤 2
├── 步骤 2: 检查热更新
│ GET /api/software/check-update?slug=nanami-launcher-patch&versionCode=76
│ │
│ ├── hasUpdate=true → 静默下载 app.asar替换本地文件
│ │ 显示"就绪"徽章,提示重启生效
│ │
│ └── hasUpdate=false → 当前已是最新,无需操作
└── 正常运行
```
### 版本号对应关系示例
| 发布类型 | 版本号 | versionCode | slug |
|---|---|---|---|
| 全量安装包 | 0.7.6 | 76 | nanami-launcher |
| 热更新补丁 | 0.7.7 | 77 | nanami-launcher-patch |
| 全量大版本 | 0.8.0 | 80 | nanami-launcher |
### 后台操作
- **发布全量版本**在「Nanami 启动器(全量包)」中上传 `.exe` 文件
- **发布热更新**在「Nanami 热更新包」中上传 `app.asar` 文件
- 两者的 `versionCode` 应保持递增关系,以便客户端正确比较
---
---
# 三、客户端集成示例Electron
## 3.1 启动器自身更新
```javascript
const BASE_URL = "https://nui.rucky.cn/api/software/check-update";
const CURRENT_VERSION_CODE = 76;
async function checkUpdates() {
// 步骤 1: 检查全量更新
const fullResp = await fetch(
`${BASE_URL}?slug=nanami-launcher&versionCode=${CURRENT_VERSION_CODE}`
);
const fullData = await fullResp.json();
if (fullData.hasUpdate && fullData.forceUpdate) {
showForceUpdateDialog(fullData.latest);
return;
}
if (fullData.hasUpdate) {
showUpdateBadge(fullData.latest);
}
// 步骤 2: 检查热更新
const patchResp = await fetch(
`${BASE_URL}?slug=nanami-launcher-patch&versionCode=${CURRENT_VERSION_CODE}`
);
const patchData = await patchResp.json();
if (patchData.hasUpdate) {
const asarResp = await fetch(patchData.latest.downloadUrl);
const buffer = await asarResp.arrayBuffer();
// 写入本地 app.asar 路径...
showRestartBadge();
}
}
```
## 3.2 插件市场拉取
```javascript
const API_BASE = "https://nui.rucky.cn";
// 获取全部已发布插件
async function fetchAddons(category?: string, search?: string) {
const params = new URLSearchParams();
if (category) params.set("category", category);
if (search) params.set("search", search);
const resp = await fetch(`${API_BASE}/api/addons?${params}`);
return resp.json();
}
// 获取单个插件详情(含所有版本和截图)
async function fetchAddonDetail(slug: string) {
const resp = await fetch(`${API_BASE}/api/addons/${slug}`);
return resp.json();
}
// 下载插件最新版本
async function downloadAddon(addon: any) {
const latestRelease = addon.releases?.find((r: any) => r.isLatest);
if (!latestRelease) return;
const downloadUrl = `${API_BASE}/api/download/${latestRelease.id}`;
const resp = await fetch(downloadUrl);
const buffer = await resp.arrayBuffer();
// 保存到 WoW 插件目录 Interface/AddOns/...
}
// 检查插件是否有更新(对比本地已安装版本)
async function checkAddonUpdates(installedAddons: { slug: string; version: string }[]) {
const addons = await fetchAddons();
const updates = [];
for (const installed of installedAddons) {
const remote = addons.find((a: any) => a.slug === installed.slug);
if (!remote) continue;
const latestRelease = remote.releases?.[0];
if (latestRelease && latestRelease.version !== installed.version) {
updates.push({
addon: remote,
currentVersion: installed.version,
newVersion: latestRelease.version,
});
}
}
return updates;
}
```

309
docs/DEPLOY-BAOTA.md Normal file
View File

@@ -0,0 +1,309 @@
# 阿里云宝塔面板部署文档
本文档介绍如何在阿里云 ECS + 宝塔面板环境中部署 nanami-web 项目。
---
## 方案概览
```
用户浏览器 --> Nginx (宝塔管理SSL) --> Node.js (PM2 守护) --> PostgreSQL
```
- **Nginx**: 由宝塔面板管理,负责反向代理和 SSL
- **Node.js**: 使用 PM2 进程守护运行 Next.js standalone 产物
- **PostgreSQL**: 通过宝塔面板安装,或使用 Docker
---
## 一、环境准备
### 1.1 宝塔面板安装软件
在宝塔面板「软件商店」中安装:
- **Nginx**(推荐最新稳定版)
- **PostgreSQL 16**(或通过 Docker 安装)
- **PM2 管理器**Node.js 进程管理)
- **Node.js 版本管理器**,并安装 **Node.js 20+**
### 1.2 创建数据库
1. 进入宝塔面板 → 数据库 → PostgreSQL
2. 添加数据库:
- 数据库名:`nanamiweb`(宝塔不允许包含下划线等特殊字符)
- 用户名:`nanami`
- 密码:自行设定,建议只用字母和数字(记住用于后续配置)
如果宝塔不支持 PostgreSQL 管理界面,可通过终端操作:
```bash
# 切换到 postgres 用户
sudo -u postgres psql
# 创建用户和数据库
CREATE USER nanami WITH PASSWORD 'your_password_here';
CREATE DATABASE nanamiweb OWNER nanami;
\q
```
---
## 二、部署项目
### 2.1 上传代码
```bash
# 在服务器上克隆代码(或通过宝塔面板上传 zip
cd /www/wwwroot
git clone <your-repo-url> nanami-web
cd nanami-web
```
### 2.2 安装依赖并构建
```bash
# 安装 Node.js 依赖
npm install
# 创建环境变量文件
cp .env.example .env
```
### 2.3 配置环境变量
编辑 `/www/wwwroot/nanami-web/.env`
```env
DATABASE_URL="postgresql://nanami:your_password_here@localhost:5432/nanamiweb?schema=public"
NEXTAUTH_SECRET="生成一个随机字符串"
NEXTAUTH_URL="https://your-domain.com"
ADMIN_USERNAME="admin"
ADMIN_PASSWORD="your_admin_password"
```
生成随机密钥:
```bash
openssl rand -base64 32
```
### 2.4 初始化数据库
```bash
# 推送表结构
npx prisma db push
# 初始化管理员账号
npx tsx prisma/seed.ts
```
### 2.5 构建项目
```bash
npm run build
```
构建完成后会在 `.next/standalone` 目录生成独立的 Node.js 应用。
### 2.6 准备运行目录
```bash
# 复制静态文件到 standalone 目录
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
# 创建上传目录
mkdir -p .next/standalone/uploads
# 创建软链接使 uploads 共享
ln -sf /www/wwwroot/nanami-web/uploads .next/standalone/uploads
```
---
## 三、使用 PM2 启动应用
### 3.1 创建 PM2 配置文件
在项目根目录创建 `ecosystem.config.js`
```javascript
module.exports = {
apps: [
{
name: "nanami-web",
script: ".next/standalone/server.js",
cwd: "/www/wwwroot/nanami-web",
env: {
NODE_ENV: "production",
PORT: 3000,
HOSTNAME: "0.0.0.0",
},
instances: 1,
autorestart: true,
max_memory_restart: "512M",
},
],
};
```
### 3.2 启动应用
**方式一:通过宝塔 PM2 管理器**
1. 进入宝塔面板 → 软件商店 → PM2 管理器
2. 添加项目:
- 项目目录:`/www/wwwroot/nanami-web`
- 启动文件:`.next/standalone/server.js`
- 项目名称:`nanami-web`
**方式二:通过命令行**
```bash
cd /www/wwwroot/nanami-web
pm2 start ecosystem.config.js
pm2 save
pm2 startup # 设置开机自启
```
### 3.3 验证应用运行
```bash
curl http://localhost:3000
# 应返回 HTML 内容
```
---
## 四、配置 Nginx 反向代理
### 4.1 宝塔面板创建网站
1. 进入宝塔面板 → 网站 → 添加站点
2. 域名:填写你的域名(如 `addon.example.com`
3. PHP 版本:选择「纯静态」
4. 不创建数据库
### 4.2 配置反向代理
进入网站设置 → 反向代理 → 添加反向代理:
- 代理名称:`nanami`
- 目标 URL`http://127.0.0.1:3000`
- 发送域名:`$host`
或手动编辑 Nginx 配置,在 `server` 块中添加:
```nginx
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 上传文件大小限制(根据需要调整)
client_max_body_size 100m;
}
```
### 4.3 配置 SSL
1. 进入网站设置 → SSL
2. 选择 Let's Encrypt → 申请证书
3. 开启「强制 HTTPS」
### 4.4 阿里云安全组
确保阿里云安全组放行以下端口:
| 端口 | 用途 |
|---|---|
| 80 | HTTP |
| 443 | HTTPS |
| 22 | SSH |
> 注意3000 端口**不需要**对外开放Nginx 通过内部代理访问。
---
## 五、更新部署
当代码有更新时:
```bash
cd /www/wwwroot/nanami-web
# 拉取最新代码
git pull
# 安装依赖(如有新依赖)
npm install
# 同步数据库变更(如有 schema 变化)
npx prisma db push
# 重新构建
npm run build
# 复制静态文件
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
ln -sf /www/wwwroot/nanami-web/uploads .next/standalone/uploads
# 重启应用
pm2 restart nanami-web
```
可以将上述步骤写成脚本 `deploy.sh`
```bash
#!/bin/bash
set -e
cd /www/wwwroot/nanami-web
git pull
npm install
npx prisma db push
npm run build
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
ln -sf /www/wwwroot/nanami-web/uploads .next/standalone/uploads
pm2 restart nanami-web
echo "部署完成"
```
---
## 六、常见问题
### Q: 上传的文件在哪里?
上传的文件存储在 `/www/wwwroot/nanami-web/uploads/` 目录。请确保定期备份。
### Q: 如何备份数据库?
```bash
pg_dump -U nanami nanami_web > backup_$(date +%Y%m%d).sql
```
可在宝塔面板的「计划任务」中添加定时备份。
### Q: 忘记管理员密码?
重新运行 seed 脚本(会用 .env 中的密码重置):
```bash
cd /www/wwwroot/nanami-web
npx tsx prisma/seed.ts
```
### Q: 如何查看应用日志?
```bash
pm2 logs nanami-web
pm2 logs nanami-web --lines 100 # 查看最近100行
```

20
ecosystem.config.js Normal file
View File

@@ -0,0 +1,20 @@
const path = require("path");
module.exports = {
apps: [
{
name: "nanami-web",
script: "server.js",
cwd: path.join(__dirname, ".next", "standalone"),
exec_mode: "fork",
env: {
NODE_ENV: "production",
PORT: 3000,
HOSTNAME: "0.0.0.0",
},
instances: 1,
autorestart: true,
max_memory_restart: "512M",
},
],
};

View File

@@ -1,7 +1,13 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
serverExternalPackages: ["bcryptjs", "@prisma/adapter-pg", "pg"],
images: {
remotePatterns: [
{ protocol: "https", hostname: "**" },
],
},
};
export default nextConfig;

1821
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,28 +6,48 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"db:migrate": "npx prisma migrate dev",
"db:push": "npx prisma db push",
"db:seed": "npx tsx prisma/seed.ts",
"db:studio": "npx prisma studio",
"postinstall": "npx prisma generate"
},
"prisma": {
"seed": "npx tsx prisma/seed.ts"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@prisma/adapter-pg": "^7.5.0",
"@prisma/client": "^7.5.0",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"next": "16.1.7",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"pg": "^8.20.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"shadcn": "^4.0.8",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/pg": "^8.18.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.3.1",
"eslint": "^9",
"eslint-config-next": "16.1.7",
"prisma": "^7.5.0",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
}
}

14
prisma.config.ts Normal file
View File

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

89
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,89 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model Admin {
id String @id @default(cuid())
username String @unique
passwordHash String
createdAt DateTime @default(now())
}
model Addon {
id String @id @default(cuid())
name String
slug String @unique
summary String
description String @db.Text
iconUrl String?
category String @default("general")
published Boolean @default(false)
totalDownloads Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
releases Release[]
screenshots Screenshot[]
}
model Release {
id String @id @default(cuid())
addonId String
version String
changelog String @db.Text
downloadType String @default("local")
filePath String?
externalUrl String?
gameVersion String @default("")
downloadCount Int @default(0)
isLatest Boolean @default(false)
createdAt DateTime @default(now())
addon Addon @relation(fields: [addonId], references: [id], onDelete: Cascade)
@@index([addonId])
}
model Screenshot {
id String @id @default(cuid())
addonId String
imageUrl String
sortOrder Int @default(0)
addon Addon @relation(fields: [addonId], references: [id], onDelete: Cascade)
@@index([addonId])
}
model Software {
id String @id @default(cuid())
name String
slug String @unique
description String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
versions SoftwareVersion[]
}
model SoftwareVersion {
id String @id @default(cuid())
softwareId String
version String
versionCode Int
changelog String @db.Text
downloadType String @default("local")
filePath String?
externalUrl String?
fileSize Int @default(0)
downloadCount Int @default(0)
isLatest Boolean @default(false)
forceUpdate Boolean @default(false)
minVersion String?
createdAt DateTime @default(now())
software Software @relation(fields: [softwareId], references: [id], onDelete: Cascade)
@@unique([softwareId, version])
@@index([softwareId])
}

48
prisma/seed.ts Normal file
View File

@@ -0,0 +1,48 @@
import "dotenv/config";
import { PrismaClient } from "../src/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import bcrypt from "bcryptjs";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
const prisma = new PrismaClient({ adapter });
async function main() {
const username = process.env.ADMIN_USERNAME || "admin";
const password = process.env.ADMIN_PASSWORD || "admin123";
const passwordHash = await bcrypt.hash(password, 12);
await prisma.admin.upsert({
where: { username },
update: { passwordHash },
create: { username, passwordHash },
});
console.log(`Admin user "${username}" created/updated.`);
const existingAddon = await prisma.addon.findUnique({
where: { slug: "nanami" },
});
if (!existingAddon) {
await prisma.addon.create({
data: {
name: "Nanami",
slug: "nanami",
summary: "A powerful WoW addon that enhances your gameplay experience.",
description:
"# Nanami\n\nNanami is a comprehensive World of Warcraft addon designed to improve your gaming experience.\n\n## Features\n\n- Feature 1\n- Feature 2\n- Feature 3",
category: "gameplay",
published: true,
},
});
console.log("Sample addon 'Nanami' created.");
}
}
main()
.then(() => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

BIN
public/banners/banner_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

BIN
public/banners/banner_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

BIN
public/banners/banner_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

View File

@@ -0,0 +1,209 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Download, Package, Calendar, Tag } from "lucide-react";
import { DownloadButton } from "@/components/public/DownloadButton";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const addon = await prisma.addon.findUnique({
where: { slug },
select: { name: true, summary: true },
});
if (!addon) return { title: "Not Found" };
return {
title: `${addon.name} - Nanami`,
description: addon.summary,
};
}
export const dynamic = "force-dynamic";
export default async function AddonDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const addon = await prisma.addon.findUnique({
where: { slug },
include: {
releases: { orderBy: { createdAt: "desc" } },
screenshots: { orderBy: { sortOrder: "asc" } },
},
});
if (!addon || !addon.published) notFound();
const latestRelease = addon.releases.find((r) => r.isLatest);
return (
<div className="mx-auto max-w-6xl px-4 py-12">
{/* Header */}
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
{addon.iconUrl ? (
<img
src={addon.iconUrl}
alt={addon.name}
className="h-20 w-20 rounded-2xl object-cover"
/>
) : (
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-primary/10">
<Package className="h-10 w-10 text-primary" />
</div>
)}
<div className="flex-1">
<h1 className="text-3xl font-bold">{addon.name}</h1>
<p className="mt-2 text-lg text-muted-foreground">{addon.summary}</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<Badge>{addon.category}</Badge>
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Download className="h-3.5 w-3.5" />
{addon.totalDownloads.toLocaleString()}
</span>
{latestRelease && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<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>
<Separator className="my-8" />
<div className="grid gap-8 lg:grid-cols-3">
{/* Description */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="prose prose-neutral max-w-none dark:prose-invert">
<MarkdownContent content={addon.description} />
</div>
</CardContent>
</Card>
{/* Screenshots */}
{addon.screenshots.length > 0 && (
<Card className="mt-6">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
{addon.screenshots.map((ss) => (
<img
key={ss.id}
src={ss.imageUrl}
alt="Screenshot"
className="rounded-lg border object-cover"
/>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* Sidebar - Releases */}
<div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{addon.releases.length}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{addon.releases.map((release) => (
<div
key={release.id}
className="rounded-lg border p-4 transition-colors hover:bg-muted/50"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-semibold">v{release.version}</span>
{release.isLatest && (
<Badge variant="default" className="text-xs">
</Badge>
)}
</div>
<DownloadButton
releaseId={release.id}
version={release.version}
size="sm"
/>
</div>
{release.gameVersion && (
<p className="mt-1 text-xs text-muted-foreground">
WoW {release.gameVersion}
</p>
)}
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
<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 text-sm text-muted-foreground whitespace-pre-line">
{release.changelog}
</p>
)}
</div>
))}
{addon.releases.length === 0 && (
<p className="text-sm text-muted-foreground"></p>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function MarkdownContent({ content }: { content: string }) {
const html = content
.replace(/^### (.*$)/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
.replace(/^## (.*$)/gm, '<h2 class="text-xl font-semibold mt-6 mb-3">$1</h2>')
.replace(/^# (.*$)/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`(.*?)`/g, '<code class="rounded bg-muted px-1.5 py-0.5 text-sm">$1</code>')
.replace(/^- (.*$)/gm, '<li class="ml-4 list-disc">$1</li>')
.replace(/\n\n/g, '<br/><br/>')
.replace(/\n/g, "<br/>");
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

View File

@@ -0,0 +1,101 @@
import { prisma } from "@/lib/db";
import { AddonCard } from "@/components/public/AddonCard";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
const categoryLabels: Record<string, string> = {
general: "通用",
gameplay: "游戏玩法",
ui: "界面增强",
combat: "战斗",
raid: "团队副本",
pvp: "PvP",
tradeskill: "专业技能",
utility: "实用工具",
};
export const metadata = {
title: "插件列表 - Nanami",
};
export const dynamic = "force-dynamic";
export default async function AddonsPage({
searchParams,
}: {
searchParams: Promise<{ category?: string; search?: string }>;
}) {
const { category, search } = await searchParams;
const where: Record<string, unknown> = { published: true };
if (category) where.category = category;
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ summary: { contains: search, mode: "insensitive" } },
];
}
const addons = await prisma.addon.findMany({
where,
include: {
releases: {
where: { isLatest: true },
select: { version: true },
},
},
orderBy: { totalDownloads: "desc" },
});
const categories = await prisma.addon.groupBy({
by: ["category"],
where: { published: true },
_count: { id: true },
});
return (
<div className="mx-auto max-w-6xl px-4 py-12">
<h1 className="text-3xl font-bold"></h1>
<p className="mt-2 text-muted-foreground">
World of Warcraft
</p>
{/* Category Filter */}
<div className="mt-6 flex flex-wrap gap-2">
<Link href="/addons">
<Badge
variant={!category ? "default" : "outline"}
className="cursor-pointer"
>
</Badge>
</Link>
{categories.map((cat) => (
<Link key={cat.category} href={`/addons?category=${cat.category}`}>
<Badge
variant={category === cat.category ? "default" : "outline"}
className="cursor-pointer"
>
{categoryLabels[cat.category] || cat.category} ({cat._count.id})
</Badge>
</Link>
))}
</div>
{/* Addon Grid */}
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{addons.map((addon) => (
<AddonCard key={addon.id} addon={addon} />
))}
</div>
{addons.length === 0 && (
<div className="mt-16 text-center">
<p className="text-lg text-muted-foreground">
{search ? `没有找到"${search}"相关的插件` : "暂无插件"}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dark flex min-h-screen flex-col bg-[#0d0b15]">
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}

110
src/app/(public)/page.tsx Normal file
View File

@@ -0,0 +1,110 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Button } from "@/components/ui/button";
import { AddonCard } from "@/components/public/AddonCard";
import { HeroBanner } from "@/components/public/HeroBanner";
import { Sparkles, Shield, Zap } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function HomePage() {
const [featuredAddons, totalDownloads, launcher] = await Promise.all([
prisma.addon.findMany({
where: { published: true },
include: {
releases: {
where: { isLatest: true },
select: { version: true },
},
},
orderBy: { totalDownloads: "desc" },
take: 6,
}),
prisma.addon.aggregate({
_sum: { totalDownloads: true },
}),
prisma.software.findUnique({
where: { slug: "nanami-launcher" },
include: {
versions: {
where: { isLatest: true },
take: 1,
},
},
}),
]);
const launcherVersion = launcher?.versions[0]?.version ?? null;
return (
<>
<HeroBanner
totalDownloads={totalDownloads._sum.totalDownloads ?? undefined}
launcherVersion={launcherVersion}
/>
{/* Features */}
<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="relative mx-auto max-w-6xl px-4 py-16">
<div className="grid 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 h-12 w-12 items-center justify-center rounded-xl bg-amber-500/10">
<Sparkles className="h-6 w-6 text-amber-400" />
</div>
<h3 className="mt-4 font-semibold text-amber-100"></h3>
<p className="mt-2 text-sm text-gray-400">
1.18.0
</p>
</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 h-12 w-12 items-center justify-center rounded-xl bg-purple-500/10">
<Shield className="h-6 w-6 text-purple-400" />
</div>
<h3 className="mt-4 font-semibold text-amber-100">
</h3>
<p className="mt-2 text-sm text-gray-400">
Nanami
</p>
</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 h-12 w-12 items-center justify-center rounded-xl bg-cyan-500/10">
<Zap className="h-6 w-6 text-cyan-400" />
</div>
<h3 className="mt-4 font-semibold text-amber-100">
AI
</h3>
<p className="mt-2 text-sm text-gray-400">
</p>
</div>
</div>
</div>
</section>
{/* Featured Addons */}
{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]">
<div className="mx-auto max-w-6xl px-4 py-16">
<div className="mb-8 flex items-center justify-between">
<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="/addons" />}
>
</Button>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{featuredAddons.map((addon) => (
<AddonCard key={addon.id} addon={addon} />
))}
</div>
</div>
</section>
)}
</>
);
}

View File

@@ -0,0 +1,23 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { AddonForm } from "@/components/admin/AddonForm";
export const dynamic = "force-dynamic";
export default async function EditAddonPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const addon = await prisma.addon.findUnique({ where: { id } });
if (!addon) notFound();
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold"></h1>
<AddonForm initialData={addon} />
</div>
);
}

View File

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

View File

@@ -0,0 +1,87 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
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 { DeleteAddonButton } from "@/components/admin/DeleteAddonButton";
export const dynamic = "force-dynamic";
export default async function AdminAddonsPage() {
const addons = await prisma.addon.findMany({
include: { _count: { select: { releases: true } } },
orderBy: { updatedAt: "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/addons/new" />}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>Slug</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{addons.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
addons.map((addon) => (
<TableRow key={addon.id}>
<TableCell className="font-medium">{addon.name}</TableCell>
<TableCell className="text-muted-foreground">
{addon.slug}
</TableCell>
<TableCell>
<Badge variant="secondary">{addon.category}</Badge>
</TableCell>
<TableCell>
<Badge variant={addon.published ? "default" : "outline"}>
{addon.published ? "已发布" : "草稿"}
</Badge>
</TableCell>
<TableCell>{addon._count.releases}</TableCell>
<TableCell>{addon.totalDownloads}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" render={<Link href={`/admin/addons/${addon.id}/edit`} />}>
</Button>
<DeleteAddonButton addonId={addon.id} addonName={addon.name} />
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { Sidebar } from "@/components/admin/Sidebar";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-y-auto bg-muted/40 p-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { prisma } from "@/lib/db";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Package, Download, FileUp } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const [addonCount, totalDownloads, releaseCount, recentReleases] =
await Promise.all([
prisma.addon.count(),
prisma.addon.aggregate({ _sum: { totalDownloads: true } }),
prisma.release.count(),
prisma.release.findMany({
take: 5,
orderBy: { createdAt: "desc" },
include: { addon: { select: { name: true } } },
}),
]);
const stats = [
{
title: "插件总数",
value: addonCount,
icon: Package,
},
{
title: "总下载量",
value: totalDownloads._sum.totalDownloads || 0,
icon: Download,
},
{
title: "版本发布数",
value: releaseCount,
icon: FileUp,
},
];
return (
<div className="space-y-8">
<h1 className="text-3xl font-bold"></h1>
<div className="grid gap-4 md:grid-cols-3">
{stats.map((stat) => (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{stat.title}
</CardTitle>
<stat.icon className="h-5 w-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stat.value}</div>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{recentReleases.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="space-y-4">
{recentReleases.map((release) => (
<div
key={release.id}
className="flex items-center justify-between border-b pb-3 last:border-0"
>
<div>
<p className="font-medium">{release.addon.name}</p>
<p className="text-sm text-muted-foreground">
v{release.version}
{release.gameVersion &&
` · WoW ${release.gameVersion}`}
</p>
</div>
<div className="text-right text-sm text-muted-foreground">
<p>{release.downloadCount} </p>
<p>{new Date(release.createdAt).toLocaleDateString("zh-CN")}</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { prisma } from "@/lib/db";
import { ReleaseForm } from "@/components/admin/ReleaseForm";
export const dynamic = "force-dynamic";
export default async function NewReleasePage() {
const addons = await prisma.addon.findMany({
select: { id: true, name: true },
orderBy: { name: "asc" },
});
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold"></h1>
{addons.length === 0 ? (
<p className="text-muted-foreground">
</p>
) : (
<ReleaseForm addons={addons} />
)}
</div>
);
}

View File

@@ -0,0 +1,88 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
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";
export const dynamic = "force-dynamic";
export default async function AdminReleasesPage() {
const releases = await prisma.release.findMany({
include: { addon: { select: { name: true, slug: true } } },
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/releases/new" />}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="rounded-lg border bg-card">
<Table>
<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>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
export default function SettingsPage() {
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const formData = new FormData(e.currentTarget);
const newPassword = formData.get("newPassword") as string;
const confirmPassword = formData.get("confirmPassword") as string;
if (newPassword !== confirmPassword) {
toast.error("两次输入的新密码不一致");
setLoading(false);
return;
}
const res = await fetch("/api/admin/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
currentPassword: formData.get("currentPassword"),
newPassword,
}),
});
const data = await res.json();
if (res.ok) {
toast.success("密码修改成功");
(e.target as HTMLFormElement).reset();
} else {
toast.error(data.error || "修改失败");
}
setLoading(false);
}
return (
<div className="mx-auto max-w-lg space-y-6">
<h1 className="text-3xl font-bold"></h1>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword"></Label>
<Input
id="currentPassword"
name="currentPassword"
type="password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword"></Label>
<Input
id="newPassword"
name="newPassword"
type="password"
required
minLength={6}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword"></Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength={6}
/>
</div>
<Button type="submit" disabled={loading}>
{loading ? "修改中..." : "修改密码"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { SoftwareEditForm } from "@/components/admin/SoftwareEditForm";
export const dynamic = "force-dynamic";
export default async function EditSoftwarePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const software = await prisma.software.findUnique({
where: { id },
include: { versions: { orderBy: { versionCode: "desc" } } },
});
if (!software) notFound();
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold"> - {software.name}</h1>
<SoftwareEditForm software={software} />
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { SoftwareVersionForm } from "@/components/admin/SoftwareVersionForm";
export const dynamic = "force-dynamic";
export default async function NewSoftwareVersionPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const software = await prisma.software.findUnique({
where: { id },
select: { id: true, name: true, slug: true },
});
if (!software) notFound();
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold">
- {software.name}
</h1>
<SoftwareVersionForm softwareId={software.id} />
</div>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
export default function NewSoftwarePage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
function generateSlug(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const fd = new FormData(e.currentTarget);
const res = await fetch("/api/software", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: fd.get("name"),
slug: fd.get("slug"),
description: fd.get("description"),
}),
});
if (res.ok) {
toast.success("创建成功");
router.push("/admin/software");
router.refresh();
} else {
const err = await res.json();
toast.error(err.error || "创建失败");
}
setLoading(false);
}
return (
<div className="mx-auto max-w-lg space-y-6">
<h1 className="text-3xl font-bold"></h1>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name" name="name" required
onChange={(e) => {
const slugInput = document.getElementById("slug") as HTMLInputElement;
if (slugInput) slugInput.value = generateSlug(e.target.value);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug *</Label>
<Input id="slug" name="slug" required pattern="[a-z0-9-]+" />
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea id="description" name="description" rows={3} />
</div>
<div className="flex gap-3">
<Button type="submit" disabled={loading}>
{loading ? "创建中..." : "创建"}
</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Plus } from "lucide-react";
import { SoftwareVersionTable } from "@/components/admin/SoftwareVersionTable";
const SOFTWARE_DEFS = [
{
slug: "nanami-launcher",
name: "Nanami 启动器(全量包)",
description: "Nanami 插件启动器完整安装包,用户首次下载或大版本更新",
badgeLabel: "全量",
},
{
slug: "nanami-launcher-patch",
name: "Nanami 热更新包",
description: "app.asar 热更新包,客户端静默下载替换实现热更新",
badgeLabel: "热更新",
},
];
async function ensureSoftware(slug: string, name: string, description: string) {
let sw = await prisma.software.findUnique({ where: { slug } });
if (!sw) {
sw = await prisma.software.create({ data: { name, slug, description } });
}
return sw;
}
export const dynamic = "force-dynamic";
export default async function AdminSoftwarePage() {
const softwareItems = await Promise.all(
SOFTWARE_DEFS.map(async (def) => {
const sw = await ensureSoftware(def.slug, def.name, def.description);
const versions = await prisma.softwareVersion.findMany({
where: { softwareId: sw.id },
orderBy: { versionCode: "desc" },
});
const totalDownloads = versions.reduce((s, v) => s + v.downloadCount, 0);
const latestVersion = versions.find((v) => v.isLatest);
return { ...def, sw, versions, totalDownloads, latestVersion };
})
);
return (
<div className="space-y-10">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="mt-1 text-muted-foreground">
slug
</p>
</div>
{softwareItems.map((item) => {
const serializedVersions = item.versions.map((v) => ({
...v,
createdAt: v.createdAt.toISOString(),
}));
return (
<div key={item.slug} className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold">{item.name}</h2>
<span className="rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
slug: {item.slug}
</span>
</div>
<Button
render={
<Link
href={`/admin/software/${item.sw.id}/versions/new`}
/>
}
>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{item.latestVersion
? `v${item.latestVersion.version}`
: "未发布"}
</p>
</CardContent>
</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>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="p-0">
<SoftwareVersionTable versions={serializedVersions} />
</CardContent>
</Card>
</div>
);
})}
</div>
);
}

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

@@ -0,0 +1,11 @@
export const metadata = {
title: "管理后台 - Nanami",
};
export default function AdminRootLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,7 @@
export default function LoginLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const result = await signIn("credentials", {
username: formData.get("username"),
password: formData.get("password"),
redirect: false,
});
if (result?.error) {
setError("用户名或密码错误");
setLoading(false);
} else {
router.push("/admin");
router.refresh();
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-muted/40">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
name="username"
required
autoComplete="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "登录中..." : "登录"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,82 @@
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 addon = await prisma.addon.findFirst({
where: { OR: [{ id }, { slug: id }] },
include: {
releases: { orderBy: { createdAt: "desc" } },
screenshots: { orderBy: { sortOrder: "asc" } },
},
});
if (!addon) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(addon);
}
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 { name, slug, summary, description, iconUrl, category, published } =
body;
if (slug) {
const existing = await prisma.addon.findFirst({
where: { slug, NOT: { id } },
});
if (existing) {
return NextResponse.json(
{ error: "Slug already exists" },
{ status: 409 }
);
}
}
const addon = await prisma.addon.update({
where: { id },
data: {
...(name !== undefined && { name }),
...(slug !== undefined && { slug }),
...(summary !== undefined && { summary }),
...(description !== undefined && { description }),
...(iconUrl !== undefined && { iconUrl }),
...(category !== undefined && { category }),
...(published !== undefined && { published }),
},
});
return NextResponse.json(addon);
}
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.addon.delete({ where: { id } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,72 @@
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 category = searchParams.get("category");
const search = searchParams.get("search");
const publishedOnly = searchParams.get("published") !== "false";
const where: Record<string, unknown> = {};
if (publishedOnly) where.published = true;
if (category) where.category = category;
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ summary: { contains: search, mode: "insensitive" } },
];
}
const addons = await prisma.addon.findMany({
where,
include: {
releases: {
where: { isLatest: true },
take: 1,
},
_count: { select: { releases: true } },
},
orderBy: { updatedAt: "desc" },
});
return NextResponse.json(addons);
}
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 { name, slug, summary, description, iconUrl, category } = body;
if (!name || !slug || !summary) {
return NextResponse.json(
{ error: "name, slug, summary are required" },
{ status: 400 }
);
}
const existing = await prisma.addon.findUnique({ where: { slug } });
if (existing) {
return NextResponse.json(
{ error: "Slug already exists" },
{ status: 409 }
);
}
const addon = await prisma.addon.create({
data: {
name,
slug,
summary,
description: description || "",
iconUrl: iconUrl || null,
category: category || "general",
},
});
return NextResponse.json(addon, { status: 201 });
}

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
import bcrypt from "bcryptjs";
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword) {
return NextResponse.json(
{ error: "请填写当前密码和新密码" },
{ status: 400 }
);
}
if (newPassword.length < 6) {
return NextResponse.json(
{ error: "新密码长度不能少于6位" },
{ status: 400 }
);
}
const admin = await prisma.admin.findFirst({
where: { username: session.user.name! },
});
if (!admin) {
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
}
const isValid = await bcrypt.compare(currentPassword, admin.passwordHash);
if (!isValid) {
return NextResponse.json({ error: "当前密码错误" }, { status: 403 });
}
const newHash = await bcrypt.hash(newPassword, 12);
await prisma.admin.update({
where: { id: admin.id },
data: { passwordHash: newHash },
});
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { readFile, stat } from "fs/promises";
import path from "path";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const release = await prisma.release.findUnique({
where: { id },
include: { addon: { select: { name: true, slug: true } } },
});
if (!release) {
return NextResponse.json({ error: "Release not found" }, { status: 404 });
}
await prisma.release.update({
where: { id },
data: { downloadCount: { increment: 1 } },
});
await prisma.addon.update({
where: { id: release.addonId },
data: { totalDownloads: { increment: 1 } },
});
if (release.downloadType === "url" && release.externalUrl) {
return NextResponse.redirect(release.externalUrl);
}
if (release.filePath) {
const filePath = path.join(process.cwd(), release.filePath);
try {
await stat(filePath);
} catch {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
const fileBuffer = await readFile(filePath);
const fileName = `${release.addon.slug}-${release.version}${path.extname(release.filePath)}`;
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`,
"Content-Length": fileBuffer.length.toString(),
},
});
}
return NextResponse.json(
{ error: "No download source available" },
{ status: 404 }
);
}

View File

@@ -0,0 +1,76 @@
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 addonId = searchParams.get("addonId");
const where: Record<string, unknown> = {};
if (addonId) where.addonId = addonId;
const releases = await prisma.release.findMany({
where,
include: { addon: { select: { id: true, name: true, slug: true } } },
orderBy: { createdAt: "desc" },
});
return NextResponse.json(releases);
}
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 {
addonId,
version,
changelog,
downloadType,
filePath,
externalUrl,
gameVersion,
} = body;
if (!addonId || !version) {
return NextResponse.json(
{ error: "addonId and version are required" },
{ status: 400 }
);
}
if (downloadType === "url" && !externalUrl) {
return NextResponse.json(
{ error: "externalUrl is required for url type" },
{ status: 400 }
);
}
const addon = await prisma.addon.findUnique({ where: { id: addonId } });
if (!addon) {
return NextResponse.json({ error: "Addon not found" }, { status: 404 });
}
await prisma.release.updateMany({
where: { addonId, isLatest: true },
data: { isLatest: false },
});
const release = await prisma.release.create({
data: {
addonId,
version,
changelog: changelog || "",
downloadType: downloadType || "local",
filePath: filePath || null,
externalUrl: externalUrl || null,
gameVersion: gameVersion || "",
isLatest: true,
},
});
return NextResponse.json(release, { status: 201 });
}

View File

@@ -0,0 +1,61 @@
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 software = await prisma.software.findFirst({
where: { OR: [{ id }, { slug: id }] },
include: {
versions: { orderBy: { versionCode: "desc" } },
},
});
if (!software) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(software);
}
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 software = await prisma.software.update({
where: { id },
data: {
...(body.name !== undefined && { name: body.name }),
...(body.slug !== undefined && { slug: body.slug }),
...(body.description !== undefined && { description: body.description }),
},
});
return NextResponse.json(software);
}
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.software.delete({ where: { id } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
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: softwareId } = await params;
const body = await request.json();
const {
version,
versionCode,
changelog,
downloadType,
filePath,
externalUrl,
fileSize,
forceUpdate,
minVersion,
} = body;
if (!version || !versionCode) {
return NextResponse.json(
{ error: "version and versionCode are required" },
{ status: 400 }
);
}
const software = await prisma.software.findUnique({
where: { id: softwareId },
});
if (!software) {
return NextResponse.json(
{ error: "Software not found" },
{ status: 404 }
);
}
await prisma.softwareVersion.updateMany({
where: { softwareId, isLatest: true },
data: { isLatest: false },
});
const sv = await prisma.softwareVersion.create({
data: {
softwareId,
version,
versionCode: Number(versionCode),
changelog: changelog || "",
downloadType: downloadType || "local",
filePath: filePath || null,
externalUrl: externalUrl || null,
fileSize: Number(fileSize) || 0,
forceUpdate: forceUpdate || false,
minVersion: minVersion || null,
isLatest: true,
},
});
return NextResponse.json(sv, { status: 201 });
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const slug = searchParams.get("slug");
const currentVersionCode = searchParams.get("versionCode");
if (!slug) {
return NextResponse.json(
{ error: "slug parameter is required" },
{ status: 400 }
);
}
const software = await prisma.software.findUnique({
where: { slug },
include: {
versions: {
where: { isLatest: true },
take: 1,
},
},
});
if (!software || software.versions.length === 0) {
return NextResponse.json(
{ error: "Software or latest version not found" },
{ status: 404 }
);
}
const latest = software.versions[0];
const clientVersionCode = currentVersionCode
? parseInt(currentVersionCode, 10)
: 0;
const hasUpdate = latest.versionCode > clientVersionCode;
const downloadUrl = latest.downloadType === "url" && latest.externalUrl
? latest.externalUrl
: `${request.nextUrl.origin}/api/software/download/${latest.id}`;
return NextResponse.json({
hasUpdate,
forceUpdate: hasUpdate && latest.forceUpdate,
latest: {
version: latest.version,
versionCode: latest.versionCode,
changelog: latest.changelog,
downloadUrl,
fileSize: latest.fileSize,
minVersion: latest.minVersion,
createdAt: latest.createdAt,
},
});
}

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { readFile, stat } from "fs/promises";
import path from "path";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const sv = await prisma.softwareVersion.findUnique({
where: { id },
include: { software: { select: { slug: true } } },
});
if (!sv) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
await prisma.softwareVersion.update({
where: { id },
data: { downloadCount: { increment: 1 } },
});
if (sv.downloadType === "url" && sv.externalUrl) {
return new Response(null, {
status: 302,
headers: { Location: sv.externalUrl },
});
}
if (sv.filePath) {
const filePath = path.join(process.cwd(), sv.filePath);
try {
await stat(filePath);
} catch {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
const fileBuffer = await readFile(filePath);
const ext = path.extname(sv.filePath);
const fileName = `${sv.software.slug}-v${sv.version}${ext}`;
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`,
"Content-Length": fileBuffer.length.toString(),
},
});
}
return NextResponse.json(
{ error: "No download source available" },
{ status: 404 }
);
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { readFile, stat } from "fs/promises";
import path from "path";
const LAUNCHER_SLUG = "nanami-launcher";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const infoOnly = searchParams.get("info") === "1";
const software = await prisma.software.findUnique({
where: { slug: LAUNCHER_SLUG },
include: {
versions: {
where: { isLatest: true },
take: 1,
},
},
});
if (!software || software.versions.length === 0) {
if (infoOnly) {
return NextResponse.json({ available: false });
}
return NextResponse.json(
{ error: "暂无可下载版本" },
{ status: 404 }
);
}
const latest = software.versions[0];
if (infoOnly) {
const downloadUrl =
latest.downloadType === "url" && latest.externalUrl
? latest.externalUrl
: `/api/software/download/${latest.id}`;
return NextResponse.json({
available: true,
version: latest.version,
versionCode: latest.versionCode,
changelog: latest.changelog,
fileSize: latest.fileSize,
createdAt: latest.createdAt,
downloadUrl,
downloadType: latest.downloadType,
});
}
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-v${latest.version}${ext}`;
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`,
"Content-Length": fileBuffer.length.toString(),
},
});
}
return NextResponse.json({ error: "无下载来源" }, { status: 404 });
}

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
export async function GET() {
const software = await prisma.software.findMany({
include: {
versions: {
where: { isLatest: true },
take: 1,
},
_count: { select: { versions: true } },
},
orderBy: { updatedAt: "desc" },
});
return NextResponse.json(software);
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { name, slug, description } = await request.json();
if (!name || !slug) {
return NextResponse.json(
{ error: "name and slug are required" },
{ status: 400 }
);
}
const existing = await prisma.software.findUnique({ where: { slug } });
if (existing) {
return NextResponse.json(
{ error: "Slug already exists" },
{ status: 409 }
);
}
const software = await prisma.software.create({
data: { name, slug, description: description || "" },
});
return NextResponse.json(software, { status: 201 });
}

View File

@@ -0,0 +1,71 @@
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<{ versionId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { versionId } = await params;
const body = await request.json();
const existing = await prisma.softwareVersion.findUnique({
where: { id: versionId },
});
if (!existing) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
const setLatest = body.isLatest === true && !existing.isLatest;
if (setLatest) {
await prisma.softwareVersion.updateMany({
where: { softwareId: existing.softwareId, isLatest: true },
data: { isLatest: false },
});
}
const updated = await prisma.softwareVersion.update({
where: { id: versionId },
data: {
...(body.version !== undefined && { version: body.version }),
...(body.versionCode !== undefined && {
versionCode: Number(body.versionCode),
}),
...(body.changelog !== undefined && { changelog: body.changelog }),
...(body.downloadType !== undefined && {
downloadType: body.downloadType,
}),
...(body.filePath !== undefined && { filePath: body.filePath || null }),
...(body.externalUrl !== undefined && {
externalUrl: body.externalUrl || null,
}),
...(body.fileSize !== undefined && { fileSize: Number(body.fileSize) }),
...(body.forceUpdate !== undefined && { forceUpdate: body.forceUpdate }),
...(body.minVersion !== undefined && {
minVersion: body.minVersion || null,
}),
...(body.isLatest !== undefined && { isLatest: body.isLatest }),
},
});
return NextResponse.json(updated);
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ versionId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { versionId } = await params;
await prisma.softwareVersion.delete({ where: { id: versionId } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const timestamp = Date.now();
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
const filename = `${timestamp}-${safeName}`;
const uploadDir = path.join(process.cwd(), "uploads");
await mkdir(uploadDir, { recursive: true });
const filepath = path.join(uploadDir, filename);
await writeFile(filepath, buffer);
return NextResponse.json({
filePath: `/uploads/${filename}`,
originalName: file.name,
size: file.size,
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -7,8 +7,8 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-sans: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
--font-mono: "SF Mono", "Cascadia Code", "Menlo", "Consolas", monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -127,3 +127,70 @@
@apply font-sans;
}
}
/* ---- Hero Aurora Effects ---- */
.hero-aurora-wrapper {
position: relative;
z-index: 0;
}
.hero-aurora {
position: absolute;
left: 0;
right: 0;
height: 80px;
z-index: 30;
pointer-events: none;
filter: blur(30px);
opacity: 0.7;
}
.hero-aurora--top {
top: -30px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(168, 85, 247, 0.4) 15%,
rgba(59, 130, 246, 0.3) 30%,
rgba(236, 72, 153, 0.35) 50%,
rgba(139, 92, 246, 0.4) 70%,
rgba(6, 182, 212, 0.3) 85%,
transparent 100%
);
animation: auroraShift 8s ease-in-out infinite alternate;
}
.hero-aurora--bottom {
bottom: -30px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(245, 158, 11, 0.35) 15%,
rgba(168, 85, 247, 0.3) 35%,
rgba(6, 182, 212, 0.35) 55%,
rgba(236, 72, 153, 0.3) 75%,
rgba(245, 158, 11, 0.35) 90%,
transparent 100%
);
animation: auroraShift 8s ease-in-out infinite alternate-reverse;
}
@keyframes auroraShift {
0% {
background-position: 0% 50%;
opacity: 0.5;
}
50% {
opacity: 0.8;
}
100% {
background-position: 100% 50%;
opacity: 0.5;
}
}
/* Force background-size for aurora animation */
.hero-aurora--top,
.hero-aurora--bottom {
background-size: 200% 100%;
}

View File

@@ -1,20 +1,11 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/ThemeProvider";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Nanami - WoW Addons",
description: "World of Warcraft 插件发布与下载平台",
};
export default function RootLayout({
@@ -23,11 +14,12 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<html lang="zh-CN" suppressHydrationWarning>
<body className="antialiased">
<ThemeProvider>
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
);

View File

@@ -1,65 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,16 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return (
<Button variant="ghost" size="icon" className="h-9 w-9">
<Sun className="h-4 w-4" />
</Button>
);
}
const isDark = theme === "dark";
return (
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label={isDark ? "切换到亮色模式" : "切换到暗色模式"}
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
);
}

View File

@@ -0,0 +1,208 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
const categories = [
{ value: "general", label: "通用" },
{ value: "gameplay", label: "游戏玩法" },
{ value: "ui", label: "界面增强" },
{ value: "combat", label: "战斗" },
{ value: "raid", label: "团队副本" },
{ value: "pvp", label: "PvP" },
{ value: "tradeskill", label: "专业技能" },
{ value: "utility", label: "实用工具" },
];
interface AddonFormProps {
initialData?: {
id: string;
name: string;
slug: string;
summary: string;
description: string;
iconUrl: string | null;
category: string;
published: boolean;
};
}
export function AddonForm({ initialData }: AddonFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const isEdit = !!initialData;
function generateSlug(name: string) {
return name
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-")
.replace(/^-|-$/g, "");
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const formData = new FormData(e.currentTarget);
const data = {
name: formData.get("name"),
slug: formData.get("slug"),
summary: formData.get("summary"),
description: formData.get("description"),
iconUrl: formData.get("iconUrl") || null,
category: formData.get("category"),
published: formData.get("published") === "on",
};
const url = isEdit ? `/api/addons/${initialData.id}` : "/api/addons";
const method = isEdit ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
toast.success(isEdit ? "更新成功" : "创建成功");
router.push("/admin/addons");
router.refresh();
} else {
const err = await res.json();
toast.error(err.error || "操作失败");
}
setLoading(false);
}
return (
<Card>
<CardHeader>
<CardTitle>{isEdit ? "编辑插件" : "新建插件"}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
name="name"
required
defaultValue={initialData?.name}
onChange={(e) => {
if (!isEdit) {
const slugInput = document.getElementById(
"slug"
) as HTMLInputElement;
if (slugInput) slugInput.value = generateSlug(e.target.value);
}
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug (URL ) *</Label>
<Input
id="slug"
name="slug"
required
defaultValue={initialData?.slug}
pattern="[a-z0-9-]+"
title="只能包含小写字母、数字和连字符"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="summary"> *</Label>
<Input
id="summary"
name="summary"
required
defaultValue={initialData?.summary}
placeholder="一句话描述插件功能"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description"> ( Markdown)</Label>
<Textarea
id="description"
name="description"
rows={12}
defaultValue={initialData?.description}
placeholder="# 插件名称&#10;&#10;详细描述..."
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="iconUrl"> URL</Label>
<Input
id="iconUrl"
name="iconUrl"
defaultValue={initialData?.iconUrl || ""}
placeholder="https://example.com/icon.png"
/>
</div>
<div className="space-y-2">
<Label htmlFor="category"></Label>
<Select
name="category"
defaultValue={initialData?.category || "general"}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="published"
name="published"
defaultChecked={initialData?.published ?? false}
className="h-4 w-4 rounded border-input"
/>
<Label htmlFor="published"></Label>
</div>
<div className="flex gap-3">
<Button type="submit" disabled={loading}>
{loading ? "保存中..." : isEdit ? "更新" : "创建"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.back()}
>
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { Trash2 } from "lucide-react";
export function DeleteAddonButton({
addonId,
addonName,
}: {
addonId: string;
addonName: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
async function handleDelete() {
setLoading(true);
const res = await fetch(`/api/addons/${addonId}`, { method: "DELETE" });
if (res.ok) {
toast.success("删除成功");
setOpen(false);
router.refresh();
} else {
toast.error("删除失败");
}
setLoading(false);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="destructive" size="sm" />
}
>
<Trash2 className="h-4 w-4" />
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{addonName}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={loading}
>
{loading ? "删除中..." : "确认删除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,223 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { Upload } from "lucide-react";
interface ReleaseFormProps {
addons: { id: string; name: string }[];
}
export function ReleaseForm({ addons }: ReleaseFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [downloadType, setDownloadType] = useState("local");
const [uploadedFilePath, setUploadedFilePath] = useState("");
const [uploading, setUploading] = useState(false);
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (res.ok) {
const data = await res.json();
setUploadedFilePath(data.filePath);
toast.success(`文件 ${data.originalName} 上传成功`);
} else {
toast.error("文件上传失败");
}
setUploading(false);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const formData = new FormData(e.currentTarget);
const data = {
addonId: formData.get("addonId"),
version: formData.get("version"),
changelog: formData.get("changelog"),
downloadType,
filePath: downloadType === "local" ? uploadedFilePath : null,
externalUrl:
downloadType === "url" ? formData.get("externalUrl") : null,
gameVersion: formData.get("gameVersion"),
};
if (downloadType === "local" && !uploadedFilePath) {
toast.error("请先上传文件");
setLoading(false);
return;
}
const res = await fetch("/api/releases", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
toast.success("版本发布成功");
router.push("/admin/releases");
router.refresh();
} else {
const err = await res.json();
toast.error(err.error || "发布失败");
}
setLoading(false);
}
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="addonId"> *</Label>
<Select name="addonId" required>
<SelectTrigger>
<SelectValue placeholder="选择一个插件" />
</SelectTrigger>
<SelectContent>
{addons.map((addon) => (
<SelectItem key={addon.id} value={addon.id}>
{addon.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="version"> *</Label>
<Input
id="version"
name="version"
required
placeholder="1.0.0"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="gameVersion"></Label>
<Input
id="gameVersion"
name="gameVersion"
placeholder="11.1.0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="changelog"></Label>
<Textarea
id="changelog"
name="changelog"
rows={6}
placeholder="- 新增功能 A&#10;- 修复问题 B"
/>
</div>
<div className="space-y-4">
<Label></Label>
<div className="flex gap-4">
<Button
type="button"
variant={downloadType === "local" ? "default" : "outline"}
onClick={() => setDownloadType("local")}
>
</Button>
<Button
type="button"
variant={downloadType === "url" ? "default" : "outline"}
onClick={() => setDownloadType("url")}
>
</Button>
</div>
{downloadType === "local" ? (
<div className="space-y-2">
<div className="flex items-center gap-4">
<Label
htmlFor="file"
className="flex cursor-pointer items-center gap-2 rounded-lg border-2 border-dashed px-6 py-4 transition-colors hover:border-primary"
>
<Upload className="h-5 w-5" />
{uploading
? "上传中..."
: uploadedFilePath
? "重新选择文件"
: "选择文件"}
</Label>
<Input
id="file"
type="file"
className="hidden"
accept=".zip,.rar,.7z,.tar.gz"
onChange={handleFileUpload}
/>
</div>
{uploadedFilePath && (
<p className="text-sm text-muted-foreground">
: {uploadedFilePath}
</p>
)}
</div>
) : (
<div className="space-y-2">
<Label htmlFor="externalUrl"></Label>
<Input
id="externalUrl"
name="externalUrl"
type="url"
placeholder="https://github.com/user/repo/releases/download/v1.0.0/addon.zip"
/>
</div>
)}
</div>
<div className="flex gap-3">
<Button type="submit" disabled={loading}>
{loading ? "发布中..." : "发布版本"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.back()}
>
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { signOut } from "next-auth/react";
import {
LayoutDashboard,
Package,
Upload,
Monitor,
Settings,
LogOut,
ChevronLeft,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/ThemeToggle";
import { cn } from "@/lib/utils";
const navItems = [
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard },
{ href: "/admin/addons", label: "插件管理", icon: Package },
{ href: "/admin/releases", label: "插件版本", icon: Upload },
{ href: "/admin/software", label: "软件管理", icon: Monitor },
{ href: "/admin/settings", label: "系统设置", icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
return (
<div className="flex h-full w-64 flex-col border-r bg-card">
<div className="flex h-16 items-center gap-2 border-b px-6">
<Link href="/" className="text-muted-foreground hover:text-foreground">
<ChevronLeft className="h-4 w-4" />
</Link>
<h1 className="text-lg font-semibold">Nanami Admin</h1>
</div>
<nav className="flex-1 space-y-1 p-4">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/admin" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</nav>
<div className="border-t p-4 space-y-1">
<div className="flex items-center justify-between px-3 py-1">
<span className="text-sm text-muted-foreground"></span>
<ThemeToggle />
</div>
<Button
variant="ghost"
className="w-full justify-start gap-3 text-muted-foreground"
onClick={() => signOut({ callbackUrl: "/admin/login" })}
>
<LogOut className="h-4 w-4" />
退
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,420 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
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 { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { Pencil, Trash2, Upload, X, Check } from "lucide-react";
interface SoftwareVersion {
id: string;
version: string;
versionCode: number;
changelog: string;
downloadType: string;
filePath: string | null;
externalUrl: string | null;
fileSize: number;
downloadCount: number;
isLatest: boolean;
forceUpdate: boolean;
minVersion: string | null;
createdAt: Date;
}
interface SoftwareEditFormProps {
software: {
id: string;
name: string;
slug: string;
description: string;
versions: SoftwareVersion[];
};
}
export function SoftwareEditForm({ software }: SoftwareEditFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const fd = new FormData(e.currentTarget);
const res = await fetch(`/api/software/${software.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: fd.get("name"),
slug: fd.get("slug"),
description: fd.get("description"),
}),
});
if (res.ok) {
toast.success("更新成功");
router.refresh();
} else {
const err = await res.json();
toast.error(err.error || "更新失败");
}
setLoading(false);
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
name="name"
defaultValue={software.name}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
name="slug"
defaultValue={software.slug}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
name="description"
defaultValue={software.description}
rows={3}
/>
</div>
<Button type="submit" disabled={loading}>
{loading ? "保存中..." : "保存"}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{software.versions.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="space-y-3">
{software.versions.map((v) => (
<VersionItem key={v.id} version={v} />
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
function VersionItem({ version: v }: { version: SoftwareVersion }) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [version, setVersion] = useState(v.version);
const [versionCode, setVersionCode] = useState(v.versionCode.toString());
const [changelog, setChangelog] = useState(v.changelog);
const [downloadType, setDownloadType] = useState(v.downloadType);
const [externalUrl, setExternalUrl] = useState(v.externalUrl || "");
const [forceUpdate, setForceUpdate] = useState(v.forceUpdate);
const [minVersion, setMinVersion] = useState(v.minVersion || "");
const [isLatest, setIsLatest] = useState(v.isLatest);
const [filePath, setFilePath] = useState(v.filePath || "");
const [fileSize, setFileSize] = useState(v.fileSize);
const [uploading, setUploading] = useState(false);
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: formData });
if (res.ok) {
const data = await res.json();
setFilePath(data.filePath);
setFileSize(data.size);
toast.success(`文件 ${data.originalName} 上传成功`);
} else {
toast.error("文件上传失败");
}
setUploading(false);
}
async function handleSave() {
setSaving(true);
if (downloadType === "local" && !filePath) {
toast.error("请先上传文件");
setSaving(false);
return;
}
const res = await fetch(`/api/software/versions/${v.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
version,
versionCode: Number(versionCode),
changelog,
downloadType,
filePath: downloadType === "local" ? filePath : null,
externalUrl: downloadType === "url" ? externalUrl : null,
fileSize,
forceUpdate,
minVersion: minVersion || null,
isLatest,
}),
});
if (res.ok) {
toast.success("版本更新成功");
setEditing(false);
router.refresh();
} else {
const err = await res.json();
toast.error(err.error || "更新失败");
}
setSaving(false);
}
async function handleDelete() {
if (!confirm(`确定要删除版本 v${v.version} 吗?此操作不可撤销。`)) return;
setDeleting(true);
const res = await fetch(`/api/software/versions/${v.id}`, {
method: "DELETE",
});
if (res.ok) {
toast.success("版本已删除");
router.refresh();
} else {
toast.error("删除失败");
}
setDeleting(false);
}
if (!editing) {
return (
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-semibold">v{v.version}</span>
<span className="text-xs text-muted-foreground">
(code: {v.versionCode})
</span>
{v.isLatest && <Badge></Badge>}
{v.forceUpdate && (
<Badge variant="destructive"></Badge>
)}
<Badge variant="outline" className="text-xs">
{v.downloadType === "url" ? "外部链接" : "本地文件"}
</Badge>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditing(true)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={handleDelete}
disabled={deleting}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{v.downloadCount} ·{" "}
{new Date(v.createdAt).toLocaleDateString("zh-CN")}
</p>
{v.changelog && (
<p className="mt-2 whitespace-pre-line text-sm text-muted-foreground">
{v.changelog}
</p>
)}
</div>
);
}
return (
<div className="space-y-4 rounded-lg border border-primary/30 bg-muted/30 p-4">
<div className="flex items-center justify-between">
<span className="font-semibold"> v{v.version}</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditing(false)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="grid gap-4 md: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
type="number"
value={versionCode}
onChange={(e) => setVersionCode(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={minVersion}
onChange={(e) => setMinVersion(e.target.value)}
placeholder="可选"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
value={changelog}
onChange={(e) => setChangelog(e.target.value)}
rows={4}
/>
</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={`file-${v.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={`file-${v.id}`}
type="file"
className="hidden"
onChange={handleFileUpload}
/>
{filePath && (
<p className="text-xs text-muted-foreground">
: {filePath} ({(fileSize / 1024).toFixed(1)} KB)
</p>
)}
</div>
) : (
<div className="space-y-2">
<Input
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://..."
/>
</div>
)}
</div>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={forceUpdate}
onChange={(e) => setForceUpdate(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isLatest}
onChange={(e) => setIsLatest(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</label>
</div>
<div className="flex gap-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={() => setEditing(false)}
>
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { Upload } from "lucide-react";
export function SoftwareVersionForm({ softwareId }: { softwareId: string }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [downloadType, setDownloadType] = useState("local");
const [uploadedFilePath, setUploadedFilePath] = useState("");
const [fileSize, setFileSize] = useState(0);
const [uploading, setUploading] = useState(false);
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: formData });
if (res.ok) {
const data = await res.json();
setUploadedFilePath(data.filePath);
setFileSize(data.size);
toast.success(`文件 ${data.originalName} 上传成功`);
} else {
toast.error("文件上传失败");
}
setUploading(false);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const fd = new FormData(e.currentTarget);
if (downloadType === "local" && !uploadedFilePath) {
toast.error("请先上传文件");
setLoading(false);
return;
}
const res = await fetch(`/api/software/${softwareId}/versions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
version: fd.get("version"),
versionCode: Number(fd.get("versionCode")),
changelog: fd.get("changelog"),
downloadType,
filePath: downloadType === "local" ? uploadedFilePath : null,
externalUrl: downloadType === "url" ? fd.get("externalUrl") : null,
fileSize,
forceUpdate: fd.get("forceUpdate") === "on",
minVersion: fd.get("minVersion") || null,
}),
});
if (res.ok) {
toast.success("版本发布成功");
router.push("/admin/software");
router.refresh();
} else {
const err = await res.json();
toast.error(err.error || "发布失败");
}
setLoading(false);
}
return (
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="version"> *</Label>
<Input id="version" name="version" required placeholder="1.0.0" />
</div>
<div className="space-y-2">
<Label htmlFor="versionCode"> () *</Label>
<Input id="versionCode" name="versionCode" type="number" required placeholder="100" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="minVersion"></Label>
<Input id="minVersion" name="minVersion" placeholder="0.9.0(可选,低于此版本需升级)" />
</div>
<div className="space-y-2">
<Label htmlFor="changelog"></Label>
<Textarea id="changelog" name="changelog" rows={6} placeholder="- 新增xxx功能&#10;- 修复xxx问题" />
</div>
<div className="space-y-4">
<Label></Label>
<div className="flex gap-4">
<Button type="button" variant={downloadType === "local" ? "default" : "outline"} onClick={() => setDownloadType("local")}>
</Button>
<Button type="button" variant={downloadType === "url" ? "default" : "outline"} onClick={() => setDownloadType("url")}>
</Button>
</div>
{downloadType === "local" ? (
<div className="space-y-2">
<Label htmlFor="file" className="flex cursor-pointer items-center gap-2 rounded-lg border-2 border-dashed px-6 py-4 transition-colors hover:border-primary">
<Upload className="h-5 w-5" />
{uploading ? "上传中..." : uploadedFilePath ? "重新选择文件" : "选择文件"}
</Label>
<Input id="file" type="file" className="hidden" onChange={handleFileUpload} />
{uploadedFilePath && (
<p className="text-sm text-muted-foreground">: {uploadedFilePath} ({(fileSize / 1024).toFixed(1)} KB)</p>
)}
</div>
) : (
<div className="space-y-2">
<Label htmlFor="externalUrl"></Label>
<Input id="externalUrl" name="externalUrl" type="url" placeholder="https://..." />
</div>
)}
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="forceUpdate" name="forceUpdate" className="h-4 w-4 rounded border-input" />
<Label htmlFor="forceUpdate">使</Label>
</div>
<div className="flex gap-3">
<Button type="submit" disabled={loading}>
{loading ? "发布中..." : "发布版本"}
</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,411 @@
"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";
interface Version {
id: string;
version: string;
versionCode: number;
changelog: string;
downloadType: string;
filePath: string | null;
externalUrl: string | null;
fileSize: number;
downloadCount: number;
isLatest: boolean;
forceUpdate: boolean;
minVersion: string | null;
createdAt: string;
}
export function SoftwareVersionTable({
versions: initialVersions,
}: {
versions: Version[];
}) {
const router = useRouter();
const [editingId, setEditingId] = useState<string | null>(null);
async function handleSetLatest(versionId: string) {
const res = await fetch(`/api/software/versions/${versionId}`, {
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(v: Version) {
if (!confirm(`确定要删除版本 v${v.version} 吗?此操作不可撤销。`)) return;
const res = await fetch(`/api/software/versions/${v.id}`, {
method: "DELETE",
});
if (res.ok) {
toast.success("版本已删除");
router.refresh();
} else {
toast.error("删除失败");
}
}
if (initialVersions.length === 0) {
return (
<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>
<TableRow>
<TableCell
colSpan={8}
className="text-center text-muted-foreground"
>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
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>
{initialVersions.map((v) => (
<TableRow key={v.id}>
<TableCell className="font-medium">v{v.version}</TableCell>
<TableCell className="text-muted-foreground">
{v.versionCode}
</TableCell>
<TableCell>
<Badge variant="secondary">
{v.downloadType === "local" ? "本地文件" : "外部链接"}
</Badge>
</TableCell>
<TableCell>
<span className="flex items-center gap-1">
<Download className="h-3.5 w-3.5 text-muted-foreground" />
{v.downloadCount}
</span>
</TableCell>
<TableCell>
{v.forceUpdate ? (
<Badge variant="destructive"></Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(v.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
<TableCell>
{v.isLatest && <Badge></Badge>}
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
{!v.isLatest && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="设为当前下载版本"
onClick={() => handleSetLatest(v.id)}
>
<Star className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="编辑"
onClick={() =>
setEditingId(editingId === v.id ? null : v.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(v)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{editingId && (
<VersionEditPanel
version={initialVersions.find((v) => v.id === editingId)!}
onClose={() => setEditingId(null)}
/>
)}
</div>
);
}
function VersionEditPanel({
version: v,
onClose,
}: {
version: Version;
onClose: () => void;
}) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [version, setVersion] = useState(v.version);
const [versionCode, setVersionCode] = useState(v.versionCode.toString());
const [changelog, setChangelog] = useState(v.changelog);
const [downloadType, setDownloadType] = useState(v.downloadType);
const [externalUrl, setExternalUrl] = useState(v.externalUrl || "");
const [forceUpdate, setForceUpdate] = useState(v.forceUpdate);
const [minVersion, setMinVersion] = useState(v.minVersion || "");
const [filePath, setFilePath] = useState(v.filePath || "");
const [fileSize, setFileSize] = useState(v.fileSize);
const [uploading, setUploading] = useState(false);
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: formData });
if (res.ok) {
const data = await res.json();
setFilePath(data.filePath);
setFileSize(data.size);
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/software/versions/${v.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
version,
versionCode: Number(versionCode),
changelog,
downloadType,
filePath: downloadType === "local" ? filePath : null,
externalUrl: downloadType === "url" ? externalUrl : null,
fileSize,
forceUpdate,
minVersion: minVersion || null,
}),
});
if (res.ok) {
toast.success("版本更新成功");
onClose();
router.refresh();
} 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"> v{v.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
type="number"
value={versionCode}
onChange={(e) => setVersionCode(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={minVersion}
onChange={(e) => setMinVersion(e.target.value)}
placeholder="可选"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
value={changelog}
onChange={(e) => setChangelog(e.target.value)}
rows={4}
/>
</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-file-${v.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-file-${v.id}`}
type="file"
className="hidden"
onChange={handleFileUpload}
/>
{filePath && (
<p className="text-xs text-muted-foreground">
: {filePath} ({(fileSize / 1024).toFixed(1)} KB)
</p>
)}
</div>
) : (
<div className="space-y-2">
<Input
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://..."
/>
{externalUrl && (
<p className="truncate text-xs text-muted-foreground">
: {externalUrl}
</p>
)}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={forceUpdate}
onChange={(e) => setForceUpdate(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</label>
<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

@@ -0,0 +1,70 @@
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Download, Package } from "lucide-react";
interface AddonCardProps {
addon: {
slug: string;
name: string;
summary: string;
iconUrl: string | null;
category: string;
totalDownloads: number;
releases: { version: string }[];
};
}
export function AddonCard({ addon }: AddonCardProps) {
const latestVersion = addon.releases?.[0]?.version;
return (
<Link href={`/addons/${addon.slug}`}>
<Card className="h-full transition-shadow hover:shadow-lg">
<CardHeader>
<div className="flex items-start gap-3">
{addon.iconUrl ? (
<img
src={addon.iconUrl}
alt={addon.name}
className="h-12 w-12 rounded-lg object-cover"
/>
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-6 w-6 text-primary" />
</div>
)}
<div className="flex-1">
<CardTitle className="text-lg">{addon.name}</CardTitle>
<div className="mt-1 flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{addon.category}
</Badge>
{latestVersion && (
<span className="text-xs text-muted-foreground">
v{latestVersion}
</span>
)}
</div>
</div>
</div>
</CardHeader>
<CardContent>
<CardDescription className="line-clamp-2">
{addon.summary}
</CardDescription>
<div className="mt-3 flex items-center gap-1 text-sm text-muted-foreground">
<Download className="h-3.5 w-3.5" />
<span>{addon.totalDownloads.toLocaleString()} </span>
</div>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { Button } from "@/components/ui/button";
import { Download } from "lucide-react";
export function DownloadButton({
releaseId,
version,
size = "default",
}: {
releaseId: string;
version: string;
size?: "default" | "sm" | "lg";
}) {
function handleDownload() {
window.open(`/api/download/${releaseId}`, "_blank");
}
return (
<Button size={size} onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
v{version}
</Button>
);
}

View File

@@ -0,0 +1,16 @@
export function Footer() {
return (
<footer className="border-t border-amber-900/20 bg-[#0a0912]">
<div className="mx-auto max-w-6xl px-4 py-8">
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
<p className="text-sm text-gray-500">
&copy; {new Date().getFullYear()} Nanami
</p>
<p className="text-sm text-gray-500">
Turtle WoW
</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,253 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { Download } from "lucide-react";
interface Particle {
x: number;
y: number;
z: number;
vx: number;
vy: number;
baseSize: number;
color: string;
twinkleSpeed: number;
twinklePhase: number;
}
const slides = [
{ image: "/banners/banner_2.png" },
{ image: "/banners/banner_1.png" },
{ image: "/banners/banner_3.png" },
];
const PARTICLE_COUNT = 50;
const AUTO_PLAY_MS = 6000;
export function HeroBanner({
totalDownloads,
launcherVersion,
}: {
totalDownloads?: number;
launcherVersion?: string | null;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const particlesRef = useRef<Particle[]>([]);
const rafRef = useRef(0);
const [active, setActive] = useState(0);
const createParticles = useCallback((w: number, h: number) => {
const palettes = [
"rgba(255,215,0,",
"rgba(255,191,0,",
"rgba(218,165,32,",
"rgba(255,255,220,",
"rgba(200,160,255,",
"rgba(120,200,255,",
];
const out: Particle[] = [];
for (let i = 0; i < PARTICLE_COUNT; i++) {
const z = Math.random();
out.push({
x: Math.random() * w,
y: Math.random() * h,
z,
vx: (Math.random() - 0.5) * 0.3,
vy: -(Math.random() * 0.3 + 0.05),
baseSize: z * 2 + 0.6,
color: palettes[Math.floor(Math.random() * palettes.length)],
twinkleSpeed: Math.random() * 0.025 + 0.006,
twinklePhase: Math.random() * Math.PI * 2,
});
}
return out;
}, []);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d")!;
let w = 0;
let h = 0;
const resize = () => {
const dpr = window.devicePixelRatio || 1;
const rect = container.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);
if (particlesRef.current.length === 0) {
particlesRef.current = createParticles(w, h);
}
};
resize();
window.addEventListener("resize", resize);
let tick = 0;
const loop = () => {
ctx.clearRect(0, 0, w, h);
tick++;
for (const p of particlesRef.current) {
p.x += p.vx;
p.y += p.vy;
if (p.y < -20) {
p.y = h + 20;
p.x = Math.random() * w;
}
if (p.x < -20) p.x = w + 20;
if (p.x > w + 20) p.x = -20;
const twinkle =
Math.sin(tick * p.twinkleSpeed + p.twinklePhase) * 0.5 + 0.5;
const alpha = (p.z * 0.45 + 0.1) * twinkle;
const glowR = p.baseSize * 5;
const grad = ctx.createRadialGradient(
p.x,
p.y,
0,
p.x,
p.y,
glowR
);
grad.addColorStop(0, p.color + (alpha * 0.4).toFixed(3) + ")");
grad.addColorStop(1, p.color + "0)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(p.x, p.y, glowR, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = p.color + alpha.toFixed(3) + ")";
ctx.beginPath();
ctx.arc(p.x, p.y, p.baseSize, 0, Math.PI * 2);
ctx.fill();
}
rafRef.current = requestAnimationFrame(loop);
};
loop();
return () => {
window.removeEventListener("resize", resize);
cancelAnimationFrame(rafRef.current);
};
}, [createParticles]);
useEffect(() => {
const id = setInterval(() => {
setActive((prev) => (prev + 1) % slides.length);
}, AUTO_PLAY_MS);
return () => clearInterval(id);
}, []);
const goTo = (i: number) => setActive(i);
const handleDownload = async () => {
try {
const res = await fetch("/api/software/latest?info=1");
const data = await res.json();
if (data.available && data.downloadUrl) {
window.location.href = data.downloadUrl;
} else {
window.location.href = "/api/software/latest";
}
} catch {
window.location.href = "/api/software/latest";
}
};
return (
<div className="hero-aurora-wrapper">
<div className="hero-aurora hero-aurora--top" />
<section
ref={containerRef}
className="relative overflow-hidden bg-[#0a0912]"
>
{/* Slide images */}
{slides.map((s, i) => (
<div
key={i}
className="absolute inset-0 transition-opacity duration-[1200ms] ease-in-out"
style={{ opacity: i === active ? 1 : 0 }}
>
<img
src={s.image}
alt=""
draggable={false}
className="h-full w-full object-cover"
/>
</div>
))}
{/* Spacer to maintain 21:9 aspect ratio */}
<div className="pointer-events-none w-full" style={{ paddingBottom: "min(42.86%, 720px)" }} />
{/* Bottom gradient for button readability */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-[#0a0912] via-[#0a0912]/60 to-transparent" />
{/* Particle canvas */}
<canvas
ref={canvasRef}
className="pointer-events-none absolute inset-0 z-10"
/>
{/* Bottom content */}
<div className="absolute inset-x-0 bottom-0 z-20 flex flex-col items-center pb-6 sm:pb-8">
<button
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]"
>
<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" />
<span className="relative flex items-center gap-2.5 text-amber-100">
<Download className="h-5 w-5" />
<span className="text-base font-semibold tracking-wide">
Nanami
</span>
{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">
v{launcherVersion}
</span>
)}
</span>
</button>
{totalDownloads != null && totalDownloads > 0 && (
<p className="mt-2.5 text-sm text-white/40">
{" "}
<span className="font-semibold text-amber-300/70">
{totalDownloads.toLocaleString()}
</span>{" "}
</p>
)}
<div className="mt-3 flex gap-2.5">
{slides.map((_, i) => (
<button
key={i}
aria-label={`前往第 ${i + 1}`}
onClick={() => goTo(i)}
className={`cursor-pointer h-1.5 rounded-full transition-all duration-500 ${
i === active
? "w-8 bg-amber-400"
: "w-1.5 bg-white/30 hover:bg-white/60"
}`}
/>
))}
</div>
</div>
</section>
<div className="hero-aurora hero-aurora--bottom" />
</div>
);
}

View File

@@ -0,0 +1,29 @@
import Link from "next/link";
import { Package } from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle";
export function Navbar() {
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">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4">
<Link
href="/"
className="flex items-center gap-2 text-lg font-bold text-amber-100"
>
<Package className="h-5 w-5 text-amber-400" />
<span>Nanami</span>
</Link>
<nav className="flex items-center gap-4">
<Link
href="/addons"
className="text-sm text-gray-400 transition-colors hover:text-amber-200"
>
</Link>
<ThemeToggle />
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@@ -46,12 +46,16 @@ function Button({
className,
variant = "default",
size = "default",
nativeButton,
render,
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
nativeButton={render ? (nativeButton ?? false) : nativeButton}
render={render}
{...props}
/>
)

103
src/components/ui/card.tsx Normal file
View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

135
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

116
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

BIN
src/img/banner_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

BIN
src/img/banner_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

BIN
src/img/banner_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

34
src/lib/auth.config.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
export const authConfig: NextAuthConfig = {
providers: [
Credentials({
name: "credentials",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
// authorize is handled in the full auth.ts
authorize: () => null,
}),
],
pages: {
signIn: "/admin/login",
},
session: {
strategy: "jwt",
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
const isOnLogin = nextUrl.pathname === "/admin/login";
if (isOnAdmin && !isOnLogin) {
return isLoggedIn;
}
return true;
},
},
};

36
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,36 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { prisma } from "./db";
import { authConfig } from "./auth.config";
export const { handlers, signIn, signOut, auth } = NextAuth({
...authConfig,
providers: [
Credentials({
name: "credentials",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.username || !credentials?.password) return null;
const admin = await prisma.admin.findUnique({
where: { username: credentials.username as string },
});
if (!admin) return null;
const isValid = await bcrypt.compare(
credentials.password as string,
admin.passwordHash
);
if (!isValid) return null;
return { id: admin.id, name: admin.username };
},
}),
],
});

15
src/lib/db.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
function createPrismaClient() {
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

28
src/middleware.ts Normal file
View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";
export async function middleware(request: NextRequest) {
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
});
const isOnLogin = request.nextUrl.pathname === "/admin/login";
if (!token && !isOnLogin) {
const loginUrl = new URL("/admin/login", request.url);
return NextResponse.redirect(loginUrl);
}
if (token && isOnLogin) {
const adminUrl = new URL("/admin", request.url);
return NextResponse.redirect(adminUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/admin/:path*"],
};

42
src/types/index.ts Normal file
View File

@@ -0,0 +1,42 @@
export interface Addon {
id: string;
name: string;
slug: string;
summary: string;
description: string;
iconUrl: string | null;
category: string;
published: boolean;
totalDownloads: number;
createdAt: Date;
updatedAt: Date;
releases?: Release[];
screenshots?: Screenshot[];
}
export interface Release {
id: string;
addonId: string;
version: string;
changelog: string;
downloadType: "local" | "url";
filePath: string | null;
externalUrl: string | null;
gameVersion: string;
downloadCount: number;
isLatest: boolean;
createdAt: Date;
addon?: Addon;
}
export interface Screenshot {
id: string;
addonId: string;
imageUrl: string;
sortOrder: number;
}
export interface AdminUser {
id: string;
username: string;
}