mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-02-04 11:34:45 +08:00
feat:美化UI,添加桌面共享/文件字传输UI
This commit is contained in:
144
README.md
144
README.md
@@ -1,33 +1,133 @@
|
||||
# 川 - P2P文件传输系统
|
||||
# 传传传 - 跨平台文件传输工具
|
||||
|
||||
一个基于WebRTC技术的点对点文件传输系统,支持无服务器中转的直接文件传输。
|
||||
> 简单、快速、安全的点对点文件传输解决方案
|
||||
|
||||
## ✨ 功能特性
|
||||
## ✨ 核心功能
|
||||
|
||||
### 🚀 核心功能
|
||||
- **纯P2P传输**:文件直接在浏览器间传输,无需上传到服务器
|
||||
- **取件码机制**:6位随机取件码,简单易用
|
||||
- **实时传输**:支持大文件高速传输,64KB分块优化
|
||||
- **多文件支持**:可同时选择和传输多个文件
|
||||
- **拖拽上传**:支持文件拖拽选择
|
||||
- 📁 **文件传输** - 支持多文件同时传输,基于WebRTC的P2P技术
|
||||
- 📝 **文字传输** - 快速分享文本内容,支持大文本传输
|
||||
- 🖥️ **桌面共享** - 实时屏幕共享功能(开发中)
|
||||
- 🔗 **URL路由** - 支持直链分享特定功能和模式
|
||||
|
||||
### 🛡️ 安全特性
|
||||
- **端到端加密**:WebRTC内置加密,数据传输安全
|
||||
- **临时连接**:传输完成后自动清理连接
|
||||
- **无文件存储**:服务器不存储任何文件内容
|
||||
- **房间隔离**:每个取件码对应独立的传输房间
|
||||
## 🛡️ 安全特性
|
||||
|
||||
### 🌐 技术特性
|
||||
- **WebRTC DataChannel**:高效的浏览器间直连
|
||||
- **国内STUN优化**:使用阿里云、腾讯云等国内STUN服务器
|
||||
- **断线重连**:WebSocket连接自动重连机制
|
||||
- **传输进度**:实时显示文件传输进度
|
||||
- **跨平台兼容**:支持现代浏览器(Chrome、Firefox、Safari、Edge)
|
||||
- **端到端加密** - WebRTC内置加密,数据传输安全
|
||||
- **无文件存储** - 服务器不存储任何文件内容
|
||||
- **临时连接** - 传输完成后自动清理连接
|
||||
- **房间隔离** - 每个取件码对应独立的传输房间
|
||||
|
||||
## 🏗️ 系统架构
|
||||
## 🚀 技术栈
|
||||
|
||||
**前端架构**
|
||||
- Next.js 15 + React 18 + TypeScript
|
||||
- Tailwind CSS + 毛玻璃效果UI
|
||||
- WebRTC DataChannel + WebSocket
|
||||
|
||||
**后端架构**
|
||||
- Go + Gin框架 + WebSocket
|
||||
- 内存存储 + 房间管理
|
||||
- Docker容器化部署
|
||||
|
||||
## 📦 快速开始
|
||||
|
||||
### 方式一:Docker一键部署(推荐[未变写完成])
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MatrixSeven/file-transfer-go.git
|
||||
cd file-transfer-go
|
||||
docker-compose up -d
|
||||
|
||||
# 访问应用
|
||||
open http://localhost:8080
|
||||
```
|
||||
|
||||
### 方式二:本地开发
|
||||
|
||||
```bash
|
||||
# 1. 启动后端服务
|
||||
make dev
|
||||
|
||||
# 2. 启动前端服务
|
||||
cd chuan-next
|
||||
yarn
|
||||
yarn dev
|
||||
|
||||
# 访问应用
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
## 🎯 URL路由支持
|
||||
|
||||
支持通过URL参数直接跳转到特定功能:
|
||||
|
||||
```bash
|
||||
# 文件传输
|
||||
/?type=file&mode=send # 发送文件
|
||||
/?type=file&mode=receive # 接收文件
|
||||
|
||||
# 文字传输
|
||||
/?type=text&mode=send # 发送文字
|
||||
/?type=text&mode=receive # 接收文字
|
||||
|
||||
# 桌面共享
|
||||
/?type=desktop&mode=send # 共享桌面
|
||||
/?type=desktop&mode=receive # 观看桌面
|
||||
```
|
||||
|
||||
## 🌟 项目特色
|
||||
|
||||
- ⚡ **零配置** - 无需注册登录,即开即用
|
||||
- 🔒 **点对点** - 基于WebRTC的直接传输,服务器仅做信令
|
||||
- 📱 **响应式** - 完美适配手机、平板、电脑
|
||||
- <20> **现代UI** - 精美的毛玻璃效果,流畅的动画
|
||||
- 🚀 **高性能** - 64KB分块传输,支持大文件高速传输
|
||||
|
||||
## 📊 系统架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||
┌─────────────────┐ WebSocket ┌──────────────┐ WebSocket ┌─────────────────┐
|
||||
│ 发送方 (A) │ ←──────────────→ │ 信令服务器 │ ←──────────────→ │ 接收方 (B) │
|
||||
│ │ │ │ │ │
|
||||
│ - 选择文件 │ │ - 房间管理 │ │ - 输入取件码 │
|
||||
│ - 生成取件码 │ │ - 信令转发 │ │ - 获取文件列表 │
|
||||
│ - 等待连接 │ │ - 状态同步 │ │ - 下载文件 │
|
||||
└─────────────────┘ └──────────────┘ └─────────────────┘
|
||||
│ │
|
||||
│ WebRTC P2P │
|
||||
│ ┌─────────────────┐ │
|
||||
└────────────────────→│ 直接文件传输 │←──────────────────────────────┘
|
||||
│ │
|
||||
│ - 端到端加密 │
|
||||
│ - 高速传输 │
|
||||
│ - 断点续传 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── cmd/ # Go应用入口
|
||||
├── internal/ # Go后端核心代码
|
||||
│ ├── handlers/ # HTTP和WebSocket处理器
|
||||
│ ├── models/ # 数据模型
|
||||
│ └── services/ # 业务服务层
|
||||
├── chuan-next/ # Next.js前端应用
|
||||
│ ├── src/app/ # 应用页面
|
||||
│ ├── src/components/ # 组件库
|
||||
│ └── src/hooks/ # React Hooks
|
||||
├── web/ # 静态资源(测试页面)
|
||||
├── docker-compose.yml # Docker部署配置
|
||||
└── Makefile # 构建脚本
|
||||
```
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
欢迎提交Issue和Pull Request来帮助改进项目!
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License
|
||||
│ 发送方浏览器 │◄────────┤ 信令服务器 ├────────►│ 接收方浏览器 │
|
||||
│ │ │ │ │ │
|
||||
│ ┌───────────┐ │ │ ┌──────────┐ │ │ ┌───────────┐ │
|
||||
|
||||
@@ -1,36 +1,65 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# 传传传 - 跨平台文件传输工具
|
||||
|
||||
## Getting Started
|
||||
> 简单、快速、安全的点对点文件传输解决方案
|
||||
|
||||
First, run the development server:
|
||||
## ✨ 核心功能
|
||||
|
||||
- 📁 **文件传输** - 支持多文件同时传输,断点续传
|
||||
- 📝 **文字传输** - 快速分享文本内容
|
||||
- 🖥️ **桌面共享** - 实时屏幕共享(开发中)
|
||||
- 🔗 **URL路由** - 支持直链分享特定功能
|
||||
|
||||
## 🚀 技术栈
|
||||
|
||||
- **前端**: Next.js 15 + React 18 + TypeScript + Tailwind CSS
|
||||
- **后端**: Go + WebSocket + Gin框架
|
||||
- **部署**: Docker + Docker Compose
|
||||
|
||||
## 📦 快速开始
|
||||
|
||||
### 本地开发
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/MatrixSeven/file-transfer-go.git
|
||||
cd file-transfer-go
|
||||
|
||||
# 启动后端服务
|
||||
make dev
|
||||
|
||||
# 启动前端服务
|
||||
cd chuan-next
|
||||
npm install
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
### Docker部署
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
```bash
|
||||
# 一键启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
# 访问应用
|
||||
open http://localhost:8080
|
||||
```
|
||||
|
||||
## Learn More
|
||||
## 🎯 URL参数
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
支持通过URL直接跳转到特定功能:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
```
|
||||
/?type=file&mode=send # 文件传输-发送
|
||||
/?type=text&mode=receive # 文字传输-接收
|
||||
/?type=desktop&mode=send # 桌面共享-共享
|
||||
```
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
## 🌟 特色
|
||||
|
||||
## Deploy on Vercel
|
||||
- ⚡ **零配置** - 无需注册登录,即开即用
|
||||
- 🔒 **端到端** - 点对点传输,服务器不存储文件
|
||||
- 📱 **响应式** - 完美适配手机、平板、电脑
|
||||
- 🎨 **现代UI** - 精美的毛玻璃效果界面
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
## 📄 许可证
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
MIT License
|
||||
|
||||
54
chuan-next/URL_ROUTING.md
Normal file
54
chuan-next/URL_ROUTING.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# URL 路由参数说明
|
||||
|
||||
现在您可以通过URL参数直接导航到特定的功能和模式。
|
||||
|
||||
## URL 参数格式
|
||||
|
||||
```
|
||||
http://localhost:3000/?type={功能类型}&mode={操作模式}
|
||||
```
|
||||
|
||||
## 支持的参数
|
||||
|
||||
### type(功能类型)
|
||||
- `file` - 文件传输
|
||||
- `text` - 文字传输
|
||||
- `desktop` - 桌面共享
|
||||
|
||||
### mode(操作模式)
|
||||
- `send` - 发送/共享模式
|
||||
- `receive` - 接收/观看模式
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 文件传输
|
||||
- 发送文件:`/?type=file&mode=send`
|
||||
- 接收文件:`/?type=file&mode=receive`
|
||||
|
||||
### 文字传输
|
||||
- 发送文字:`/?type=text&mode=send`
|
||||
- 接收文字:`/?type=text&mode=receive`
|
||||
|
||||
### 桌面共享
|
||||
- 共享桌面:`/?type=desktop&mode=send`
|
||||
- 观看桌面:`/?type=desktop&mode=receive`
|
||||
|
||||
## 功能特性
|
||||
|
||||
1. **自动切换**:访问带参数的URL会自动切换到对应的功能和模式
|
||||
2. **URL同步**:用户手动切换功能和模式时,URL会自动更新
|
||||
3. **无页面刷新**:所有切换都通过客户端路由实现,无需刷新页面
|
||||
4. **向后兼容**:不带参数访问时,默认显示文件传输-发送模式
|
||||
|
||||
## 实际应用场景
|
||||
|
||||
1. **分享链接**:可以直接分享特定功能的链接给其他用户
|
||||
2. **书签管理**:用户可以为常用功能创建书签
|
||||
3. **快速访问**:通过预设链接快速跳转到特定功能
|
||||
4. **集成其他系统**:便于其他系统通过URL直接跳转到特定功能
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 桌面共享功能中,`send`模式对应"共享桌面",`receive`模式对应"观看桌面"
|
||||
- 如果提供了无效的参数值,系统会回退到默认设置
|
||||
- URL参数不区分大小写
|
||||
5849
chuan-next/package-lock.json
generated
Normal file
5849
chuan-next/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import FileUpload from '@/components/FileUpload';
|
||||
import { FileReceive } from '@/components/FileReceive';
|
||||
import Hero from '@/components/Hero';
|
||||
import FileTransfer from '@/components/FileTransfer';
|
||||
import TextTransfer from '@/components/TextTransfer';
|
||||
import DesktopShare from '@/components/DesktopShare';
|
||||
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
import { FileInfo, TransferProgress, WebSocketMessage, RoomStatus } from '@/types';
|
||||
import { Upload, Download } from 'lucide-react';
|
||||
import { Upload, MessageSquare, Monitor } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
|
||||
interface FileTransferData {
|
||||
fileId: string;
|
||||
@@ -22,8 +24,39 @@ interface FileTransferData {
|
||||
|
||||
export default function HomePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { websocket, isConnected, connect, disconnect, sendMessage } = useWebSocket();
|
||||
const { showToast } = useToast();
|
||||
|
||||
// URL参数管理
|
||||
const [activeTab, setActiveTab] = useState<'file' | 'text' | 'desktop'>('file');
|
||||
|
||||
// 从URL参数中获取初始状态
|
||||
useEffect(() => {
|
||||
const type = searchParams.get('type') as 'file' | 'text' | 'desktop';
|
||||
const mode = searchParams.get('mode') as 'send' | 'receive';
|
||||
|
||||
if (type && ['file', 'text', 'desktop'].includes(type)) {
|
||||
setActiveTab(type);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// 更新URL参数
|
||||
const updateUrlParams = useCallback((tab: string, mode?: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', tab);
|
||||
if (mode) {
|
||||
params.set('mode', mode);
|
||||
}
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}, [searchParams, router]);
|
||||
|
||||
// 处理tab切换
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
setActiveTab(value as 'file' | 'text' | 'desktop');
|
||||
updateUrlParams(value);
|
||||
}, [updateUrlParams]);
|
||||
|
||||
// 发送方状态
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [pickupCode, setPickupCode] = useState<string>('');
|
||||
@@ -40,11 +73,13 @@ export default function HomePage() {
|
||||
|
||||
// 文件传输状态
|
||||
const [fileTransfers, setFileTransfers] = useState<Map<string, FileTransferData>>(new Map());
|
||||
const [completedDownloads, setCompletedDownloads] = useState<Set<string>>(new Set());
|
||||
|
||||
// 显示通知
|
||||
const showNotification = useCallback((message: string, type: 'success' | 'error' | 'info' = 'success') => {
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
}, []);
|
||||
showToast(message, type);
|
||||
}, [showToast]);
|
||||
|
||||
// 初始化文件传输
|
||||
const initFileTransfer = useCallback((fileInfo: any) => {
|
||||
@@ -128,6 +163,12 @@ export default function HomePage() {
|
||||
const transfer = newMap.get(transferKey);
|
||||
|
||||
if (transfer) {
|
||||
// 检查是否已经完成,如果已经完成就不再处理新的数据块
|
||||
if (transfer.receivedSize >= transfer.totalSize) {
|
||||
console.log('文件已完成,忽略额外的数据块');
|
||||
return newMap;
|
||||
}
|
||||
|
||||
const chunkArray = new Uint8Array(chunkData.data);
|
||||
transfer.chunks.push({
|
||||
offset: chunkData.offset,
|
||||
@@ -135,6 +176,11 @@ export default function HomePage() {
|
||||
});
|
||||
transfer.receivedSize += chunkArray.length;
|
||||
|
||||
// 确保不超过总大小
|
||||
if (transfer.receivedSize > transfer.totalSize) {
|
||||
transfer.receivedSize = transfer.totalSize;
|
||||
}
|
||||
|
||||
const progress = (transfer.receivedSize / transfer.totalSize) * 100;
|
||||
console.log(`文件 ${transferKey} 进度: ${progress.toFixed(2)}%`);
|
||||
|
||||
@@ -155,8 +201,14 @@ export default function HomePage() {
|
||||
|
||||
// 检查是否完成
|
||||
if (chunkData.is_last || transfer.receivedSize >= transfer.totalSize) {
|
||||
console.log('文件接收完成,开始组装下载');
|
||||
assembleAndDownloadFile(transferKey, transfer);
|
||||
console.log('文件接收完成,准备下载');
|
||||
// 标记为完成,等待 file-complete 消息统一处理下载
|
||||
setTransferProgresses(prev =>
|
||||
prev.map(p => p.fileId === transferKey
|
||||
? { ...p, status: 'completed' as const, progress: 100, receivedSize: transfer.totalSize }
|
||||
: p
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到对应的文件传输:', transferKey);
|
||||
@@ -164,12 +216,36 @@ export default function HomePage() {
|
||||
|
||||
return newMap;
|
||||
});
|
||||
}, [assembleAndDownloadFile]);
|
||||
}, []);
|
||||
|
||||
// 完成文件下载
|
||||
const completeFileDownload = useCallback((fileId: string) => {
|
||||
console.log('文件传输完成:', fileId);
|
||||
}, []);
|
||||
console.log('文件传输完成,开始下载:', fileId);
|
||||
|
||||
// 检查是否已经完成过下载
|
||||
if (completedDownloads.has(fileId)) {
|
||||
console.log('文件已经下载过,跳过重复下载:', fileId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记为已完成
|
||||
setCompletedDownloads(prev => new Set([...prev, fileId]));
|
||||
|
||||
// 查找对应的文件传输数据
|
||||
const transfer = fileTransfers.get(fileId);
|
||||
if (transfer) {
|
||||
assembleAndDownloadFile(fileId, transfer);
|
||||
|
||||
// 清理传输进度,移除已完成的文件进度显示
|
||||
setTimeout(() => {
|
||||
setTransferProgresses(prev =>
|
||||
prev.filter(p => p.fileId !== fileId)
|
||||
);
|
||||
}, 2000); // 2秒后清理,让用户看到完成状态
|
||||
} else {
|
||||
console.warn('未找到文件传输数据:', fileId);
|
||||
}
|
||||
}, [fileTransfers, assembleAndDownloadFile, completedDownloads]);
|
||||
|
||||
// 处理文件请求(发送方)
|
||||
const handleFileRequest = useCallback(async (payload: any) => {
|
||||
@@ -345,6 +421,12 @@ export default function HomePage() {
|
||||
|
||||
// 加入房间
|
||||
const handleJoinRoom = useCallback(async (code: string) => {
|
||||
// 防止重复连接
|
||||
if (isConnecting || (isConnected && pickupCode === code)) {
|
||||
console.log('已在连接中或已连接,跳过重复请求');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
@@ -356,7 +438,7 @@ export default function HomePage() {
|
||||
setCurrentRole('receiver');
|
||||
setReceiverFiles(data.files || []);
|
||||
connect(code, 'receiver');
|
||||
showNotification('连接成功!');
|
||||
showNotification('连接成功!', 'success');
|
||||
} else {
|
||||
showNotification(data.message || '取件码无效或已过期', 'error');
|
||||
setIsConnecting(false);
|
||||
@@ -366,16 +448,16 @@ export default function HomePage() {
|
||||
showNotification('连接失败,请检查网络连接', 'error');
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [connect, showNotification]);
|
||||
}, [connect, showNotification, isConnecting, isConnected, pickupCode]);
|
||||
|
||||
// 处理URL参数中的取件码
|
||||
useEffect(() => {
|
||||
const code = searchParams.get('code');
|
||||
if (code && code.length === 6) {
|
||||
if (code && code.length === 6 && !isConnected && pickupCode !== code.toUpperCase()) {
|
||||
setCurrentRole('receiver');
|
||||
handleJoinRoom(code.toUpperCase());
|
||||
}
|
||||
}, [searchParams, handleJoinRoom]);
|
||||
}, [searchParams]); // 移除依赖,只在URL变化时触发
|
||||
|
||||
// 下载文件
|
||||
const handleDownloadFile = useCallback((fileId: string) => {
|
||||
@@ -385,6 +467,14 @@ export default function HomePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已有同文件的进行中传输
|
||||
const existingProgress = transferProgresses.find(p => p.originalFileId === fileId && p.status !== 'completed');
|
||||
if (existingProgress) {
|
||||
console.log('文件已在下载中,跳过重复请求:', fileId);
|
||||
showNotification('文件正在下载中...', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = 'req_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
console.log('生成请求ID:', requestId);
|
||||
|
||||
@@ -412,7 +502,7 @@ export default function HomePage() {
|
||||
...prev.filter(p => p.originalFileId !== fileId), // 移除该文件的旧进度记录
|
||||
newProgress
|
||||
]);
|
||||
}, [websocket, sendMessage, receiverFiles, showNotification]);
|
||||
}, [websocket, sendMessage, receiverFiles, showNotification, transferProgresses]);
|
||||
|
||||
// 通过WebSocket更新文件列表
|
||||
const updateFileList = useCallback((files: File[]) => {
|
||||
@@ -459,114 +549,158 @@ export default function HomePage() {
|
||||
disconnect();
|
||||
}, [disconnect]);
|
||||
|
||||
// 复制到剪贴板
|
||||
const copyToClipboard = useCallback(async (text: string, message: string) => {
|
||||
// 复制到剪贴板
|
||||
const copyToClipboard = useCallback(async (text: string, successMessage: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showNotification(message);
|
||||
} catch (error) {
|
||||
showNotification(successMessage, 'success');
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err);
|
||||
showNotification('复制失败,请手动复制', 'error');
|
||||
}
|
||||
}, [showNotification]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold mb-2">文件快传</h1>
|
||||
<p className="text-muted-foreground">
|
||||
安全、快速的P2P文件传输服务
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
|
||||
{/* Hero Section */}
|
||||
<div className="relative min-h-screen">
|
||||
{/* Background decorations */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-blue-400/20 to-indigo-600/20 rounded-full blur-3xl"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-br from-purple-400/20 to-pink-400/20 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative container mx-auto px-4 sm:px-6 py-8 max-w-6xl">
|
||||
<Hero />
|
||||
|
||||
<Tabs defaultValue="send" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="send" className="flex items-center space-x-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>发送文件</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="receive" className="flex items-center space-x-2">
|
||||
<Download className="w-4 h-4" />
|
||||
<span>接收文件</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Main Interface */}
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-white/80 backdrop-blur-sm border-0 shadow-lg h-12 sm:h-14 p-1 mb-6">
|
||||
<TabsTrigger
|
||||
value="file"
|
||||
className="flex items-center justify-center space-x-2 text-sm sm:text-base font-medium data-[state=active]:bg-gradient-to-r data-[state=active]:from-blue-500 data-[state=active]:to-indigo-500 data-[state=active]:text-white data-[state=active]:shadow-lg transition-all duration-300"
|
||||
>
|
||||
<Upload className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
<span className="hidden sm:inline">传文件</span>
|
||||
<span className="sm:hidden">文件</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="text"
|
||||
className="flex items-center justify-center space-x-2 text-sm sm:text-base font-medium data-[state=active]:bg-gradient-to-r data-[state=active]:from-emerald-500 data-[state=active]:to-teal-500 data-[state=active]:text-white data-[state=active]:shadow-lg transition-all duration-300"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
<span className="hidden sm:inline">传文字</span>
|
||||
<span className="sm:hidden">文字</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="desktop"
|
||||
className="flex items-center justify-center space-x-2 text-sm sm:text-base font-medium data-[state=active]:bg-gradient-to-r data-[state=active]:from-purple-500 data-[state=active]:to-pink-500 data-[state=active]:text-white data-[state=active]:shadow-lg transition-all duration-300"
|
||||
>
|
||||
<Monitor className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
<span className="hidden sm:inline">共享桌面</span>
|
||||
<span className="sm:hidden">桌面</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="send" className="mt-6">
|
||||
<FileUpload
|
||||
selectedFiles={selectedFiles}
|
||||
onFilesChange={setSelectedFiles}
|
||||
onGenerateCode={handleGenerateCode}
|
||||
pickupCode={pickupCode}
|
||||
pickupLink={pickupLink}
|
||||
onCopyCode={() => copyToClipboard(pickupCode, '取件码已复制到剪贴板!')}
|
||||
onCopyLink={() => copyToClipboard(pickupLink, '取件链接已复制到剪贴板!')}
|
||||
onAddMoreFiles={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.onchange = async (e) => {
|
||||
const files = Array.from((e.target as HTMLInputElement).files || []);
|
||||
const newFiles = [...selectedFiles, ...files];
|
||||
setSelectedFiles(newFiles);
|
||||
|
||||
// 如果已经生成了取件码,更新后端文件列表
|
||||
if (pickupCode && files.length > 0) {
|
||||
updateFileList(newFiles);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
onRemoveFile={handleRemoveFile}
|
||||
onReset={handleReset}
|
||||
disabled={isConnecting}
|
||||
/>
|
||||
|
||||
{roomStatus && currentRole === 'sender' && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>房间状态</CardTitle>
|
||||
<CardDescription>
|
||||
当前在线用户情况
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{roomStatus.sender_count + roomStatus.receiver_count}
|
||||
<TabsContent value="file" className="mt-6 animate-fade-in-up">
|
||||
<FileTransfer
|
||||
selectedFiles={selectedFiles}
|
||||
onFilesChange={setSelectedFiles}
|
||||
onGenerateCode={handleGenerateCode}
|
||||
pickupCode={pickupCode}
|
||||
pickupLink={pickupLink}
|
||||
onCopyCode={() => copyToClipboard(pickupCode, '取件码已复制到剪贴板!')}
|
||||
onCopyLink={() => copyToClipboard(pickupLink, '取件链接已复制到剪贴板!')}
|
||||
onAddMoreFiles={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.onchange = async (e) => {
|
||||
const files = Array.from((e.target as HTMLInputElement).files || []);
|
||||
const newFiles = [...selectedFiles, ...files];
|
||||
setSelectedFiles(newFiles);
|
||||
|
||||
if (pickupCode && files.length > 0) {
|
||||
updateFileList(newFiles);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
onRemoveFile={handleRemoveFile}
|
||||
onReset={handleReset}
|
||||
onJoinRoom={handleJoinRoom}
|
||||
receiverFiles={receiverFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
transferProgresses={transferProgresses}
|
||||
isConnected={isConnected}
|
||||
isConnecting={isConnecting}
|
||||
disabled={isConnecting}
|
||||
/>
|
||||
|
||||
{roomStatus && currentRole === 'sender' && (
|
||||
<div className="mt-6 glass-card rounded-2xl p-6 animate-fade-in-up">
|
||||
<h3 className="text-xl font-semibold text-slate-800 mb-4 text-center">实时状态</h3>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div className="text-center p-4 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl">
|
||||
<div className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
{(roomStatus?.sender_count || 0) + (roomStatus?.receiver_count || 0)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">总在线</div>
|
||||
<div className="text-sm text-slate-600 mt-1">在线用户</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{roomStatus.sender_count}
|
||||
<div className="text-center p-4 bg-gradient-to-br from-emerald-50 to-teal-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-emerald-600">
|
||||
{roomStatus?.sender_count || 0}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">发送方</div>
|
||||
<div className="text-sm text-slate-600 mt-1">发送方</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{roomStatus.receiver_count}
|
||||
<div className="text-center p-4 bg-gradient-to-br from-purple-50 to-pink-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-purple-600">
|
||||
{roomStatus?.receiver_count || 0}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">接收方</div>
|
||||
<div className="text-sm text-slate-600 mt-1">接收方</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="receive" className="mt-6">
|
||||
<FileReceive
|
||||
onJoinRoom={handleJoinRoom}
|
||||
files={receiverFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
transferProgresses={transferProgresses}
|
||||
isConnected={isConnected}
|
||||
isConnecting={isConnecting}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="text" className="mt-6 animate-fade-in-up">
|
||||
<TextTransfer
|
||||
onSendText={async (text: string) => {
|
||||
// TODO: 实现文字传输功能
|
||||
showNotification('文字传输功能开发中', 'info');
|
||||
return 'ABC123'; // 模拟返回取件码
|
||||
}}
|
||||
onReceiveText={async (code: string) => {
|
||||
// TODO: 实现文字接收功能
|
||||
showNotification('文字传输功能开发中', 'info');
|
||||
return '示例文本内容'; // 模拟返回文本
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="desktop" className="mt-6 animate-fade-in-up">
|
||||
<DesktopShare
|
||||
onStartSharing={async () => {
|
||||
// TODO: 实现桌面共享功能
|
||||
showNotification('桌面共享功能开发中', 'info');
|
||||
return 'DEF456'; // 模拟返回连接码
|
||||
}}
|
||||
onStopSharing={async () => {
|
||||
showNotification('桌面共享已停止', 'info');
|
||||
}}
|
||||
onJoinSharing={async (code: string) => {
|
||||
// TODO: 实现桌面查看功能
|
||||
showNotification('桌面共享功能开发中', 'info');
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Bottom spacing */}
|
||||
<div className="h-8 sm:h-16"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ function HomePageWrapper() {
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">加载中...</div>}>
|
||||
<HomePage />
|
||||
</Suspense>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,47 +2,47 @@
|
||||
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--foreground: 225 15% 20%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--card-foreground: 225 15% 20%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--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: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
--popover-foreground: 225 15% 20%;
|
||||
--primary: 262 83% 58%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 14% 96%;
|
||||
--secondary-foreground: 225 15% 20%;
|
||||
--muted: 220 14% 96%;
|
||||
--muted-foreground: 215 13% 55%;
|
||||
--accent: 210 40% 94%;
|
||||
--accent-foreground: 225 15% 20%;
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 262 83% 58%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
.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: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--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: 212.7 26.8% 83.9%;
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 213 31% 91%;
|
||||
--card: 224 71% 4%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 213 31% 91%;
|
||||
--primary: 263 70% 50%;
|
||||
--primary-foreground: 213 31% 91%;
|
||||
--secondary: 215 28% 17%;
|
||||
--secondary-foreground: 213 31% 91%;
|
||||
--muted: 215 28% 17%;
|
||||
--muted-foreground: 217 11% 65%;
|
||||
--accent: 215 28% 17%;
|
||||
--accent-foreground: 213 31% 91%;
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 213 31% 91%;
|
||||
--border: 215 28% 17%;
|
||||
--input: 215 28% 17%;
|
||||
--ring: 263 70% 50%;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -72,10 +72,124 @@
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: hsl(var(--background));
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
color: hsl(var(--foreground));
|
||||
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
overflow-x: hidden;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 现代化组件样式 */
|
||||
.hero-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);
|
||||
border: 2px dashed rgba(102, 126, 234, 0.3);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: rgba(102, 126, 234, 0.6);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.upload-area.drag-active {
|
||||
border-color: hsl(var(--primary));
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.code-display {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-down {
|
||||
animation: slideInDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ToastProvider } from "@/components/ui/toast-simple";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "文件快传",
|
||||
description: "安全、快速、简单的文件传输服务",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,11 +24,13 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<ToastProvider>
|
||||
{children}
|
||||
</ToastProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
269
chuan-next/src/components/DesktopShare.tsx
Normal file
269
chuan-next/src/components/DesktopShare.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Share, Monitor, Copy, Play, Square } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
|
||||
interface DesktopShareProps {
|
||||
onStartSharing?: () => Promise<string>; // 返回连接码
|
||||
onStopSharing?: () => Promise<void>;
|
||||
onJoinSharing?: (code: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function DesktopShare({ onStartSharing, onStopSharing, onJoinSharing }: DesktopShareProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<'share' | 'view'>('share');
|
||||
const [connectionCode, setConnectionCode] = useState('');
|
||||
const [inputCode, setInputCode] = useState('');
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const [isViewing, setIsViewing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 从URL参数中获取初始模式
|
||||
useEffect(() => {
|
||||
const urlMode = searchParams.get('mode');
|
||||
const type = searchParams.get('type');
|
||||
|
||||
if (type === 'desktop' && urlMode) {
|
||||
// 将send映射为share,receive映射为view
|
||||
if (urlMode === 'send') {
|
||||
setMode('share');
|
||||
} else if (urlMode === 'receive') {
|
||||
setMode('view');
|
||||
}
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// 更新URL参数
|
||||
const updateMode = useCallback((newMode: 'share' | 'view') => {
|
||||
setMode(newMode);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', 'desktop');
|
||||
// 将share映射为send,view映射为receive以保持一致性
|
||||
params.set('mode', newMode === 'share' ? 'send' : 'receive');
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}, [searchParams, router]);
|
||||
|
||||
const handleStartSharing = useCallback(async () => {
|
||||
if (!onStartSharing) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const code = await onStartSharing();
|
||||
setConnectionCode(code);
|
||||
setIsSharing(true);
|
||||
showToast('桌面共享已开始!', 'success');
|
||||
} catch (error) {
|
||||
console.error('开始共享失败:', error);
|
||||
showToast('开始共享失败,请重试', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [onStartSharing, showToast]);
|
||||
|
||||
const handleStopSharing = useCallback(async () => {
|
||||
if (!onStopSharing) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onStopSharing();
|
||||
setIsSharing(false);
|
||||
setConnectionCode('');
|
||||
showToast('桌面共享已停止', 'success');
|
||||
} catch (error) {
|
||||
console.error('停止共享失败:', error);
|
||||
showToast('停止共享失败', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [onStopSharing, showToast]);
|
||||
|
||||
const handleJoinSharing = useCallback(async () => {
|
||||
if (!inputCode.trim() || !onJoinSharing) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onJoinSharing(inputCode);
|
||||
setIsViewing(true);
|
||||
showToast('已连接到桌面共享!', 'success');
|
||||
} catch (error) {
|
||||
console.error('连接失败:', error);
|
||||
showToast('连接失败,请检查连接码', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [inputCode, onJoinSharing, showToast]);
|
||||
|
||||
const copyToClipboard = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast('已复制到剪贴板!', 'success');
|
||||
} catch (err) {
|
||||
showToast('复制失败', 'error');
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 模式切换 */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-1 shadow-lg">
|
||||
<Button
|
||||
variant={mode === 'share' ? 'default' : 'ghost'}
|
||||
onClick={() => updateMode('share')}
|
||||
className="px-6 py-2 rounded-lg"
|
||||
>
|
||||
<Share className="w-4 h-4 mr-2" />
|
||||
共享桌面
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === 'view' ? 'default' : 'ghost'}
|
||||
onClick={() => updateMode('view')}
|
||||
className="px-6 py-2 rounded-lg"
|
||||
>
|
||||
<Monitor className="w-4 h-4 mr-2" />
|
||||
观看桌面
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'share' ? (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-purple-500 to-pink-500 rounded-2xl flex items-center justify-center animate-float">
|
||||
<Share className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2">共享桌面</h2>
|
||||
<p className="text-sm sm:text-base text-slate-600">
|
||||
{isSharing ? '桌面共享进行中' : '开始共享您的桌面屏幕'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!isSharing ? (
|
||||
<Button
|
||||
onClick={handleStartSharing}
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white text-lg font-medium rounded-xl shadow-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
启动中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-5 h-5 mr-2" />
|
||||
开始共享桌面
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gradient-to-r from-purple-50 to-pink-50 rounded-xl border border-purple-200">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-purple-700 mb-2">连接码</p>
|
||||
<div className="text-2xl font-bold font-mono text-purple-600 mb-3">{connectionCode}</div>
|
||||
<Button
|
||||
onClick={() => copyToClipboard(connectionCode)}
|
||||
size="sm"
|
||||
className="bg-purple-500 hover:bg-purple-600 text-white"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
复制连接码
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleStopSharing}
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 bg-gradient-to-r from-red-500 to-pink-500 hover:from-red-600 hover:to-pink-600 text-white text-lg font-medium rounded-xl shadow-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
停止中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Square className="w-5 h-5 mr-2" />
|
||||
停止共享
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-2xl flex items-center justify-center animate-float">
|
||||
<Monitor className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2">观看桌面</h2>
|
||||
<p className="text-sm sm:text-base text-slate-600">
|
||||
{isViewing ? '正在观看桌面共享' : '输入连接码观看他人的桌面'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!isViewing ? (
|
||||
<>
|
||||
<Input
|
||||
value={inputCode}
|
||||
onChange={(e) => setInputCode(e.target.value.toUpperCase().slice(0, 6))}
|
||||
placeholder="请输入连接码"
|
||||
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-indigo-500 focus:ring-indigo-500 bg-white/80 backdrop-blur-sm"
|
||||
maxLength={6}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleJoinSharing}
|
||||
disabled={inputCode.length !== 6 || isLoading}
|
||||
className="w-full h-12 bg-gradient-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white text-lg font-medium rounded-xl shadow-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
连接中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Monitor className="w-5 h-5 mr-2" />
|
||||
连接桌面
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="aspect-video bg-slate-900 rounded-xl flex items-center justify-center text-white">
|
||||
<div className="text-center">
|
||||
<Monitor className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm opacity-75">桌面共享画面</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsViewing(false)}
|
||||
className="w-full h-12 bg-gradient-to-r from-red-500 to-pink-500 hover:from-red-600 hover:to-pink-600 text-white text-lg font-medium rounded-xl shadow-lg"
|
||||
>
|
||||
<Square className="w-5 h-5 mr-2" />
|
||||
断开连接
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,11 +18,11 @@ interface FileReceiveProps {
|
||||
}
|
||||
|
||||
const getFileIcon = (mimeType: string) => {
|
||||
if (mimeType.startsWith('image/')) return <Image className="w-5 h-5" />;
|
||||
if (mimeType.startsWith('video/')) return <Video className="w-5 h-5" />;
|
||||
if (mimeType.startsWith('audio/')) return <Music className="w-5 h-5" />;
|
||||
if (mimeType.includes('zip') || mimeType.includes('rar')) return <Archive className="w-5 h-5" />;
|
||||
return <FileText className="w-5 h-5" />;
|
||||
if (mimeType.startsWith('image/')) return <Image className="w-5 h-5 text-white" />;
|
||||
if (mimeType.startsWith('video/')) return <Video className="w-5 h-5 text-white" />;
|
||||
if (mimeType.startsWith('audio/')) return <Music className="w-5 h-5 text-white" />;
|
||||
if (mimeType.includes('zip') || mimeType.includes('rar')) return <Archive className="w-5 h-5 text-white" />;
|
||||
return <FileText className="w-5 h-5 text-white" />;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
@@ -60,110 +60,163 @@ export function FileReceive({
|
||||
// 如果已经连接并且有文件列表,显示文件列表
|
||||
if (files.length > 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>可下载文件 ({files.length})</CardTitle>
|
||||
<CardDescription>
|
||||
{isConnected ? (
|
||||
<span className="text-green-600">✅ 已连接,可以下载文件</span>
|
||||
) : (
|
||||
<span className="text-yellow-600">⏳ 正在建立连接...</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-4 sm:mb-6 gap-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg sm:text-xl font-semibold text-slate-800">可下载文件</h3>
|
||||
<p className="text-slate-500 text-sm">
|
||||
{isConnected ? (
|
||||
<span className="text-emerald-600">✅ 已连接,可以下载文件</span>
|
||||
) : (
|
||||
<span className="text-amber-600">⏳ 正在建立连接...</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-r from-emerald-100 to-teal-100 px-3 sm:px-4 py-2 rounded-full self-start sm:self-center">
|
||||
<span className="text-emerald-700 font-medium text-sm">{files.length} 个文件</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{files.map((file) => {
|
||||
const progress = transferProgresses.find(p => p.originalFileId === file.id);
|
||||
const isDownloading = progress && progress.status === 'downloading';
|
||||
|
||||
console.log(`文件 ${file.id} 进度状态:`, progress, '是否下载中:', isDownloading);
|
||||
const isCompleted = progress && progress.status === 'completed';
|
||||
|
||||
return (
|
||||
<div key={file.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<div className="text-muted-foreground">
|
||||
<div key={file.id} className="bg-gradient-to-r from-slate-50 to-blue-50 border border-slate-200 rounded-xl p-3 sm:p-4 hover:shadow-md transition-all duration-200">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-3 gap-3">
|
||||
<div className="flex items-center space-x-3 sm:space-x-4 flex-1 min-w-0">
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
{getFileIcon(file.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{file.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
<p className="font-medium text-slate-800 truncate text-sm sm:text-base">{file.name}</p>
|
||||
<p className="text-sm text-slate-500">{formatFileSize(file.size)}</p>
|
||||
{isCompleted && (
|
||||
<p className="text-xs text-emerald-600 font-medium">✅ 下载完成</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => onDownloadFile(file.id)}
|
||||
disabled={!isConnected || isDownloading}
|
||||
size="sm"
|
||||
disabled={!isConnected || isDownloading || isCompleted}
|
||||
className={`px-6 py-2 rounded-lg font-medium shadow-lg transition-all duration-200 hover:shadow-xl ${
|
||||
isCompleted
|
||||
? 'bg-slate-300 text-slate-500 cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white'
|
||||
}`}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{isDownloading ? '下载中...' : '下载'}
|
||||
{isDownloading ? '下载中...' : isCompleted ? '已完成' : '下载'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{progress && progress.status === 'downloading' && (
|
||||
<div className="px-3">
|
||||
<div className="flex justify-between text-sm text-muted-foreground mb-1">
|
||||
<span>正在下载...</span>
|
||||
<span>{progress.progress.toFixed(1)}%</span>
|
||||
{progress && (progress.status === 'downloading' || progress.status === 'completed') && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex justify-between text-sm text-slate-600">
|
||||
<span>{progress.status === 'completed' ? '下载完成' : '正在下载...'}</span>
|
||||
<span className="font-medium">{progress.progress.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={progress.progress} className="h-2" />
|
||||
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
||||
<div className="w-full bg-slate-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
progress.status === 'completed'
|
||||
? 'bg-gradient-to-r from-emerald-500 to-emerald-600'
|
||||
: 'bg-gradient-to-r from-emerald-500 to-teal-500'
|
||||
}`}
|
||||
style={{ width: `${progress.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-500">
|
||||
<span>{formatFileSize(progress.receivedSize)} / {formatFileSize(progress.totalSize)}</span>
|
||||
{progress.status === 'downloading' && (
|
||||
<span>预计还需 {Math.ceil((progress.totalSize - progress.receivedSize) / 1024 / 1024)} MB</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<div className="px-3 text-xs text-muted-foreground">
|
||||
调试:状态={progress.status}, 进度={progress.progress}%, 文件ID={progress.originalFileId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 显示取件码输入界面
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>输入取件码</CardTitle>
|
||||
<CardDescription>
|
||||
请输入6位取件码来获取文件
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 md:p-8 animate-fade-in-up">
|
||||
<div className="text-center mb-6 sm:mb-8">
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-2xl flex items-center justify-center animate-float">
|
||||
<Download className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2">输入取件码</h2>
|
||||
<p className="text-sm sm:text-base text-slate-600">请输入6位取件码来获取文件</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={pickupCode}
|
||||
onChange={handleInputChange}
|
||||
placeholder="输入6位取件码"
|
||||
className="text-center text-2xl tracking-wider font-mono"
|
||||
placeholder="请输入取件码"
|
||||
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-emerald-500 focus:ring-emerald-500 bg-white/80 backdrop-blur-sm pb-2 sm:pb-4"
|
||||
maxLength={6}
|
||||
disabled={isConnecting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{pickupCode.length}/6 位
|
||||
</p>
|
||||
<div className="absolute inset-x-0 -bottom-4 sm:-bottom-6 flex justify-center space-x-1 sm:space-x-2">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full transition-all duration-200 ${
|
||||
i < pickupCode.length
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-slate-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={pickupCode.length !== 6 || isConnecting}
|
||||
>
|
||||
{isConnecting ? '连接中...' : '连接'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="h-3 sm:h-4"></div>
|
||||
<p className="text-center text-xs sm:text-sm text-slate-500">
|
||||
{pickupCode.length}/6 位
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-10 sm:h-12 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white text-base sm:text-lg font-medium rounded-xl shadow-lg transition-all duration-200 hover:shadow-xl hover:scale-105 disabled:opacity-50 disabled:scale-100"
|
||||
disabled={pickupCode.length !== 6 || isConnecting}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>连接中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Download className="w-5 h-5" />
|
||||
<span>开始接收</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* 使用提示 */}
|
||||
<div className="mt-6 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200">
|
||||
<p className="text-sm text-slate-600 text-center">
|
||||
💡 <span className="font-medium">提示:</span>取件码由发送方提供,有效期为24小时
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
132
chuan-next/src/components/FileTransfer.tsx
Normal file
132
chuan-next/src/components/FileTransfer.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Upload, Download } from 'lucide-react';
|
||||
import FileUpload from '@/components/FileUpload';
|
||||
import { FileReceive } from '@/components/FileReceive';
|
||||
import { FileInfo, TransferProgress } from '@/types';
|
||||
|
||||
interface FileTransferProps {
|
||||
// 发送方相关
|
||||
selectedFiles: File[];
|
||||
onFilesChange: (files: File[]) => void;
|
||||
onGenerateCode: () => void;
|
||||
pickupCode: string;
|
||||
pickupLink: string;
|
||||
onCopyCode: () => void;
|
||||
onCopyLink: () => void;
|
||||
onAddMoreFiles: () => void;
|
||||
onRemoveFile: (updatedFiles: File[]) => void;
|
||||
onReset: () => void;
|
||||
|
||||
// 接收方相关
|
||||
onJoinRoom: (code: string) => void;
|
||||
receiverFiles: FileInfo[];
|
||||
onDownloadFile: (fileId: string) => void;
|
||||
transferProgresses: TransferProgress[];
|
||||
|
||||
// 通用状态
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function FileTransfer({
|
||||
selectedFiles,
|
||||
onFilesChange,
|
||||
onGenerateCode,
|
||||
pickupCode,
|
||||
pickupLink,
|
||||
onCopyCode,
|
||||
onCopyLink,
|
||||
onAddMoreFiles,
|
||||
onRemoveFile,
|
||||
onReset,
|
||||
onJoinRoom,
|
||||
receiverFiles,
|
||||
onDownloadFile,
|
||||
transferProgresses,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
disabled = false
|
||||
}: FileTransferProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<'send' | 'receive'>('send');
|
||||
|
||||
// 从URL参数中获取初始模式
|
||||
useEffect(() => {
|
||||
const urlMode = searchParams.get('mode') as 'send' | 'receive';
|
||||
const type = searchParams.get('type');
|
||||
|
||||
if (type === 'file' && urlMode && ['send', 'receive'].includes(urlMode)) {
|
||||
setMode(urlMode);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// 更新URL参数
|
||||
const updateMode = useCallback((newMode: 'send' | 'receive') => {
|
||||
setMode(newMode);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', 'file');
|
||||
params.set('mode', newMode);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}, [searchParams, router]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 模式切换 */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-1 shadow-lg">
|
||||
<Button
|
||||
variant={mode === 'send' ? 'default' : 'ghost'}
|
||||
onClick={() => updateMode('send')}
|
||||
className="px-6 py-2 rounded-lg"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
发送文件
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === 'receive' ? 'default' : 'ghost'}
|
||||
onClick={() => updateMode('receive')}
|
||||
className="px-6 py-2 rounded-lg"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
接收文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'send' ? (
|
||||
<div className="animate-fade-in-up">
|
||||
<FileUpload
|
||||
selectedFiles={selectedFiles}
|
||||
onFilesChange={onFilesChange}
|
||||
onGenerateCode={onGenerateCode}
|
||||
pickupCode={pickupCode}
|
||||
pickupLink={pickupLink}
|
||||
onCopyCode={onCopyCode}
|
||||
onCopyLink={onCopyLink}
|
||||
onAddMoreFiles={onAddMoreFiles}
|
||||
onRemoveFile={onRemoveFile}
|
||||
onReset={onReset}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-fade-in-up">
|
||||
<FileReceive
|
||||
onJoinRoom={onJoinRoom}
|
||||
files={receiverFiles}
|
||||
onDownloadFile={onDownloadFile}
|
||||
transferProgresses={transferProgresses}
|
||||
isConnected={isConnected}
|
||||
isConnecting={isConnecting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,11 +20,11 @@ interface FileUploadProps {
|
||||
}
|
||||
|
||||
const getFileIcon = (mimeType: string) => {
|
||||
if (mimeType.startsWith('image/')) return <Image className="w-5 h-5" />;
|
||||
if (mimeType.startsWith('video/')) return <Video className="w-5 h-5" />;
|
||||
if (mimeType.startsWith('audio/')) return <Music className="w-5 h-5" />;
|
||||
if (mimeType.includes('zip') || mimeType.includes('rar')) return <Archive className="w-5 h-5" />;
|
||||
return <FileText className="w-5 h-5" />;
|
||||
if (mimeType.startsWith('image/')) return <Image className="w-5 h-5 text-white" />;
|
||||
if (mimeType.startsWith('video/')) return <Video className="w-5 h-5 text-white" />;
|
||||
if (mimeType.startsWith('audio/')) return <Music className="w-5 h-5 text-white" />;
|
||||
if (mimeType.includes('zip') || mimeType.includes('rar')) return <Archive className="w-5 h-5 text-white" />;
|
||||
return <FileText className="w-5 h-5 text-white" />;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
@@ -95,72 +95,84 @@ export default function FileUpload({
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Upload className="w-5 h-5" />
|
||||
<span>选择文件</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
选择要发送的文件,支持拖拽上传
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
isDragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-primary/50'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-lg font-medium mb-2">拖拽文件到这里或点击选择</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
支持多个文件同时上传
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="glass-card rounded-2xl p-8 animate-fade-in-up">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-2xl flex items-center justify-center animate-float">
|
||||
<Upload className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<h2 className="text-2xl font-semibold text-slate-800 mb-2">选择文件</h2>
|
||||
<p className="text-slate-600">拖拽文件到下方区域或点击选择文件</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`upload-area rounded-xl p-6 sm:p-8 md:p-12 text-center cursor-pointer ${
|
||||
isDragOver ? 'drag-active' : ''
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className={`transition-all duration-300 ${isDragOver ? 'scale-110' : ''}`}>
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 mx-auto mb-4 sm:mb-6 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center">
|
||||
<Upload className={`w-8 h-8 sm:w-10 sm:h-10 transition-colors duration-300 ${
|
||||
isDragOver ? 'text-blue-600' : 'text-slate-400'
|
||||
}`} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg sm:text-xl font-medium text-slate-700">
|
||||
{isDragOver ? '释放文件' : '拖拽文件到这里'}
|
||||
</p>
|
||||
<p className="text-sm sm:text-base text-slate-500">
|
||||
或者 <span className="text-blue-600 font-medium underline">点击选择文件</span>
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-slate-400 mt-4">
|
||||
支持多个文件同时上传,无大小限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Upload className="w-5 h-5" />
|
||||
<span>已选择文件 ({selectedFiles.length})</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
文件列表,点击生成取件码开始传输
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 文件列表 */}
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-4 sm:mb-6 gap-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg sm:text-xl font-semibold text-slate-800">已选择文件</h3>
|
||||
<p className="text-slate-500 text-sm">{selectedFiles.length} 个文件准备传输</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-4 sm:mb-6">
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div
|
||||
key={`${file.name}-${file.size}-${index}`}
|
||||
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
|
||||
className="group flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-blue-50 border border-slate-200 rounded-xl hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{getFileIcon(file.type)}
|
||||
<div>
|
||||
<p className="font-medium">{file.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
{getFileIcon(file.type)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-slate-800 truncate text-sm sm:text-base">{file.name}</p>
|
||||
<p className="text-xs sm:text-sm text-slate-500">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -168,99 +180,127 @@ export default function FileUpload({
|
||||
size="sm"
|
||||
onClick={() => removeFile(index)}
|
||||
disabled={disabled}
|
||||
className="text-destructive hover:text-destructive"
|
||||
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 hover:bg-red-50 transition-all duration-200 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-4">
|
||||
{!pickupCode && (
|
||||
<>
|
||||
<Button
|
||||
onClick={onGenerateCode}
|
||||
disabled={disabled || selectedFiles.length === 0}
|
||||
className="flex-1 min-w-[120px]"
|
||||
>
|
||||
生成取件码
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onAddMoreFiles}
|
||||
disabled={disabled}
|
||||
>
|
||||
添加更多文件
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{pickupCode && (
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{!pickupCode && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onAddMoreFiles}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
onClick={onGenerateCode}
|
||||
disabled={disabled || selectedFiles.length === 0}
|
||||
className="button-primary text-white px-6 sm:px-8 py-3 rounded-xl font-medium flex-1 min-w-0 shadow-lg"
|
||||
>
|
||||
添加更多文件
|
||||
<Upload className="w-5 h-5 mr-2" />
|
||||
生成取件码
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={onAddMoreFiles}
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className="px-6 sm:px-8 py-3 rounded-xl font-medium"
|
||||
>
|
||||
添加文件
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onReset}
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className="text-red-600 hover:bg-red-50 px-6 sm:px-8 py-3 rounded-xl font-medium"
|
||||
>
|
||||
重新选择
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{pickupCode && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onReset}
|
||||
onClick={onAddMoreFiles}
|
||||
disabled={disabled}
|
||||
className="px-6 py-3 rounded-xl border-slate-300 text-slate-600 hover:bg-slate-50 flex-1"
|
||||
>
|
||||
重置
|
||||
添加更多文件
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onReset}
|
||||
disabled={disabled}
|
||||
className="px-6 py-3 rounded-xl border-slate-300 text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 取件码展示 */}
|
||||
{pickupCode && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>取件码生成成功</CardTitle>
|
||||
<CardDescription>
|
||||
分享以下信息给接收方
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 md:p-8 animate-fade-in-up">
|
||||
<div className="text-center mb-4 sm:mb-6">
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-2xl flex items-center justify-center animate-float">
|
||||
<FileText className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl sm:text-2xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent mb-2">
|
||||
取件码生成成功!
|
||||
</h3>
|
||||
<p className="text-sm sm:text-base text-slate-600">分享以下信息给接收方</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 取件码 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium">取件码</label>
|
||||
<div className="flex space-x-2 mt-1">
|
||||
<div className="flex-1 p-3 bg-muted rounded-lg font-mono text-lg text-center">
|
||||
{pickupCode}
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">取件码</label>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1 code-display rounded-xl p-4 sm:p-6 text-center">
|
||||
<div className="text-2xl sm:text-3xl font-bold font-mono bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent tracking-wider">
|
||||
{pickupCode}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCopyCode}
|
||||
size="sm"
|
||||
className="px-4 sm:px-6 py-3 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl font-medium shadow-lg transition-all duration-200 hover:shadow-xl w-full sm:w-auto"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 取件链接 */}
|
||||
{pickupLink && (
|
||||
<div>
|
||||
<label className="text-sm font-medium">取件链接</label>
|
||||
<div className="flex space-x-2 mt-1">
|
||||
<div className="flex-1 p-3 bg-muted rounded-lg text-sm break-all">
|
||||
{pickupLink}
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">取件链接</label>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1 code-display rounded-xl p-3 sm:p-4">
|
||||
<div className="text-xs sm:text-sm text-slate-700 break-all font-mono">
|
||||
{pickupLink}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCopyLink}
|
||||
size="sm"
|
||||
className="px-4 sm:px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-xl font-medium shadow-lg transition-all duration-200 hover:shadow-xl w-full sm:w-auto"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 使用提示 */}
|
||||
<div className="mt-4 sm:mt-6 p-3 sm:p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200">
|
||||
<p className="text-xs sm:text-sm text-slate-600 text-center">
|
||||
💡 <span className="font-medium">使用提示:</span>接收方输入取件码或访问取件链接即可下载文件
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
18
chuan-next/src/components/Hero.tsx
Normal file
18
chuan-next/src/components/Hero.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<div className="text-center mb-8 sm:mb-12 animate-fade-in-up">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent mb-4">
|
||||
文件快传
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg text-slate-600 max-w-2xl mx-auto leading-relaxed px-4">
|
||||
安全、快速、简单的传输服务
|
||||
<br />
|
||||
<span className="text-sm sm:text-base text-slate-500">支持文件、文字、桌面共享 - 无需注册,即传即用</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
228
chuan-next/src/components/TextTransfer.tsx
Normal file
228
chuan-next/src/components/TextTransfer.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { MessageSquare, Copy, Send, Download } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
|
||||
interface TextTransferProps {
|
||||
onSendText?: (text: string) => Promise<string>; // 返回取件码
|
||||
onReceiveText?: (code: string) => Promise<string>; // 返回文本内容
|
||||
}
|
||||
|
||||
export default function TextTransfer({ onSendText, onReceiveText }: TextTransferProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<'send' | 'receive'>('send');
|
||||
const [textContent, setTextContent] = useState('');
|
||||
const [pickupCode, setPickupCode] = useState('');
|
||||
const [receivedText, setReceivedText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 从URL参数中获取初始模式
|
||||
useEffect(() => {
|
||||
const urlMode = searchParams.get('mode') as 'send' | 'receive';
|
||||
const type = searchParams.get('type');
|
||||
|
||||
if (type === 'text' && urlMode && ['send', 'receive'].includes(urlMode)) {
|
||||
setMode(urlMode);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// 更新URL参数
|
||||
const updateMode = useCallback((newMode: 'send' | 'receive') => {
|
||||
setMode(newMode);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', 'text');
|
||||
params.set('mode', newMode);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}, [searchParams, router]);
|
||||
|
||||
const handleSendText = useCallback(async () => {
|
||||
if (!textContent.trim() || !onSendText) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const code = await onSendText(textContent);
|
||||
setPickupCode(code);
|
||||
showToast('文本已生成取件码!', 'success');
|
||||
} catch (error) {
|
||||
console.error('发送文本失败:', error);
|
||||
showToast('发送失败,请重试', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [textContent, onSendText, showToast]);
|
||||
|
||||
const handleReceiveText = useCallback(async () => {
|
||||
if (!pickupCode.trim() || !onReceiveText) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const text = await onReceiveText(pickupCode);
|
||||
setReceivedText(text);
|
||||
showToast('文本接收成功!', 'success');
|
||||
} catch (error) {
|
||||
console.error('接收文本失败:', error);
|
||||
showToast('接收失败,请检查取件码', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [pickupCode, onReceiveText, showToast]);
|
||||
|
||||
const copyToClipboard = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast('已复制到剪贴板!', 'success');
|
||||
} catch (err) {
|
||||
showToast('复制失败', 'error');
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 模式切换 */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-1 shadow-lg">
|
||||
<Button
|
||||
variant={mode === 'send' ? 'default' : 'ghost'}
|
||||
onClick={() => updateMode('send')}
|
||||
className="px-6 py-2 rounded-lg"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
发送文本
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === 'receive' ? 'default' : 'ghost'}
|
||||
onClick={() => updateMode('receive')}
|
||||
className="px-6 py-2 rounded-lg"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
接收文本
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'send' ? (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-2xl flex items-center justify-center animate-float">
|
||||
<MessageSquare className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2">传送文字</h2>
|
||||
<p className="text-sm sm:text-base text-slate-600">输入要传输的文本内容</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
value={textContent}
|
||||
onChange={(e) => setTextContent(e.target.value)}
|
||||
placeholder="在这里输入要传输的文本内容..."
|
||||
className="w-full min-h-[200px] p-4 border-2 border-slate-200 rounded-xl focus:border-blue-500 focus:ring-blue-500 bg-white/80 backdrop-blur-sm resize-none"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between text-sm text-slate-500">
|
||||
<span>{textContent.length} 字符</span>
|
||||
<span>最大 10,000 字符</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSendText}
|
||||
disabled={!textContent.trim() || textContent.length > 10000 || isLoading}
|
||||
className="w-full h-12 bg-gradient-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600 text-white text-lg font-medium rounded-xl shadow-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-5 h-5 mr-2" />
|
||||
生成取件码
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{pickupCode && (
|
||||
<div className="mt-6 p-4 bg-gradient-to-r from-emerald-50 to-teal-50 rounded-xl border border-emerald-200">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-emerald-700 mb-2">取件码生成成功!</p>
|
||||
<div className="text-2xl font-bold font-mono text-emerald-600 mb-3">{pickupCode}</div>
|
||||
<Button
|
||||
onClick={() => copyToClipboard(pickupCode)}
|
||||
size="sm"
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
复制取件码
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-2xl flex items-center justify-center animate-float">
|
||||
<Download className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2">接收文字</h2>
|
||||
<p className="text-sm sm:text-base text-slate-600">输入6位取件码来获取文本</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
value={pickupCode}
|
||||
onChange={(e) => setPickupCode(e.target.value.toUpperCase().slice(0, 6))}
|
||||
placeholder="请输入取件码"
|
||||
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-emerald-500 focus:ring-emerald-500 bg-white/80 backdrop-blur-sm"
|
||||
maxLength={6}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleReceiveText}
|
||||
disabled={pickupCode.length !== 6 || isLoading}
|
||||
className="w-full h-12 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white text-lg font-medium rounded-xl shadow-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
获取中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-5 h-5 mr-2" />
|
||||
获取文本
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{receivedText && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<textarea
|
||||
value={receivedText}
|
||||
readOnly
|
||||
className="w-full min-h-[200px] p-4 border-2 border-emerald-200 rounded-xl bg-emerald-50/50 backdrop-blur-sm resize-none"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => copyToClipboard(receivedText)}
|
||||
className="w-full h-12 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white text-lg font-medium rounded-xl shadow-lg"
|
||||
>
|
||||
<Copy className="w-5 h-5 mr-2" />
|
||||
复制文本
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
chuan-next/src/components/ui/textarea.tsx
Normal file
24
chuan-next/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
93
chuan-next/src/components/ui/toast-simple.tsx
Normal file
93
chuan-next/src/components/ui/toast-simple.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (message: string, type?: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: 'success' | 'error' | 'info' = 'success') => {
|
||||
const id = Date.now().toString();
|
||||
const newToast = { id, message, type };
|
||||
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
|
||||
// 自动移除toast
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
|
||||
{/* Toast 容器 */}
|
||||
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`
|
||||
max-w-sm p-4 rounded-xl shadow-lg backdrop-blur-sm transform transition-all duration-300 ease-in-out
|
||||
${toast.type === 'success' ? 'bg-emerald-50/90 border border-emerald-200 text-emerald-800' : ''}
|
||||
${toast.type === 'error' ? 'bg-red-50/90 border border-red-200 text-red-800' : ''}
|
||||
${toast.type === 'info' ? 'bg-blue-50/90 border border-blue-200 text-blue-800' : ''}
|
||||
animate-slide-in-down
|
||||
`}
|
||||
onClick={() => removeToast(toast.id)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
{toast.type === 'success' && (
|
||||
<div className="w-6 h-6 bg-emerald-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{toast.type === 'error' && (
|
||||
<div className="w-6 h-6 bg-red-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{toast.type === 'info' && (
|
||||
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium">{toast.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
131
chuan-next/src/components/ui/toast.tsx
Normal file
131
chuan-next/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
|
||||
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",
|
||||
success:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-900",
|
||||
},
|
||||
},
|
||||
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,
|
||||
}
|
||||
35
chuan-next/src/components/ui/toaster.tsx
Normal file
35
chuan-next/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
193
chuan-next/src/hooks/use-toast.ts
Normal file
193
chuan-next/src/hooks/use-toast.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user