init 仓库

This commit is contained in:
LittleBoy 2025-08-01 15:41:44 +08:00
commit ac081dd134
55 changed files with 13085 additions and 0 deletions

16
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

57
package.json Normal file
View 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
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

30
prisma/schema.prisma Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,8 @@
'use client';
import React from 'react';
import { AdminPanel } from '@/components/AdminPanel';
export default function AdminPage() {
return <AdminPanel />;
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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>
);
};

View 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
View 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>
);
};

View 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>
);
};

View 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
View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>;
}

View 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 };

View 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 };

View 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,
};

View 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,
};

View 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
View 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,
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}