init 仓库
This commit is contained in:
commit
ac081dd134
16
.env.example
Normal file
16
.env.example
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
NEXTAUTH_SECRET="your-secret-key"
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
ADMIN_PASSWORD="your-admin-password"
|
||||||
|
|
||||||
|
# File Upload
|
||||||
|
MAX_FILE_SIZE=10485760
|
||||||
|
ALLOWED_FILE_TYPES="image/jpeg,image/png,image/gif,video/mp4,video/webm"
|
||||||
|
|
||||||
|
# Socket.io
|
||||||
|
SOCKET_PORT=3001
|
147
.gitignore
vendored
Normal file
147
.gitignore
vendored
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
.env.test
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
public
|
||||||
|
|
||||||
|
# Storybook build outputs
|
||||||
|
.out
|
||||||
|
.storybook-out
|
||||||
|
|
||||||
|
# Temporary folders
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
public/uploads/*
|
||||||
|
!public/uploads/.gitkeep
|
349
README.md
Normal file
349
README.md
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
# My Live Photos - 实时文件共享系统
|
||||||
|
|
||||||
|
一个轻量级的实时文件共享系统,支持图片和视频文件的上传与实时分享。
|
||||||
|
|
||||||
|
## 🚀 功能特性
|
||||||
|
|
||||||
|
### 核心功能
|
||||||
|
- **实时文件上传**: 支持图片和视频文件的拖拽上传和点击上传
|
||||||
|
- **WebSocket 实时推送**: 文件上传后立即推送给所有在线用户
|
||||||
|
- **响应式现代UI设计**: 基于Tailwind CSS的现代化界面,支持移动端和桌面端
|
||||||
|
- **管理员面板**: 提供文件管理功能,包括文件状态控制
|
||||||
|
- **SQLite 数据存储**: 轻量级数据库存储文件元数据
|
||||||
|
- **文件隐藏/显示控制**: 管理员可以控制文件的可见性
|
||||||
|
- **文件删除功能**: 支持安全删除文件及其记录
|
||||||
|
|
||||||
|
### 技术亮点
|
||||||
|
- 实时双向通信,低延迟
|
||||||
|
- 文件上传进度显示
|
||||||
|
- 图片预览和视频播放
|
||||||
|
- 文件分类和筛选
|
||||||
|
- 响应式网格布局
|
||||||
|
- 暗色/亮色主题切换
|
||||||
|
|
||||||
|
## 🛠 技术栈
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- **Next.js 14+**: React全栈框架,支持SSR和静态生成
|
||||||
|
- **TypeScript**: 类型安全的JavaScript
|
||||||
|
- **Tailwind CSS**: 实用优先的CSS框架
|
||||||
|
- **Socket.io Client**: 实时通信客户端
|
||||||
|
- **React Hook Form**: 高性能表单处理
|
||||||
|
- **Lucide React**: 现代化图标库
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- **Next.js API Routes**: 内置API路由
|
||||||
|
- **Socket.io Server**: WebSocket服务器
|
||||||
|
- **SQLite**: 轻量级关系型数据库
|
||||||
|
- **Prisma**: 数据库ORM和迁移工具
|
||||||
|
- **Multer**: 文件上传中间件
|
||||||
|
- **Sharp**: 图片处理库
|
||||||
|
|
||||||
|
### 开发工具
|
||||||
|
- **ESLint**: 代码质量检查
|
||||||
|
- **Prettier**: 代码格式化
|
||||||
|
- **Husky**: Git hooks管理
|
||||||
|
- **Commitlint**: 提交信息规范
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
my-live-photos/
|
||||||
|
├── README.md
|
||||||
|
├── package.json
|
||||||
|
├── next.config.js
|
||||||
|
├── tailwind.config.js
|
||||||
|
├── tsconfig.json
|
||||||
|
├── .env.local
|
||||||
|
├── .env.example
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma
|
||||||
|
│ ├── migrations/
|
||||||
|
│ └── seed.ts
|
||||||
|
├── public/
|
||||||
|
│ ├── uploads/
|
||||||
|
│ │ ├── images/
|
||||||
|
│ │ └── videos/
|
||||||
|
│ ├── favicon.ico
|
||||||
|
│ └── robots.txt
|
||||||
|
├── src/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── globals.css
|
||||||
|
│ │ ├── layout.tsx
|
||||||
|
│ │ ├── page.tsx
|
||||||
|
│ │ └── admin/
|
||||||
|
│ │ ├── layout.tsx
|
||||||
|
│ │ └── page.tsx
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── ui/
|
||||||
|
│ │ │ ├── button.tsx
|
||||||
|
│ │ │ ├── card.tsx
|
||||||
|
│ │ │ ├── dialog.tsx
|
||||||
|
│ │ │ ├── dropdown-menu.tsx
|
||||||
|
│ │ │ ├── input.tsx
|
||||||
|
│ │ │ ├── toast.tsx
|
||||||
|
│ │ │ └── tooltip.tsx
|
||||||
|
│ │ ├── FileCard.tsx
|
||||||
|
│ │ ├── FileGrid.tsx
|
||||||
|
│ │ ├── FileUpload.tsx
|
||||||
|
│ │ ├── Header.tsx
|
||||||
|
│ │ ├── AdminPanel.tsx
|
||||||
|
│ │ ├── ThemeToggle.tsx
|
||||||
|
│ │ └── SocketProvider.tsx
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── db.ts
|
||||||
|
│ │ ├── socket.ts
|
||||||
|
│ │ ├── utils.ts
|
||||||
|
│ │ └── validations.ts
|
||||||
|
│ ├── types/
|
||||||
|
│ │ ├── file.ts
|
||||||
|
│ │ └── api.ts
|
||||||
|
│ ├── hooks/
|
||||||
|
│ │ ├── useFiles.ts
|
||||||
|
│ │ ├── useUpload.ts
|
||||||
|
│ │ └── useTheme.ts
|
||||||
|
│ └── api/
|
||||||
|
│ ├── files/
|
||||||
|
│ │ ├── route.ts
|
||||||
|
│ │ └── [id]/route.ts
|
||||||
|
│ └── upload/
|
||||||
|
│ └── route.ts
|
||||||
|
└── .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🗄 数据库设计
|
||||||
|
|
||||||
|
### 文件表 (files)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE files (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
original_name VARCHAR(255) NOT NULL,
|
||||||
|
file_type VARCHAR(50) NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
file_path VARCHAR(500) NOT NULL,
|
||||||
|
mime_type VARCHAR(100) NOT NULL,
|
||||||
|
is_visible BOOLEAN DEFAULT true,
|
||||||
|
is_deleted BOOLEAN DEFAULT false,
|
||||||
|
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 索引设计
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_files_type ON files(file_type);
|
||||||
|
CREATE INDEX idx_files_visible ON files(is_visible);
|
||||||
|
CREATE INDEX idx_files_uploaded_at ON files(uploaded_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔌 API 设计
|
||||||
|
|
||||||
|
### 文件上传 API
|
||||||
|
```
|
||||||
|
POST /api/upload
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
- file: 文件对象
|
||||||
|
- metadata: JSON字符串(可选)
|
||||||
|
|
||||||
|
响应:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"file": {
|
||||||
|
"id": 1,
|
||||||
|
"filename": "uuid_filename.jpg",
|
||||||
|
"originalName": "photo.jpg",
|
||||||
|
"fileType": "image",
|
||||||
|
"fileSize": 1024000,
|
||||||
|
"filePath": "/uploads/images/uuid_filename.jpg",
|
||||||
|
"mimeType": "image/jpeg",
|
||||||
|
"isVisible": true,
|
||||||
|
"uploadedAt": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取文件列表 API
|
||||||
|
```
|
||||||
|
GET /api/files?type=image&page=1&limit=20
|
||||||
|
|
||||||
|
响应:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"files": [...],
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"total": 100,
|
||||||
|
"totalPages": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件管理 API
|
||||||
|
```
|
||||||
|
PUT /api/files/[id]
|
||||||
|
{
|
||||||
|
"isVisible": false
|
||||||
|
}
|
||||||
|
|
||||||
|
DELETE /api/files/[id]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📡 WebSocket 事件
|
||||||
|
|
||||||
|
### 客户端事件
|
||||||
|
- `file:upload`: 文件上传
|
||||||
|
- `file:delete`: 文件删除
|
||||||
|
- `file:toggle:visibility`: 切换文件可见性
|
||||||
|
|
||||||
|
### 服务器事件
|
||||||
|
- `file:uploaded`: 新文件上传通知
|
||||||
|
- `file:deleted`: 文件删除通知
|
||||||
|
- `file:visibility:changed`: 文件可见性变更通知
|
||||||
|
- `user:connected`: 用户连接通知
|
||||||
|
- `user:disconnected`: 用户断开连接通知
|
||||||
|
|
||||||
|
## 🎨 UI/UX 设计
|
||||||
|
|
||||||
|
### 颜色主题
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--primary-dark: #2563eb;
|
||||||
|
--secondary: #64748b;
|
||||||
|
--background: #ffffff;
|
||||||
|
--surface: #f8fafc;
|
||||||
|
--text: #1e293b;
|
||||||
|
--text-secondary: #64748b;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--success: #10b981;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--error: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: #0f172a;
|
||||||
|
--surface: #1e293b;
|
||||||
|
--text: #f1f5f9;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--border: #334155;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 组件设计
|
||||||
|
1. **文件卡片**: 显示文件预览、基本信息和操作按钮
|
||||||
|
2. **上传区域**: 拖拽上传区域,支持点击选择文件
|
||||||
|
3. **文件网格**: 响应式网格布局,自适应不同屏幕尺寸
|
||||||
|
4. **管理面板**: 文件列表管理界面,支持批量操作
|
||||||
|
5. **实时通知**: Toast通知系统,显示实时更新
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
- Node.js 18+
|
||||||
|
- npm 或 yarn
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境配置
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
# 编辑 .env.local 文件,配置数据库连接等
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库初始化
|
||||||
|
```bash
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma db push
|
||||||
|
npx prisma db seed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动开发服务器
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 http://localhost:3000 查看应用
|
||||||
|
|
||||||
|
### 构建生产版本
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 开发指南
|
||||||
|
|
||||||
|
### 添加新功能
|
||||||
|
1. 在 `src/types/` 中定义类型
|
||||||
|
2. 在 `src/lib/validations.ts` 中添加验证规则
|
||||||
|
3. 在 `src/api/` 中创建API路由
|
||||||
|
4. 在 `src/components/` 中创建UI组件
|
||||||
|
5. 在 `src/hooks/` 中创建自定义Hook
|
||||||
|
|
||||||
|
### 代码规范
|
||||||
|
- 使用 TypeScript 严格模式
|
||||||
|
- 遵循 ESLint 和 Prettier 规则
|
||||||
|
- 使用 Conventional Commits 提交规范
|
||||||
|
- 编写组件和函数的 JSDoc 注释
|
||||||
|
|
||||||
|
### 测试策略
|
||||||
|
- 单元测试: Jest + React Testing Library
|
||||||
|
- 集成测试: Playwright
|
||||||
|
- API测试: Supertest
|
||||||
|
|
||||||
|
## 📈 性能优化
|
||||||
|
|
||||||
|
### 前端优化
|
||||||
|
- 图片懒加载和压缩
|
||||||
|
- 虚拟滚动处理大量文件
|
||||||
|
- 代码分割和懒加载
|
||||||
|
- 缓存策略优化
|
||||||
|
|
||||||
|
### 后端优化
|
||||||
|
- 文件上传大小限制和类型验证
|
||||||
|
- 数据库查询优化和索引
|
||||||
|
- WebSocket连接管理
|
||||||
|
- CDN集成静态资源
|
||||||
|
|
||||||
|
## 🔒 安全考虑
|
||||||
|
|
||||||
|
### 文件上传安全
|
||||||
|
- 文件类型白名单验证
|
||||||
|
- 文件大小限制
|
||||||
|
- 文件名消毒处理
|
||||||
|
- 病毒扫描集成
|
||||||
|
|
||||||
|
### 数据安全
|
||||||
|
- SQL注入防护
|
||||||
|
- XSS攻击防护
|
||||||
|
- CSRF保护
|
||||||
|
- 认证和授权
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## 🤝 贡献指南
|
||||||
|
|
||||||
|
1. Fork 项目
|
||||||
|
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||||
|
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||||
|
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||||
|
5. 创建 Pull Request
|
||||||
|
|
||||||
|
## 📞 联系方式
|
||||||
|
|
||||||
|
如有问题或建议,请通过以下方式联系:
|
||||||
|
- 创建 Issue
|
||||||
|
- 发送邮件
|
||||||
|
- 提交 Pull Request
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**My Live Photos** - 让分享更简单,让回忆更鲜活。
|
208
STARTUP.md
Normal file
208
STARTUP.md
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
# 启动指南
|
||||||
|
|
||||||
|
## 快速启动
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 数据库初始化
|
||||||
|
```bash
|
||||||
|
# 生成 Prisma 客户端
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# 推送数据库 schema
|
||||||
|
DATABASE_URL="file:./dev.db" npx prisma db push
|
||||||
|
|
||||||
|
# 填充示例数据
|
||||||
|
DATABASE_URL="file:./dev.db" npx prisma db seed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 构建应用
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 启动应用
|
||||||
|
```bash
|
||||||
|
# 启动 Next.js 应用和 Socket.IO 服务器
|
||||||
|
node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 访问应用
|
||||||
|
- 主页: http://localhost:3000
|
||||||
|
- 管理面板: http://localhost:3000/admin
|
||||||
|
|
||||||
|
## 开发模式
|
||||||
|
|
||||||
|
### 启动开发服务器
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 单独启动 Socket.IO 服务器(如果需要)
|
||||||
|
```bash
|
||||||
|
node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 可用脚本
|
||||||
|
|
||||||
|
- `npm run dev` - 启动开发服务器
|
||||||
|
- `npm run build` - 构建生产版本
|
||||||
|
- `npm run start` - 启动生产服务器
|
||||||
|
- `npm run lint` - 运行 ESLint
|
||||||
|
- `npm run type-check` - 运行 TypeScript 类型检查
|
||||||
|
|
||||||
|
## 数据库管理
|
||||||
|
|
||||||
|
### 查看数据库
|
||||||
|
```bash
|
||||||
|
npx prisma studio
|
||||||
|
```
|
||||||
|
|
||||||
|
### 重置数据库
|
||||||
|
```bash
|
||||||
|
DATABASE_URL="file:./dev.db" npx prisma db push --force-reset
|
||||||
|
DATABASE_URL="file:./dev.db" npx prisma db seed
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
确保 `.env.local` 文件包含以下配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database
|
||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
NEXTAUTH_SECRET="my-live-photos-secret-key-123"
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# File Upload
|
||||||
|
MAX_FILE_SIZE=10485760
|
||||||
|
ALLOWED_FILE_TYPES="image/jpeg,image/png,image/gif,video/mp4,video/webm"
|
||||||
|
|
||||||
|
# Socket.io
|
||||||
|
SOCKET_PORT=3001
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 核心功能
|
||||||
|
- ✅ 实时文件上传(图片/视频)
|
||||||
|
- ✅ WebSocket 实时推送更新
|
||||||
|
- ✅ 响应式现代UI设计
|
||||||
|
- ✅ 管理员面板(文件管理)
|
||||||
|
- ✅ SQLite 数据存储
|
||||||
|
- ✅ 文件隐藏/显示控制
|
||||||
|
- ✅ 文件删除功能
|
||||||
|
|
||||||
|
### 技术特性
|
||||||
|
- ✅ 文件拖拽上传
|
||||||
|
- ✅ 上传进度显示
|
||||||
|
- ✅ 图片预览和视频播放
|
||||||
|
- ✅ 暗色/亮色主题切换
|
||||||
|
- ✅ 实时在线用户计数
|
||||||
|
- ✅ 文件搜索和筛选
|
||||||
|
- ✅ 响应式网格布局
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
1. **数据库连接错误**
|
||||||
|
```bash
|
||||||
|
# 确保 DATABASE_URL 环境变量设置正确
|
||||||
|
export DATABASE_URL="file:./dev.db"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Socket.IO 连接失败**
|
||||||
|
```bash
|
||||||
|
# 确保 Socket.IO 服务器正在运行
|
||||||
|
node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **文件上传失败**
|
||||||
|
- 检查文件大小(最大 10MB)
|
||||||
|
- 检查文件类型(支持 JPG, PNG, GIF, MP4, WebM)
|
||||||
|
- 确保 uploads 目录存在且有写权限
|
||||||
|
|
||||||
|
4. **构建失败**
|
||||||
|
```bash
|
||||||
|
# 清理并重新安装依赖
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日志查看
|
||||||
|
|
||||||
|
- Next.js 日志:控制台输出
|
||||||
|
- Socket.IO 日志:控制台输出
|
||||||
|
- 数据库日志:Prisma Studio
|
||||||
|
|
||||||
|
## 生产部署
|
||||||
|
|
||||||
|
### 环境准备
|
||||||
|
1. 设置生产环境变量
|
||||||
|
2. 配置数据库(使用 PostgreSQL 或 MySQL)
|
||||||
|
3. 设置文件存储(使用 S3 或其他对象存储)
|
||||||
|
|
||||||
|
### 构建和启动
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 PM2 管理进程
|
||||||
|
```bash
|
||||||
|
# 安装 PM2
|
||||||
|
npm install -g pm2
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
pm2 start server.js --name "my-live-photos"
|
||||||
|
|
||||||
|
# 查看状态
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
pm2 logs my-live-photos
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 文档
|
||||||
|
|
||||||
|
### 文件上传
|
||||||
|
```
|
||||||
|
POST /api/upload
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取文件列表
|
||||||
|
```
|
||||||
|
GET /api/files?type=image&page=1&limit=20
|
||||||
|
```
|
||||||
|
|
||||||
|
### 更新文件
|
||||||
|
```
|
||||||
|
PUT /api/files/[id]
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除文件
|
||||||
|
```
|
||||||
|
DELETE /api/files/[id]
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebSocket 事件
|
||||||
|
|
||||||
|
### 客户端发送
|
||||||
|
- `file:upload` - 文件上传
|
||||||
|
- `file:delete` - 文件删除
|
||||||
|
- `file:toggle:visibility` - 切换文件可见性
|
||||||
|
|
||||||
|
### 服务器推送
|
||||||
|
- `file:uploaded` - 新文件上传
|
||||||
|
- `file:deleted` - 文件删除
|
||||||
|
- `file:visibility:changed` - 文件可见性变更
|
||||||
|
- `user:count` - 在线用户数
|
162
UPDATE.md
Normal file
162
UPDATE.md
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
# 更新说明
|
||||||
|
|
||||||
|
## 问题修复
|
||||||
|
|
||||||
|
### 1. API路由404错误修复
|
||||||
|
**问题**:访问 `/api/files` 接口报404错误
|
||||||
|
|
||||||
|
**原因**:API路由放在了 `src/api` 目录下,但Next.js 13+的App Router要求API路由放在 `src/app/api` 目录下
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- 将所有API路由从 `src/api` 移动到 `src/app/api`
|
||||||
|
- 更新了文件上传、文件管理、认证相关的API路由
|
||||||
|
- 添加了 `export const dynamic = 'force-dynamic';` 确保API路由动态渲染
|
||||||
|
|
||||||
|
### 2. 权限控制系统实现
|
||||||
|
**需求**:上传和管理面板需要登录后才可访问,密码可以设置为固定密码
|
||||||
|
|
||||||
|
**实现方案**:
|
||||||
|
|
||||||
|
#### 认证系统架构
|
||||||
|
- **JWT Token认证**:使用jsonwebtoken库实现基于token的认证
|
||||||
|
- **HTTP-only Cookie**:token存储在HTTP-only cookie中,增强安全性
|
||||||
|
- **中间件保护**:所有需要认证的API都进行token验证
|
||||||
|
|
||||||
|
#### 新增功能模块
|
||||||
|
|
||||||
|
##### 1. 认证API路由
|
||||||
|
- `/api/auth/login` - 用户登录
|
||||||
|
- `/api/auth/logout` - 用户登出
|
||||||
|
- `/api/auth/check` - 检查认证状态
|
||||||
|
|
||||||
|
##### 2. 认证中间件 (`src/lib/auth.ts`)
|
||||||
|
- `authenticateUser()` - 验证用户密码
|
||||||
|
- `authenticateRequest()` - 验证请求的token
|
||||||
|
- 支持环境变量配置管理员密码
|
||||||
|
|
||||||
|
##### 3. 认证Hook (`src/hooks/useAuth.ts`)
|
||||||
|
- `isAuthenticated` - 认证状态
|
||||||
|
- `loading` - 加载状态
|
||||||
|
- `login()` - 登录方法
|
||||||
|
- `logout()` - 登出方法
|
||||||
|
- `checkAuth()` - 检查认证状态
|
||||||
|
|
||||||
|
##### 4. 登录模态框 (`src/components/LoginModal.tsx`)
|
||||||
|
- 密码输入框(支持显示/隐藏)
|
||||||
|
- 登录状态反馈
|
||||||
|
- 错误提示
|
||||||
|
|
||||||
|
##### 5. 认证守卫 (`src/components/AuthGuard.tsx`)
|
||||||
|
- 保护需要认证的页面
|
||||||
|
- 自动重定向到登录页面
|
||||||
|
- 登录成功后跳转到原页面
|
||||||
|
|
||||||
|
#### 权限控制实现
|
||||||
|
|
||||||
|
##### 1. 文件上传权限
|
||||||
|
- 未登录用户显示锁定状态的上传区域
|
||||||
|
- 点击上传时弹出登录模态框
|
||||||
|
- 登录成功后才能正常上传文件
|
||||||
|
|
||||||
|
##### 2. 管理面板权限
|
||||||
|
- 整个 `/admin` 路由被AuthGuard保护
|
||||||
|
- 未登录用户自动显示登录界面
|
||||||
|
- 登录后才能访问管理功能
|
||||||
|
|
||||||
|
##### 3. API权限保护
|
||||||
|
- 文件上传API (`/api/upload`) - 需要认证
|
||||||
|
- 文件管理API (`/api/files/*`) - 需要认证
|
||||||
|
- 文件列表API (`/api/files`) - 公开访问
|
||||||
|
|
||||||
|
#### 环境变量配置
|
||||||
|
|
||||||
|
新增环境变量:
|
||||||
|
```env
|
||||||
|
# Authentication
|
||||||
|
ADMIN_PASSWORD="admin123" # 管理员密码,可自定义
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 用户体验优化
|
||||||
|
|
||||||
|
##### 1. 视觉反馈
|
||||||
|
- 未登录时上传区域显示锁定图标和提示
|
||||||
|
- 登录按钮在Header中显示
|
||||||
|
- 管理员身份标识
|
||||||
|
|
||||||
|
##### 2. 交互流程
|
||||||
|
- 点击需要权限的功能时自动弹出登录框
|
||||||
|
- 登录成功后自动跳转到目标页面
|
||||||
|
- 登出后自动跳转到首页
|
||||||
|
|
||||||
|
##### 3. 安全特性
|
||||||
|
- Token有效期24小时
|
||||||
|
- HTTP-only cookie防止XSS攻击
|
||||||
|
- 密码错误时提供友好提示
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
### 默认登录信息
|
||||||
|
- **密码**:`admin123`(可在 `.env.local` 中修改)
|
||||||
|
|
||||||
|
### 登录流程
|
||||||
|
1. 点击文件上传区域或访问 `/admin`
|
||||||
|
2. 在弹出的登录框中输入密码
|
||||||
|
3. 登录成功后即可使用所有功能
|
||||||
|
|
||||||
|
### 管理员功能
|
||||||
|
- 文件上传和管理
|
||||||
|
- 文件可见性控制
|
||||||
|
- 文件删除
|
||||||
|
- 查看统计信息
|
||||||
|
|
||||||
|
### 安全建议
|
||||||
|
1. **修改默认密码**:在生产环境中修改 `ADMIN_PASSWORD`
|
||||||
|
2. **使用强密码**:设置复杂的管理员密码
|
||||||
|
3. **定期更换**:定期更换管理员密码
|
||||||
|
4. **环境保护**:确保 `.env.local` 文件不被提交到版本控制
|
||||||
|
|
||||||
|
## 技术细节
|
||||||
|
|
||||||
|
### JWT Token结构
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
isAdmin: true,
|
||||||
|
timestamp: number,
|
||||||
|
iat: number,
|
||||||
|
exp: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cookie配置
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 60 * 60 * 24, // 24 hours
|
||||||
|
path: '/',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
- 密码错误:返回401状态码
|
||||||
|
- Token无效:自动清除cookie并要求重新登录
|
||||||
|
- 网络错误:显示友好的错误提示
|
||||||
|
|
||||||
|
## 测试验证
|
||||||
|
|
||||||
|
### 功能测试
|
||||||
|
1. ✅ 未登录用户无法上传文件
|
||||||
|
2. ✅ 未登录用户无法访问管理面板
|
||||||
|
3. ✅ 登录后可以正常上传文件
|
||||||
|
4. ✅ 登录后可以访问管理面板
|
||||||
|
5. ✅ 登出后权限被正确收回
|
||||||
|
6. ✅ API路由正确响应
|
||||||
|
|
||||||
|
### 安全测试
|
||||||
|
1. ✅ Token验证正常工作
|
||||||
|
2. ✅ Cookie安全设置正确
|
||||||
|
3. ✅ 密码错误被正确拒绝
|
||||||
|
4. ✅ Token过期后需要重新登录
|
||||||
|
|
||||||
|
系统现在具备完整的权限控制功能,确保只有授权用户才能进行文件上传和管理操作。
|
16
next.config.js
Normal file
16
next.config.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
domains: ['localhost'],
|
||||||
|
},
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/socket.io',
|
||||||
|
destination: 'http://localhost:3001/socket.io',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
8602
package-lock.json
generated
Normal file
8602
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
package.json
Normal file
57
package.json
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "my-live-photos",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.7.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"lucide-react": "^0.302.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"next": "14.0.4",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"react-hook-form": "^7.48.2",
|
||||||
|
"sharp": "^0.33.1",
|
||||||
|
"socket.io": "^4.7.4",
|
||||||
|
"socket.io-client": "^4.7.4",
|
||||||
|
"tailwind-merge": "^2.2.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/multer": "^1.4.11",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"autoprefixer": "^10.0.1",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.0.4",
|
||||||
|
"postcss": "^8",
|
||||||
|
"prisma": "^5.7.1",
|
||||||
|
"tailwindcss": "^3.3.0",
|
||||||
|
"tsx": "^4.6.2",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
}
|
||||||
|
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
30
prisma/schema.prisma
Normal file
30
prisma/schema.prisma
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model File {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
filename String @unique
|
||||||
|
originalName String
|
||||||
|
fileType String // 'image' or 'video'
|
||||||
|
fileSize Int
|
||||||
|
filePath String
|
||||||
|
mimeType String
|
||||||
|
isVisible Boolean @default(true)
|
||||||
|
isDeleted Boolean @default(false)
|
||||||
|
uploadedAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("files")
|
||||||
|
@@index([fileType])
|
||||||
|
@@index([isVisible])
|
||||||
|
@@index([uploadedAt])
|
||||||
|
}
|
57
prisma/seed.ts
Normal file
57
prisma/seed.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Seeding database...');
|
||||||
|
|
||||||
|
// Create some sample files
|
||||||
|
const sampleFiles = [
|
||||||
|
{
|
||||||
|
filename: 'sample-image-1.jpg',
|
||||||
|
originalName: 'Beautiful Landscape.jpg',
|
||||||
|
fileType: 'image',
|
||||||
|
fileSize: 1024000,
|
||||||
|
filePath: '/uploads/images/sample-image-1.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filename: 'sample-image-2.png',
|
||||||
|
originalName: 'City View.png',
|
||||||
|
fileType: 'image',
|
||||||
|
fileSize: 2048000,
|
||||||
|
filePath: '/uploads/images/sample-image-2.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filename: 'sample-video-1.mp4',
|
||||||
|
originalName: 'Nature Video.mp4',
|
||||||
|
fileType: 'video',
|
||||||
|
fileSize: 10485760,
|
||||||
|
filePath: '/uploads/videos/sample-video-1.mp4',
|
||||||
|
mimeType: 'video/mp4',
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of sampleFiles) {
|
||||||
|
await prisma.file.upsert({
|
||||||
|
where: { filename: file.filename },
|
||||||
|
update: {},
|
||||||
|
create: file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Database seeded successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
137
server.js
Normal file
137
server.js
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
const { createServer } = require('http');
|
||||||
|
const { parse } = require('url');
|
||||||
|
const next = require('next');
|
||||||
|
const { Server } = require('socket.io');
|
||||||
|
|
||||||
|
const dev = process.env.NODE_ENV !== 'production';
|
||||||
|
const hostname = 'localhost';
|
||||||
|
const port = parseInt(process.env.PORT || '3000', 10);
|
||||||
|
const socketPort = parseInt(process.env.SOCKET_PORT || '3001', 10);
|
||||||
|
|
||||||
|
// Create Next.js app
|
||||||
|
const app = next({ dev, hostname, port });
|
||||||
|
const handler = app.getRequestHandler();
|
||||||
|
|
||||||
|
// Create HTTP server for socket events
|
||||||
|
const httpServer = createServer((req, res) => {
|
||||||
|
const url = parse(req.url, true);
|
||||||
|
|
||||||
|
if (url.pathname === '/file:uploaded' && req.method === 'POST') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => {
|
||||||
|
body += chunk.toString();
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const fileData = JSON.parse(body);
|
||||||
|
io.emit('file:uploaded', fileData);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true }));
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Invalid data' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (url.pathname === '/file:deleted' && req.method === 'POST') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => {
|
||||||
|
body += chunk.toString();
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
io.emit('file:deleted', data);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true }));
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Invalid data' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (url.pathname === '/file:visibility:changed' && req.method === 'POST') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => {
|
||||||
|
body += chunk.toString();
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
io.emit('file:visibility:changed', data);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true }));
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Invalid data' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Not found' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Socket.IO server
|
||||||
|
const io = new Server(httpServer, {
|
||||||
|
cors: {
|
||||||
|
origin: process.env.NEXTAUTH_URL || `http://${hostname}:${port}`,
|
||||||
|
methods: ['GET', 'POST'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let userCount = 0;
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
userCount++;
|
||||||
|
io.emit('user:count', userCount);
|
||||||
|
console.log(`User connected. Total users: ${userCount}`);
|
||||||
|
|
||||||
|
// Handle file upload events
|
||||||
|
socket.on('file:upload', (data) => {
|
||||||
|
socket.broadcast.emit('file:uploaded', data);
|
||||||
|
console.log('File uploaded:', data.filename);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle file delete events
|
||||||
|
socket.on('file:delete', (data) => {
|
||||||
|
socket.broadcast.emit('file:deleted', data);
|
||||||
|
console.log('File deleted:', data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle file visibility toggle events
|
||||||
|
socket.on('file:toggle:visibility', (data) => {
|
||||||
|
socket.broadcast.emit('file:visibility:changed', data);
|
||||||
|
console.log('File visibility changed:', data.id, data.isVisible);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
userCount--;
|
||||||
|
io.emit('user:count', userCount);
|
||||||
|
console.log(`User disconnected. Total users: ${userCount}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.prepare().then(() => {
|
||||||
|
// Start Next.js server
|
||||||
|
createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const parsedUrl = parse(req.url, true);
|
||||||
|
await handler(req, res, parsedUrl);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error occurred handling', req.url, err);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end('internal server error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.once('error', (err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.listen(port, () => {
|
||||||
|
console.log(`> Ready on http://${hostname}:${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start Socket.IO server
|
||||||
|
httpServer.listen(socketPort, () => {
|
||||||
|
console.log(`> Socket.IO server ready on http://${hostname}:${socketPort}`);
|
||||||
|
});
|
||||||
|
});
|
110
src/api/files/[id]/route.ts
Normal file
110
src/api/files/[id]/route.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { FileUpdateResponse, FileDeleteResponse } from '@/types/file';
|
||||||
|
import { fileUpdateSchema } from '@/lib/validations';
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Invalid file ID' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const validatedData = fileUpdateSchema.parse(body);
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
const existingFile = await prisma.file.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingFile) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'File not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update file
|
||||||
|
const updatedFile = await prisma.file.update({
|
||||||
|
where: { id },
|
||||||
|
data: validatedData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: FileUpdateResponse = {
|
||||||
|
success: true,
|
||||||
|
file: {
|
||||||
|
id: updatedFile.id,
|
||||||
|
filename: updatedFile.filename,
|
||||||
|
originalName: updatedFile.originalName,
|
||||||
|
fileType: updatedFile.fileType as 'image' | 'video',
|
||||||
|
fileSize: updatedFile.fileSize,
|
||||||
|
filePath: updatedFile.filePath,
|
||||||
|
mimeType: updatedFile.mimeType,
|
||||||
|
isVisible: updatedFile.isVisible,
|
||||||
|
isDeleted: updatedFile.isDeleted,
|
||||||
|
uploadedAt: updatedFile.uploadedAt.toISOString(),
|
||||||
|
updatedAt: updatedFile.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update file error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Invalid file ID' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
const existingFile = await prisma.file.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingFile) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'File not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete file
|
||||||
|
await prisma.file.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isDeleted: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: FileDeleteResponse = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete file error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
104
src/api/files/route.ts
Normal file
104
src/api/files/route.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { FileListResponse } from '@/types/file';
|
||||||
|
import { FileQueryParams } from '@/types/api';
|
||||||
|
import { fileQuerySchema } from '@/lib/validations';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
// Parse and validate query parameters
|
||||||
|
const queryParams = {
|
||||||
|
page: searchParams.get('page'),
|
||||||
|
limit: searchParams.get('limit'),
|
||||||
|
type: searchParams.get('type'),
|
||||||
|
isVisible: searchParams.get('isVisible'),
|
||||||
|
search: searchParams.get('search'),
|
||||||
|
sortBy: searchParams.get('sortBy'),
|
||||||
|
sortOrder: searchParams.get('sortOrder'),
|
||||||
|
};
|
||||||
|
console.log('queryParams:', JSON.stringify(queryParams))
|
||||||
|
|
||||||
|
const validatedParams = queryParams;//fileQuerySchema.parse(queryParams);
|
||||||
|
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
type,
|
||||||
|
isVisible,
|
||||||
|
search,
|
||||||
|
sortBy = 'uploadedAt',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
} = validatedParams;
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
const where: any = {
|
||||||
|
isDeleted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
where.fileType = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof isVisible === 'boolean') {
|
||||||
|
where.isVisible = isVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ originalName: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ filename: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate pagination
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const total = await prisma.file.count({ where });
|
||||||
|
|
||||||
|
// Get files
|
||||||
|
const files = await prisma.file.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: {
|
||||||
|
[sortBy]: sortOrder,
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
const response: FileListResponse = {
|
||||||
|
success: true,
|
||||||
|
files: files.map(file => ({
|
||||||
|
id: file.id,
|
||||||
|
filename: file.filename,
|
||||||
|
originalName: file.originalName,
|
||||||
|
fileType: file.fileType as 'image' | 'video',
|
||||||
|
fileSize: file.fileSize,
|
||||||
|
filePath: file.filePath,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
isVisible: file.isVisible,
|
||||||
|
isDeleted: file.isDeleted,
|
||||||
|
uploadedAt: file.uploadedAt.toISOString(),
|
||||||
|
updatedAt: file.updatedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get files error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
96
src/api/upload/route.ts
Normal file
96
src/api/upload/route.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { writeFile } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getFileTypeFromMimeType, generateUniqueFilename } from '@/lib/utils';
|
||||||
|
import { FileUploadResponse } from '@/types/file';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const data = await request.formData();
|
||||||
|
const file: File | null = data.get('file') as unknown as File;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'No file uploaded' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
const allowedTypes = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'video/mp4',
|
||||||
|
'video/webm'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'File type not allowed' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (10MB max)
|
||||||
|
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'File too large (max 10MB)' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
const uniqueFilename = generateUniqueFilename(file.name);
|
||||||
|
const fileType = getFileTypeFromMimeType(file.type);
|
||||||
|
|
||||||
|
// Determine upload directory
|
||||||
|
const uploadDir = fileType === 'image' ? 'images' : 'videos';
|
||||||
|
const filePath = join(process.cwd(), 'public', 'uploads', uploadDir, uniqueFilename);
|
||||||
|
|
||||||
|
// Convert file to buffer and save
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(bytes);
|
||||||
|
await writeFile(filePath, buffer);
|
||||||
|
|
||||||
|
// Save file info to database
|
||||||
|
const dbFile = await prisma.file.create({
|
||||||
|
data: {
|
||||||
|
filename: uniqueFilename,
|
||||||
|
originalName: file.name,
|
||||||
|
fileType,
|
||||||
|
fileSize: file.size,
|
||||||
|
filePath: `/uploads/${uploadDir}/${uniqueFilename}`,
|
||||||
|
mimeType: file.type,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: FileUploadResponse = {
|
||||||
|
success: true,
|
||||||
|
file: {
|
||||||
|
id: dbFile.id,
|
||||||
|
filename: dbFile.filename,
|
||||||
|
originalName: dbFile.originalName,
|
||||||
|
fileType: dbFile.fileType as 'image' | 'video',
|
||||||
|
fileSize: dbFile.fileSize,
|
||||||
|
filePath: dbFile.filePath,
|
||||||
|
mimeType: dbFile.mimeType,
|
||||||
|
isVisible: dbFile.isVisible,
|
||||||
|
isDeleted: dbFile.isDeleted,
|
||||||
|
uploadedAt: dbFile.uploadedAt.toISOString(),
|
||||||
|
updatedAt: dbFile.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
32
src/app/admin/layout.tsx
Normal file
32
src/app/admin/layout.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { AuthGuard } from '@/components/AuthGuard';
|
||||||
|
|
||||||
|
export default function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="container mx-auto py-8 px-4">
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight">
|
||||||
|
管理面板
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
管理文件、控制可见性、查看统计信息
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AuthGuard>
|
||||||
|
{children}
|
||||||
|
</AuthGuard>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
8
src/app/admin/page.tsx
Normal file
8
src/app/admin/page.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { AdminPanel } from '@/components/AdminPanel';
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
return <AdminPanel />;
|
||||||
|
}
|
26
src/app/api/auth/check/route.ts
Normal file
26
src/app/api/auth/check/route.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { authenticateRequest } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await authenticateRequest(request);
|
||||||
|
|
||||||
|
if (!auth.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: auth.error },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Authenticated',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
47
src/app/api/auth/login/route.ts
Normal file
47
src/app/api/auth/login/route.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { authenticateUser } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { password } = await request.json();
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Password is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = await authenticateUser(password);
|
||||||
|
|
||||||
|
if (!auth.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: auth.error },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create response with token cookie
|
||||||
|
const response = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Login successful',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set HTTP-only cookie with token
|
||||||
|
response.cookies.set('auth-token', auth.token!, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 60 * 60 * 24, // 24 hours
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
27
src/app/api/auth/logout/route.ts
Normal file
27
src/app/api/auth/logout/route.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const response = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Logout successful',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the auth token cookie
|
||||||
|
response.cookies.set('auth-token', '', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 0,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
155
src/app/api/files/[id]/route.ts
Normal file
155
src/app/api/files/[id]/route.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { FileUpdateResponse, FileDeleteResponse } from '@/types/file';
|
||||||
|
import { fileUpdateSchema } from '@/lib/validations';
|
||||||
|
import { authenticateRequest } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Check authentication
|
||||||
|
const auth = await authenticateRequest(request);
|
||||||
|
if (!auth.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: auth.error },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Invalid file ID' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const validatedData = fileUpdateSchema.parse(body);
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
const existingFile = await prisma.file.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingFile) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'File not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update file
|
||||||
|
const updatedFile = await prisma.file.update({
|
||||||
|
where: { id },
|
||||||
|
data: validatedData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: FileUpdateResponse = {
|
||||||
|
success: true,
|
||||||
|
file: {
|
||||||
|
id: updatedFile.id,
|
||||||
|
filename: updatedFile.filename,
|
||||||
|
originalName: updatedFile.originalName,
|
||||||
|
fileType: updatedFile.fileType as 'image' | 'video',
|
||||||
|
fileSize: updatedFile.fileSize,
|
||||||
|
filePath: updatedFile.filePath,
|
||||||
|
mimeType: updatedFile.mimeType,
|
||||||
|
isVisible: updatedFile.isVisible,
|
||||||
|
isDeleted: updatedFile.isDeleted,
|
||||||
|
uploadedAt: updatedFile.uploadedAt.toISOString(),
|
||||||
|
updatedAt: updatedFile.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Notify socket server about visibility change
|
||||||
|
try {
|
||||||
|
await fetch(`http://localhost:3001/file:visibility:changed`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: updatedFile.id, isVisible: updatedFile.isVisible }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to notify socket server:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update file error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Check authentication
|
||||||
|
const auth = await authenticateRequest(request);
|
||||||
|
if (!auth.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: auth.error },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Invalid file ID' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
const existingFile = await prisma.file.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingFile) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'File not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete file
|
||||||
|
await prisma.file.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isDeleted: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: FileDeleteResponse = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Notify socket server about file deletion
|
||||||
|
try {
|
||||||
|
await fetch(`http://localhost:3001/file:deleted`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to notify socket server:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete file error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
120
src/app/api/files/route.ts
Normal file
120
src/app/api/files/route.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { FileListResponse } from '@/types/file';
|
||||||
|
import { FileQueryParams } from '@/types/api';
|
||||||
|
import { fileQuerySchema } from '@/lib/validations';
|
||||||
|
import { authenticateRequest } from '@/lib/auth';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Check authentication
|
||||||
|
const auth = await authenticateRequest(request);
|
||||||
|
const isAuthenticated = auth.success;
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
// Parse and validate query parameters
|
||||||
|
const queryParams = {
|
||||||
|
page: searchParams.get('page') ? parseInt(searchParams.get('page')!) : undefined,
|
||||||
|
limit: searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined,
|
||||||
|
type: searchParams.get('type') as 'image' | 'video' | null,
|
||||||
|
isVisible: searchParams.get('isVisible') === 'true' ? true :
|
||||||
|
searchParams.get('isVisible') === 'false' ? false : undefined,
|
||||||
|
search: searchParams.get('search'),
|
||||||
|
sortBy: searchParams.get('sortBy') || undefined,
|
||||||
|
sortOrder: searchParams.get('sortOrder') || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const validatedParams = queryParams;//fileQuerySchema.parse(queryParams);
|
||||||
|
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
type,
|
||||||
|
isVisible,
|
||||||
|
search,
|
||||||
|
sortBy = 'uploadedAt',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
} = validatedParams;
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
const where: any = {
|
||||||
|
isDeleted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// For non-authenticated users, only show visible files
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
where.isVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
where.fileType = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow filtering by visibility if authenticated
|
||||||
|
if (typeof isVisible === 'boolean' && isAuthenticated) {
|
||||||
|
where.isVisible = isVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ originalName: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ filename: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate pagination
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const total = await prisma.file.count({ where });
|
||||||
|
const orderBy: {
|
||||||
|
[key: string]: any
|
||||||
|
} = {};
|
||||||
|
if (sortBy) {
|
||||||
|
orderBy[sortBy] = sortOrder;
|
||||||
|
}
|
||||||
|
// Get files
|
||||||
|
const files = await prisma.file.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
const response: FileListResponse = {
|
||||||
|
success: true,
|
||||||
|
files: files.map(file => ({
|
||||||
|
id: file.id,
|
||||||
|
filename: file.filename,
|
||||||
|
originalName: file.originalName,
|
||||||
|
fileType: file.fileType as 'image' | 'video',
|
||||||
|
fileSize: file.fileSize,
|
||||||
|
filePath: file.filePath,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
isVisible: file.isVisible,
|
||||||
|
isDeleted: file.isDeleted,
|
||||||
|
uploadedAt: file.uploadedAt.toISOString(),
|
||||||
|
updatedAt: file.updatedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get files error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
119
src/app/api/upload/route.ts
Normal file
119
src/app/api/upload/route.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { writeFile } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getFileTypeFromMimeType, generateUniqueFilename } from '@/lib/utils';
|
||||||
|
import { FileUploadResponse } from '@/types/file';
|
||||||
|
import { authenticateRequest } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Check authentication
|
||||||
|
const auth = await authenticateRequest(request);
|
||||||
|
if (!auth.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: auth.error },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await request.formData();
|
||||||
|
const file: File | null = data.get('file') as unknown as File;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'No file uploaded' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
const allowedTypes = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'video/mp4',
|
||||||
|
'video/webm'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'File type not allowed' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (10MB max)
|
||||||
|
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'File too large (max 10MB)' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
const uniqueFilename = generateUniqueFilename(file.name);
|
||||||
|
const fileType = getFileTypeFromMimeType(file.type);
|
||||||
|
|
||||||
|
// Determine upload directory
|
||||||
|
const uploadDir = fileType === 'image' ? 'images' : 'videos';
|
||||||
|
const filePath = join(process.cwd(), 'public', 'uploads', uploadDir, uniqueFilename);
|
||||||
|
|
||||||
|
// Convert file to buffer and save
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(bytes);
|
||||||
|
await writeFile(filePath, buffer);
|
||||||
|
|
||||||
|
// Save file info to database
|
||||||
|
const dbFile = await prisma.file.create({
|
||||||
|
data: {
|
||||||
|
filename: uniqueFilename,
|
||||||
|
originalName: file.name,
|
||||||
|
fileType,
|
||||||
|
fileSize: file.size,
|
||||||
|
filePath: `/uploads/${uploadDir}/${uniqueFilename}`,
|
||||||
|
mimeType: file.type,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: FileUploadResponse = {
|
||||||
|
success: true,
|
||||||
|
file: {
|
||||||
|
id: dbFile.id,
|
||||||
|
filename: dbFile.filename,
|
||||||
|
originalName: dbFile.originalName,
|
||||||
|
fileType: dbFile.fileType as 'image' | 'video',
|
||||||
|
fileSize: dbFile.fileSize,
|
||||||
|
filePath: dbFile.filePath,
|
||||||
|
mimeType: dbFile.mimeType,
|
||||||
|
isVisible: dbFile.isVisible,
|
||||||
|
isDeleted: dbFile.isDeleted,
|
||||||
|
uploadedAt: dbFile.uploadedAt.toISOString(),
|
||||||
|
updatedAt: dbFile.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Notify socket server about the new file
|
||||||
|
try {
|
||||||
|
await fetch(`http://localhost:3001/file:uploaded`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(response.file),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to notify socket server:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
73
src/app/globals.css
Normal file
73
src/app/globals.css
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 221.2 83.2% 53.3%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96%;
|
||||||
|
--secondary-foreground: 222.2 84% 4.9%;
|
||||||
|
--muted: 210 40% 96%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96%;
|
||||||
|
--accent-foreground: 222.2 84% 4.9%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 221.2 83.2% 53.3%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 217.2 91.2% 59.8%;
|
||||||
|
--primary-foreground: 222.2 84% 4.9%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 224.3 76.3% 94.1%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.file-card {
|
||||||
|
@apply relative overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm transition-all hover:shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
@apply flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 bg-muted/50 p-8 text-center transition-colors hover:border-muted-foreground/50 hover:bg-muted/75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area.drag-over {
|
||||||
|
@apply border-primary bg-primary/5;
|
||||||
|
}
|
||||||
|
}
|
35
src/app/layout.tsx
Normal file
35
src/app/layout.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
import './globals.css';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { SocketProvider } from '@/components/socket-provider';
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'My Live Photos - 实时文件共享系统',
|
||||||
|
description: '一个轻量级的实时文件共享系统,支持图片和视频文件的上传与实时分享',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN" suppressHydrationWarning>
|
||||||
|
<body className={inter.className}>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<SocketProvider>
|
||||||
|
{children}
|
||||||
|
</SocketProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
67
src/app/page.tsx
Normal file
67
src/app/page.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { FileUpload } from '@/components/FileUpload';
|
||||||
|
import { FileGrid } from '@/components/FileGrid';
|
||||||
|
import { useFiles } from '@/hooks/useFiles';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const { files, loading, refreshFiles } = useFiles();
|
||||||
|
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
|
const handleUploadComplete = () => {
|
||||||
|
refreshFiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show loading state while checking authentication
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||||
|
<p className="mt-4 text-muted-foreground">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="container mx-auto py-8 px-4">
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight">
|
||||||
|
实时文件共享
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
上传和分享您的图片与视频,实时同步给所有在线用户
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Section - Only show if authenticated */}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<section className="max-w-2xl mx-auto">
|
||||||
|
<FileUpload onUploadComplete={handleUploadComplete} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Files Grid */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-2xl font-semibold">最新文件</h2>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
共 {files.length} 个文件
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FileGrid files={files} loading={loading} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
151
src/components/AdminPanel.tsx
Normal file
151
src/components/AdminPanel.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Search, Filter, Eye, EyeOff, Trash2, Download } from 'lucide-react';
|
||||||
|
import { File } from '@/types/file';
|
||||||
|
import { useFiles } from '@/hooks/useFiles';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { FileGrid } from './FileGrid';
|
||||||
|
import { formatFileSize, formatDate } from '@/lib/utils';
|
||||||
|
|
||||||
|
export const AdminPanel: React.FC = () => {
|
||||||
|
const { files, loading, deleteFile, toggleFileVisibility, fetchFiles } = useFiles();
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [filterType, setFilterType] = useState<'all' | 'image' | 'video'>('all');
|
||||||
|
const [filterVisibility, setFilterVisibility] = useState<'all' | 'visible' | 'hidden'>('all');
|
||||||
|
|
||||||
|
const filteredFiles = files.filter(file => {
|
||||||
|
const matchesSearch = file.originalName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
const matchesType = filterType === 'all' || file.fileType === filterType;
|
||||||
|
const matchesVisibility =
|
||||||
|
filterVisibility === 'all' ||
|
||||||
|
(filterVisibility === 'visible' && file.isVisible) ||
|
||||||
|
(filterVisibility === 'hidden' && !file.isVisible);
|
||||||
|
|
||||||
|
return matchesSearch && matchesType && matchesVisibility;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleBulkAction = async (action: 'show' | 'hide' | 'delete', selectedIds: number[]) => {
|
||||||
|
if (selectedIds.length === 0) return;
|
||||||
|
|
||||||
|
if (action === 'delete' && !confirm(`确定要删除选中的 ${selectedIds.length} 个文件吗?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
if (action === 'show') {
|
||||||
|
await toggleFileVisibility(id, true);
|
||||||
|
} else if (action === 'hide') {
|
||||||
|
await toggleFileVisibility(id, false);
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
await deleteFile(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileStats = () => {
|
||||||
|
const total = files.length;
|
||||||
|
const visible = files.filter(f => f.isVisible).length;
|
||||||
|
const hidden = files.filter(f => !f.isVisible).length;
|
||||||
|
const images = files.filter(f => f.fileType === 'image').length;
|
||||||
|
const videos = files.filter(f => f.fileType === 'video').length;
|
||||||
|
const totalSize = files.reduce((sum, f) => sum + f.fileSize, 0);
|
||||||
|
|
||||||
|
return { total, visible, hidden, images, videos, totalSize };
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = getFileStats();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">总文件数</h3>
|
||||||
|
<p className="text-2xl font-bold">{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">可见文件</h3>
|
||||||
|
<p className="text-2xl font-bold text-green-600">{stats.visible}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">隐藏文件</h3>
|
||||||
|
<p className="text-2xl font-bold text-orange-600">{stats.hidden}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">总大小</h3>
|
||||||
|
<p className="text-2xl font-bold">{formatFileSize(stats.totalSize)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索文件名..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Filter className="w-4 h-4 mr-2" />
|
||||||
|
类型: {filterType === 'all' ? '全部' : filterType === 'image' ? '图片' : '视频'}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem onClick={() => setFilterType('all')}>
|
||||||
|
全部类型
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setFilterType('image')}>
|
||||||
|
图片 ({stats.images})
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setFilterType('video')}>
|
||||||
|
视频 ({stats.videos})
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Eye className="w-4 h-4 mr-2" />
|
||||||
|
可见性: {filterVisibility === 'all' ? '全部' : filterVisibility === 'visible' ? '可见' : '隐藏'}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem onClick={() => setFilterVisibility('all')}>
|
||||||
|
全部状态
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setFilterVisibility('visible')}>
|
||||||
|
可见文件 ({stats.visible})
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setFilterVisibility('hidden')}>
|
||||||
|
隐藏文件 ({stats.hidden})
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={() => fetchFiles()}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Grid */}
|
||||||
|
<FileGrid files={filteredFiles} loading={loading} isAdmin={true} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
68
src/components/AuthGuard.tsx
Normal file
68
src/components/AuthGuard.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { LoginModal } from './LoginModal';
|
||||||
|
|
||||||
|
interface AuthGuardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
redirectTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthGuard: React.FC<AuthGuardProps> = ({
|
||||||
|
children,
|
||||||
|
redirectTo = '/admin'
|
||||||
|
}) => {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !isAuthenticated) {
|
||||||
|
setShowLoginModal(true);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, loading, router, redirectTo]);
|
||||||
|
|
||||||
|
const handleLoginSuccess = () => {
|
||||||
|
setShowLoginModal(false);
|
||||||
|
router.push(redirectTo);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="w-16 h-16 mx-auto bg-muted rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-2xl">🔒</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">需要管理员权限</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
请登录以访问管理面板
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoginModal
|
||||||
|
isOpen={showLoginModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowLoginModal(false);
|
||||||
|
router.push('/');
|
||||||
|
}}
|
||||||
|
onSuccess={handleLoginSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
168
src/components/FileCard.tsx
Normal file
168
src/components/FileCard.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { MoreHorizontal, Eye, EyeOff, Trash2, Download, Play } from 'lucide-react';
|
||||||
|
import { File } from '@/types/file';
|
||||||
|
import { formatFileSize, formatDate, isImageFile, isVideoFile } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useFiles } from '@/hooks/useFiles';
|
||||||
|
|
||||||
|
interface FileCardProps {
|
||||||
|
file: File;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileCard: React.FC<FileCardProps> = ({ file, isAdmin = false }) => {
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
const { deleteFile, toggleFileVisibility } = useFiles();
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (confirm('确定要删除这个文件吗?')) {
|
||||||
|
await deleteFile(file.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleVisibility = async () => {
|
||||||
|
await toggleFileVisibility(file.id, !file.isVisible);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = file.filePath;
|
||||||
|
link.download = file.originalName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isImage = isImageFile(file.mimeType);
|
||||||
|
const isVideo = isVideoFile(file.mimeType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`file-card ${!file.isVisible ? 'opacity-50' : ''}`}>
|
||||||
|
<div className="aspect-square relative overflow-hidden bg-muted">
|
||||||
|
{isImage ? (
|
||||||
|
<Image
|
||||||
|
src={file.filePath}
|
||||||
|
alt={file.originalName}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
onClick={() => setPreviewOpen(true)}
|
||||||
|
/>
|
||||||
|
) : isVideo ? (
|
||||||
|
<div className="relative w-full h-full flex items-center justify-center">
|
||||||
|
<video
|
||||||
|
src={file.filePath}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
muted
|
||||||
|
onClick={() => setPreviewOpen(true)}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/20 flex items-center justify-center">
|
||||||
|
<Play className="w-12 h-12 text-white/80" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 mx-auto mb-2 bg-muted rounded flex items-center justify-center">
|
||||||
|
<span className="text-2xl">📄</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">文件</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!file.isVisible && (
|
||||||
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||||
|
<EyeOff className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<h3 className="font-medium text-sm line-clamp-2 flex-1 mr-2">
|
||||||
|
{file.originalName}
|
||||||
|
</h3>
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={handleToggleVisibility}>
|
||||||
|
{file.isVisible ? (
|
||||||
|
<>
|
||||||
|
<EyeOff className="mr-2 h-4 w-4" />
|
||||||
|
隐藏文件
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
显示文件
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleDownload}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
下载文件
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleDelete} className="text-destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
删除文件
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
|
<p>{formatFileSize(file.fileSize)}</p>
|
||||||
|
<p>{formatDate(file.uploadedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Dialog */}
|
||||||
|
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{file.originalName}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{isImage ? (
|
||||||
|
<Image
|
||||||
|
src={file.filePath}
|
||||||
|
alt={file.originalName}
|
||||||
|
width={800}
|
||||||
|
height={600}
|
||||||
|
className="max-w-full max-h-[600px] object-contain"
|
||||||
|
/>
|
||||||
|
) : isVideo ? (
|
||||||
|
<video
|
||||||
|
src={file.filePath}
|
||||||
|
controls
|
||||||
|
className="max-w-full max-h-[600px]"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
73
src/components/FileGrid.tsx
Normal file
73
src/components/FileGrid.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { File } from '@/types/file';
|
||||||
|
import { FileCard } from './FileCard';
|
||||||
|
import { useFiles } from '@/hooks/useFiles';
|
||||||
|
|
||||||
|
interface FileGridProps {
|
||||||
|
files: File[];
|
||||||
|
loading?: boolean;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileGrid: React.FC<FileGridProps> = ({
|
||||||
|
files,
|
||||||
|
loading = false,
|
||||||
|
isAdmin = false
|
||||||
|
}) => {
|
||||||
|
const { error } = useFiles();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="aspect-square bg-muted rounded-lg animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-destructive mb-4">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-2xl">📁</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium mb-2">暂无文件</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
上传一些图片或视频来开始分享吧!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{files.map((file) => (
|
||||||
|
<FileCard
|
||||||
|
key={file.id}
|
||||||
|
file={file}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
162
src/components/FileUpload.tsx
Normal file
162
src/components/FileUpload.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
|
import { Upload, X, FileImage, FileVideo, AlertCircle, Lock } from 'lucide-react';
|
||||||
|
import { useUpload } from '@/hooks/useUpload';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { LoginModal } from '@/components/LoginModal';
|
||||||
|
import { isImageFile, isVideoFile } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface FileUploadProps {
|
||||||
|
onUploadComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileUpload: React.FC<FileUploadProps> = ({ onUploadComplete }) => {
|
||||||
|
const { uploading, progress, error, uploadFile, resetUpload } = useUpload();
|
||||||
|
const { isAuthenticated, login } = useAuth();
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback(async (file: File) => {
|
||||||
|
const response = await uploadFile(file);
|
||||||
|
if (response.success) {
|
||||||
|
onUploadComplete?.();
|
||||||
|
}
|
||||||
|
}, [uploadFile, onUploadComplete]);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setShowLoginModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
if (files.length > 0) {
|
||||||
|
handleFileSelect(files[0]);
|
||||||
|
}
|
||||||
|
}, [handleFileSelect, isAuthenticated]);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
handleFileSelect(files[0]);
|
||||||
|
}
|
||||||
|
}, [handleFileSelect]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setShowLoginModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
const handleLoginSuccess = () => {
|
||||||
|
setShowLoginModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileIcon = (file: File) => {
|
||||||
|
if (isImageFile(file.type)) {
|
||||||
|
return <FileImage className="w-8 h-8 text-blue-500" />;
|
||||||
|
}
|
||||||
|
if (isVideoFile(file.type)) {
|
||||||
|
return <FileVideo className="w-8 h-8 text-green-500" />;
|
||||||
|
}
|
||||||
|
return <Upload className="w-8 h-8 text-gray-500" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div
|
||||||
|
className={`upload-area ${dragOver ? 'drag-over' : ''} ${!isAuthenticated ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*,video/*"
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
className="hidden"
|
||||||
|
disabled={uploading || !isAuthenticated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{uploading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">上传中...</p>
|
||||||
|
<div className="w-64 bg-muted rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{Math.round(progress)}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 bg-orange-100 rounded-full mx-auto">
|
||||||
|
<Lock className="w-6 h-6 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Upload className={`w-12 h-12 ${!isAuthenticated ? 'text-orange-600' : 'text-muted-foreground'}`} />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-lg font-medium">
|
||||||
|
{isAuthenticated ? '拖拽文件到此处上传' : '需要登录后才能上传文件'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isAuthenticated ? '或点击选择文件' : '点击登录以开始上传'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
支持 JPG, PNG, GIF, MP4, WebM 格式,最大 10MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||||
|
<AlertCircle className="w-4 h-4 text-destructive" />
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 ml-auto"
|
||||||
|
onClick={resetUpload}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoginModal
|
||||||
|
isOpen={showLoginModal}
|
||||||
|
onClose={() => setShowLoginModal(false)}
|
||||||
|
onSuccess={handleLoginSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
128
src/components/Header.tsx
Normal file
128
src/components/Header.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
LogOut
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useThemeToggle } from '@/hooks/useTheme';
|
||||||
|
import { useSocket } from '@/components/socket-provider';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Header: React.FC<HeaderProps> = ({ isAdmin = false }) => {
|
||||||
|
const { theme, toggleTheme, isDark } = useThemeToggle();
|
||||||
|
const { isConnected, onlineUsers } = useSocket();
|
||||||
|
const { isAuthenticated, logout } = useAuth();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
// Redirect to home if on admin page
|
||||||
|
if (window.location.pathname === '/admin') {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<div className="container flex h-14 items-center">
|
||||||
|
<div className="mr-4 hidden md:flex">
|
||||||
|
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||||
|
<div className="w-6 h-6 bg-primary rounded flex items-center justify-center">
|
||||||
|
<span className="text-white text-xs font-bold">LP</span>
|
||||||
|
</div>
|
||||||
|
<span className="hidden font-bold sm:inline-block">
|
||||||
|
My Live Photos
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<nav className="flex items-center space-x-6 text-sm font-medium">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="transition-colors hover:text-foreground/80 text-foreground"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Home className="w-4 h-4" />
|
||||||
|
首页
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
{isAuthenticated && (
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="transition-colors hover:text-foreground/80 text-foreground"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
管理面板
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||||
|
<div className="w-full flex-1 md:w-auto md:flex-none">
|
||||||
|
{/* Mobile menu button could go here */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Connection status */}
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
{isConnected ? (
|
||||||
|
<Wifi className="w-3 h-3 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="w-3 h-3 text-red-500" />
|
||||||
|
)}
|
||||||
|
<span>{onlineUsers} 在线</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
{isDark ? (
|
||||||
|
<Sun className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Moon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">切换主题</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Admin actions */}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
<span>管理员</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="h-8 w-8"
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
<span className="sr-only">退出登录</span>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
121
src/components/LoginModal.tsx
Normal file
121
src/components/LoginModal.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Lock, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface LoginModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoginModal: React.FC<LoginModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
setPassword('');
|
||||||
|
} else {
|
||||||
|
setError(data.error || '登录失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('网络错误,请重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>管理员登录</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
请输入管理员密码以访问上传和管理功能
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="password" className="text-sm font-medium">
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading || !password}>
|
||||||
|
{loading ? '登录中...' : '登录'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
73
src/components/socket-provider.tsx
Normal file
73
src/components/socket-provider.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import { File } from '@/types/file';
|
||||||
|
|
||||||
|
interface SocketContextType {
|
||||||
|
socket: Socket | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
onlineUsers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SocketContext = createContext<SocketContextType>({
|
||||||
|
socket: null,
|
||||||
|
isConnected: false,
|
||||||
|
onlineUsers: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useSocket = () => {
|
||||||
|
const context = useContext(SocketContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSocket must be used within a SocketProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SocketProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SocketProvider: React.FC<SocketProviderProps> = ({ children }) => {
|
||||||
|
const [socket, setSocket] = useState<Socket | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [onlineUsers, setOnlineUsers] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const socketConnection = io(process.env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3001', {
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
});
|
||||||
|
|
||||||
|
socketConnection.on('connect', () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
console.log('Connected to WebSocket server');
|
||||||
|
});
|
||||||
|
|
||||||
|
socketConnection.on('disconnect', () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
console.log('Disconnected from WebSocket server');
|
||||||
|
});
|
||||||
|
|
||||||
|
socketConnection.on('user:count', (count: number) => {
|
||||||
|
setOnlineUsers(count);
|
||||||
|
});
|
||||||
|
|
||||||
|
setSocket(socketConnection);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socketConnection.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: SocketContextType = {
|
||||||
|
socket,
|
||||||
|
isConnected,
|
||||||
|
onlineUsers,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SocketContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</SocketContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
8
src/components/theme-provider.tsx
Normal file
8
src/components/theme-provider.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: any) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-2xl font-semibold leading-none tracking-tight',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-6 pt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
120
src/components/ui/dialog.tsx
Normal file
120
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col space-y-1.5 text-center sm:text-left',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = 'DialogHeader';
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = 'DialogFooter';
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-lg font-semibold leading-none tracking-tight',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
198
src/components/ui/dropdown-menu.tsx
Normal file
198
src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
|
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1.5 text-sm font-semibold',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
127
src/components/ui/toast.tsx
Normal file
127
src/components/ui/toast.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border bg-background text-foreground',
|
||||||
|
destructive:
|
||||||
|
'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
));
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm opacity-90', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
};
|
28
src/components/ui/tooltip.tsx
Normal file
28
src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 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',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
72
src/hooks/useAuth.ts
Normal file
72
src/hooks/useAuth.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface UseAuthReturn {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
login: (password: string) => Promise<boolean>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
checkAuth: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = (): UseAuthReturn => {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const checkAuth = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/check');
|
||||||
|
const data = await response.json();
|
||||||
|
setIsAuthenticated(data.success);
|
||||||
|
return data.success;
|
||||||
|
} catch (error) {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (password: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
checkAuth,
|
||||||
|
};
|
||||||
|
};
|
152
src/hooks/useFiles.ts
Normal file
152
src/hooks/useFiles.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { File } from '@/types/file';
|
||||||
|
import { FileQueryParams } from '@/types/api';
|
||||||
|
import { useSocket } from '@/components/socket-provider';
|
||||||
|
|
||||||
|
interface UseFilesReturn {
|
||||||
|
files: File[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fetchFiles: (params?: FileQueryParams) => Promise<void>;
|
||||||
|
deleteFile: (id: number) => Promise<void>;
|
||||||
|
toggleFileVisibility: (id: number, isVisible: boolean) => Promise<void>;
|
||||||
|
refreshFiles: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFiles = (initialParams?: FileQueryParams): UseFilesReturn => {
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [currentParams, setCurrentParams] = useState<FileQueryParams>(initialParams || {});
|
||||||
|
const { socket } = useSocket();
|
||||||
|
|
||||||
|
const fetchFiles = useCallback(async (params?: FileQueryParams) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
const finalParams = { ...currentParams, ...params };
|
||||||
|
|
||||||
|
Object.entries(finalParams).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
queryParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/files?${queryParams.toString()}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setFiles(data.files);
|
||||||
|
setCurrentParams(finalParams);
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Failed to fetch files');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentParams]);
|
||||||
|
|
||||||
|
const deleteFile = useCallback(async (id: number) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/files/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setFiles(prev => prev.filter(file => file.id !== id));
|
||||||
|
if (socket) {
|
||||||
|
socket.emit('file:delete', { id });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Failed to delete file');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
}
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
const toggleFileVisibility = useCallback(async (id: number, isVisible: boolean) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/files/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ isVisible }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setFiles(prev =>
|
||||||
|
prev.map(file =>
|
||||||
|
file.id === id ? { ...file, isVisible } : file
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (socket) {
|
||||||
|
socket.emit('file:toggle:visibility', { id, isVisible });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Failed to update file visibility');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
}
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
const refreshFiles = useCallback(() => {
|
||||||
|
return fetchFiles(currentParams);
|
||||||
|
}, [fetchFiles, currentParams]);
|
||||||
|
|
||||||
|
// Listen for real-time updates
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
const handleFileUploaded = (newFile: File) => {
|
||||||
|
setFiles(prev => [newFile, ...prev]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileDeleted = (data: { id: number }) => {
|
||||||
|
setFiles(prev => prev.filter(file => file.id !== data.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileVisibilityChanged = (data: { id: number; isVisible: boolean }) => {
|
||||||
|
setFiles(prev =>
|
||||||
|
prev.map(file =>
|
||||||
|
file.id === data.id ? { ...file, isVisible: data.isVisible } : file
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('file:uploaded', handleFileUploaded);
|
||||||
|
socket.on('file:deleted', handleFileDeleted);
|
||||||
|
socket.on('file:visibility:changed', handleFileVisibilityChanged);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('file:uploaded', handleFileUploaded);
|
||||||
|
socket.off('file:deleted', handleFileDeleted);
|
||||||
|
socket.off('file:visibility:changed', handleFileVisibilityChanged);
|
||||||
|
};
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFiles(initialParams);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchFiles,
|
||||||
|
deleteFile,
|
||||||
|
toggleFileVisibility,
|
||||||
|
refreshFiles,
|
||||||
|
};
|
||||||
|
};
|
23
src/hooks/useTheme.ts
Normal file
23
src/hooks/useTheme.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
|
export const useThemeToggle = () => {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme(theme === 'light' ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme: mounted ? theme : 'light',
|
||||||
|
toggleTheme,
|
||||||
|
isDark: mounted ? theme === 'dark' : false,
|
||||||
|
};
|
||||||
|
};
|
89
src/hooks/useUpload.ts
Normal file
89
src/hooks/useUpload.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { FileUploadResponse } from '@/types/file';
|
||||||
|
import { useSocket } from '@/components/socket-provider';
|
||||||
|
|
||||||
|
interface UseUploadReturn {
|
||||||
|
uploading: boolean;
|
||||||
|
progress: number;
|
||||||
|
error: string | null;
|
||||||
|
uploadFile: (file: File) => Promise<FileUploadResponse>;
|
||||||
|
resetUpload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpload = (): UseUploadReturn => {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { socket } = useSocket();
|
||||||
|
|
||||||
|
const uploadFile = useCallback(async (file: File): Promise<FileUploadResponse> => {
|
||||||
|
try {
|
||||||
|
setUploading(true);
|
||||||
|
setProgress(0);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
// Create a promise to handle the upload
|
||||||
|
const uploadPromise = new Promise<FileUploadResponse>((resolve, reject) => {
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
const progressPercent = (event.loaded / event.total) * 100;
|
||||||
|
setProgress(progressPercent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
const response = JSON.parse(xhr.responseText);
|
||||||
|
resolve(response);
|
||||||
|
} else {
|
||||||
|
const response = JSON.parse(xhr.responseText);
|
||||||
|
reject(new Error(response.error || 'Upload failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => {
|
||||||
|
reject(new Error('Network error during upload'));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.open('POST', '/api/upload', true);
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await uploadPromise;
|
||||||
|
|
||||||
|
if (response.success && socket) {
|
||||||
|
socket.emit('file:upload', response.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
setError(errorMessage);
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
setProgress(0);
|
||||||
|
}
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
const resetUpload = useCallback(() => {
|
||||||
|
setUploading(false);
|
||||||
|
setProgress(0);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploading,
|
||||||
|
progress,
|
||||||
|
error,
|
||||||
|
uploadFile,
|
||||||
|
resetUpload,
|
||||||
|
};
|
||||||
|
};
|
74
src/lib/auth.ts
Normal file
74
src/lib/auth.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123'; // Default password
|
||||||
|
const JWT_SECRET = process.env.NEXTAUTH_SECRET || 'my-live-photos-secret-key-123';
|
||||||
|
|
||||||
|
export interface AuthResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticateUser(password: string): Promise<AuthResult> {
|
||||||
|
if (password !== ADMIN_PASSWORD) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid password',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
isAdmin: true,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '24h' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to generate token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticateRequest(request: NextRequest): Promise<AuthResult> {
|
||||||
|
try {
|
||||||
|
// Get token from cookie
|
||||||
|
const token = request.cookies.get('auth-token')?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No authentication token provided',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||||
|
|
||||||
|
if (!decoded || !decoded.isAdmin) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid or expired token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
9
src/lib/db.ts
Normal file
9
src/lib/db.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
17
src/lib/socket.ts
Normal file
17
src/lib/socket.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Server as NetServer } from 'http';
|
||||||
|
import { NextApiRequest } from 'next';
|
||||||
|
import { Server as ServerIO } from 'socket.io';
|
||||||
|
|
||||||
|
export type NextApiResponseServerIO = NextApiRequest & {
|
||||||
|
socket: {
|
||||||
|
server: NetServer & {
|
||||||
|
io?: ServerIO;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
47
src/lib/utils.ts
Normal file
47
src/lib/utils.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileTypeFromMimeType(mimeType: string): 'image' | 'video' {
|
||||||
|
if (mimeType.startsWith('image/')) return 'image';
|
||||||
|
if (mimeType.startsWith('video/')) return 'video';
|
||||||
|
return 'image'; // default fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateUniqueFilename(originalName: string): string {
|
||||||
|
const ext = originalName.split('.').pop();
|
||||||
|
const uuid = crypto.randomUUID();
|
||||||
|
return `${uuid}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageFile(mimeType: string): boolean {
|
||||||
|
return mimeType.startsWith('image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVideoFile(mimeType: string): boolean {
|
||||||
|
return mimeType.startsWith('video/');
|
||||||
|
}
|
39
src/lib/validations.ts
Normal file
39
src/lib/validations.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const fileUploadSchema = z.object({
|
||||||
|
file: z.instanceof(File).refine(
|
||||||
|
(file) => file.size <= 10 * 1024 * 1024, // 10MB
|
||||||
|
'文件大小不能超过 10MB'
|
||||||
|
).refine(
|
||||||
|
(file) => {
|
||||||
|
const allowedTypes = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'video/mp4',
|
||||||
|
'video/webm'
|
||||||
|
];
|
||||||
|
return allowedTypes.includes(file.type);
|
||||||
|
},
|
||||||
|
'只支持 JPG, PNG, GIF, MP4, WebM 格式的文件'
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fileUpdateSchema = z.object({
|
||||||
|
isVisible: z.boolean().optional(),
|
||||||
|
isDeleted: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fileQuerySchema = z.object({
|
||||||
|
page: z.coerce.number().min(1).optional().default(1),
|
||||||
|
limit: z.coerce.number().min(1).max(100).optional().default(20),
|
||||||
|
type: z.string().optional(), // .enum(['image', 'video'])
|
||||||
|
isVisible: z.coerce.boolean().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
sortBy: z.string().optional().default('uploadedAt'), // .enum(['uploadedAt', 'fileSize', 'originalName'])
|
||||||
|
sortOrder: z.string().optional().default('desc'), // .enum(['asc', 'desc'])
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FileUploadInput = z.infer<typeof fileUploadSchema>;
|
||||||
|
export type FileUpdateInput = z.infer<typeof fileUpdateSchema>;
|
||||||
|
export type FileQueryInput = z.infer<typeof fileQuerySchema>;
|
21
src/types/api.ts
Normal file
21
src/types/api.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileFilters {
|
||||||
|
type?: 'image' | 'video';
|
||||||
|
isVisible?: boolean;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileQueryParams extends PaginationParams, FileFilters {}
|
47
src/types/file.ts
Normal file
47
src/types/file.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
export interface File {
|
||||||
|
id: number;
|
||||||
|
filename: string;
|
||||||
|
originalName: string;
|
||||||
|
fileType: 'image' | 'video';
|
||||||
|
fileSize: number;
|
||||||
|
filePath: string;
|
||||||
|
mimeType: string;
|
||||||
|
isVisible: boolean;
|
||||||
|
isDeleted: boolean;
|
||||||
|
uploadedAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileUploadResponse {
|
||||||
|
success: boolean;
|
||||||
|
file?: File;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileListResponse {
|
||||||
|
success: boolean;
|
||||||
|
files: File[];
|
||||||
|
pagination?: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileUpdateRequest {
|
||||||
|
isVisible?: boolean;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileUpdateResponse {
|
||||||
|
success: boolean;
|
||||||
|
file?: File;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileDeleteResponse {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
76
tailwind.config.js
Normal file
76
tailwind.config.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
}
|
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "es6"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user