官网 初版
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env*
|
||||
!.env.example
|
||||
uploads
|
||||
5
.env.example
Normal 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
@@ -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
@@ -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"]
|
||||
84
README.md
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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
22
package.json
@@ -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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 668 KiB |
BIN
public/banners/banner_2.png
Normal file
|
After Width: | Height: | Size: 496 KiB |
BIN
public/banners/banner_3.png
Normal file
|
After Width: | Height: | Size: 533 KiB |
209
src/app/(public)/addons/[slug]/page.tsx
Normal 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 }} />;
|
||||
}
|
||||
101
src/app/(public)/addons/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/(public)/layout.tsx
Normal 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
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
src/app/admin/(dashboard)/addons/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/admin/(dashboard)/addons/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/app/admin/(dashboard)/addons/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/app/admin/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
src/app/admin/(dashboard)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/app/admin/(dashboard)/releases/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/app/admin/(dashboard)/releases/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/app/admin/(dashboard)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/app/admin/(dashboard)/software/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
84
src/app/admin/(dashboard)/software/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/app/admin/(dashboard)/software/page.tsx
Normal 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
@@ -0,0 +1,11 @@
|
||||
export const metadata = {
|
||||
title: "管理后台 - Nanami",
|
||||
};
|
||||
|
||||
export default function AdminRootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
7
src/app/admin/login/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function LoginLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
84
src/app/admin/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/app/api/addons/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
72
src/app/api/addons/route.ts
Normal 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 });
|
||||
}
|
||||
48
src/app/api/admin/change-password/route.ts
Normal 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 });
|
||||
}
|
||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
60
src/app/api/download/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
76
src/app/api/releases/route.ts
Normal 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 });
|
||||
}
|
||||
61
src/app/api/software/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
67
src/app/api/software/[id]/versions/route.ts
Normal 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 });
|
||||
}
|
||||
57
src/app/api/software/check-update/route.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
58
src/app/api/software/download/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
85
src/app/api/software/latest/route.ts
Normal 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 });
|
||||
}
|
||||
47
src/app/api/software/route.ts
Normal 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 });
|
||||
}
|
||||
71
src/app/api/software/versions/[versionId]/route.ts
Normal 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 });
|
||||
}
|
||||
37
src/app/api/upload/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 9.4 KiB |
@@ -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%;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
16
src/components/ThemeProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
src/components/admin/AddonForm.tsx
Normal 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="# 插件名称 详细描述..."
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
73
src/components/admin/DeleteAddonButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
src/components/admin/ReleaseForm.tsx
Normal 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 - 修复问题 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>
|
||||
);
|
||||
}
|
||||
78
src/components/admin/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
420
src/components/admin/SoftwareEditForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
src/components/admin/SoftwareVersionForm.tsx
Normal 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功能 - 修复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>
|
||||
);
|
||||
}
|
||||
411
src/components/admin/SoftwareVersionTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/components/public/AddonCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/components/public/DownloadButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/components/public/Footer.tsx
Normal 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">
|
||||
© {new Date().getFullYear()} Nanami 版权所有
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Turtle WoW 插件平台
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
253
src/components/public/HeroBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/public/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/components/ui/avatar.tsx
Normal 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,
|
||||
}
|
||||
52
src/components/ui/badge.tsx
Normal 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 }
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
157
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
268
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
20
src/components/ui/input.tsx
Normal 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 }
|
||||
20
src/components/ui/label.tsx
Normal 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 }
|
||||
201
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
25
src/components/ui/separator.tsx
Normal 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
@@ -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,
|
||||
}
|
||||
49
src/components/ui/sonner.tsx
Normal 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
@@ -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,
|
||||
}
|
||||
82
src/components/ui/tabs.tsx
Normal 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 }
|
||||
18
src/components/ui/textarea.tsx
Normal 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
|
After Width: | Height: | Size: 668 KiB |
BIN
src/img/banner_2.png
Normal file
|
After Width: | Height: | Size: 496 KiB |
BIN
src/img/banner_3.png
Normal file
|
After Width: | Height: | Size: 533 KiB |
34
src/lib/auth.config.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||