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