Spaces:
Sleeping
Sleeping
| import React, { useRef, useState } from 'react'; | |
| import { Button } from './ui/button'; | |
| import { Upload, File, X, FileText, Presentation, CloudUpload } from 'lucide-react'; | |
| import { Card } from './ui/card'; | |
| import { Badge } from './ui/badge'; | |
| import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; | |
| import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'; | |
| import type { UploadedFile, FileType } from '../App'; | |
| interface FileUploadAreaProps { | |
| uploadedFiles: UploadedFile[]; | |
| onFileUpload: (files: File[]) => void; | |
| onRemoveFile: (index: number) => void; | |
| onFileTypeChange: (index: number, type: FileType) => void; | |
| // ✅ 新增:真正触发后端上传(App 里实现) | |
| onUploadFile?: (index: number) => void; | |
| onUploadAll?: () => void; | |
| disabled?: boolean; | |
| } | |
| interface PendingFile { | |
| file: File; | |
| type: FileType; | |
| } | |
| export function FileUploadArea({ | |
| uploadedFiles, | |
| onFileUpload, | |
| onRemoveFile, | |
| onFileTypeChange, | |
| onUploadFile, | |
| onUploadAll, | |
| disabled = false, | |
| }: FileUploadAreaProps) { | |
| const [isDragging, setIsDragging] = useState(false); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]); | |
| const [showTypeDialog, setShowTypeDialog] = useState(false); | |
| const handleDragOver = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| if (!disabled) setIsDragging(true); | |
| }; | |
| const handleDragLeave = () => { | |
| setIsDragging(false); | |
| }; | |
| const handleDrop = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| setIsDragging(false); | |
| if (disabled) return; | |
| const files = Array.from(e.dataTransfer.files).filter((file) => | |
| ['.pdf', '.docx', '.pptx'].some((ext) => file.name.toLowerCase().endsWith(ext)) | |
| ); | |
| if (files.length > 0) { | |
| setPendingFiles(files.map((file) => ({ file, type: 'other' as FileType }))); | |
| setShowTypeDialog(true); | |
| } | |
| }; | |
| const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const files = Array.from(e.target.files || []); | |
| if (files.length > 0) { | |
| setPendingFiles(files.map((file) => ({ file, type: 'other' as FileType }))); | |
| setShowTypeDialog(true); | |
| } | |
| e.target.value = ''; | |
| }; | |
| const handleConfirmUpload = () => { | |
| // 这里只“入库”,不触发后端上传(符合你现在的逻辑) | |
| onFileUpload(pendingFiles.map((pf) => pf.file)); | |
| // 把用户在弹窗里选的 type 同步到父组件列表(通过 index 偏移) | |
| const startIndex = uploadedFiles.length; | |
| pendingFiles.forEach((pf, idx) => { | |
| setTimeout(() => { | |
| onFileTypeChange(startIndex + idx, pf.type); | |
| }, 0); | |
| }); | |
| setPendingFiles([]); | |
| setShowTypeDialog(false); | |
| }; | |
| const handleCancelUpload = () => { | |
| setPendingFiles([]); | |
| setShowTypeDialog(false); | |
| }; | |
| const handlePendingFileTypeChange = (index: number, type: FileType) => { | |
| setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf))); | |
| }; | |
| const getFileIcon = (filename: string) => { | |
| const lower = filename.toLowerCase(); | |
| if (lower.endsWith('.pdf')) return FileText; | |
| if (lower.endsWith('.docx')) return File; | |
| if (lower.endsWith('.pptx')) return Presentation; | |
| return File; | |
| }; | |
| const formatFileSize = (bytes: number) => { | |
| if (bytes < 1024) return bytes + ' B'; | |
| if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; | |
| return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; | |
| }; | |
| const hasPendingUploads = uploadedFiles.some((f) => !f.uploaded); | |
| return ( | |
| <Card className="p-4 space-y-3"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <h4 className="text-sm">Course Materials</h4> | |
| {uploadedFiles.length > 0 && <Badge variant="secondary">{uploadedFiles.length} file(s)</Badge>} | |
| </div> | |
| {/* ✅ Upload All Pending(只在存在 pending 且提供了回调时显示) */} | |
| {uploadedFiles.length > 0 && hasPendingUploads && !!onUploadAll && ( | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className="h-8 text-xs gap-2" | |
| disabled={disabled} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| onUploadAll(); | |
| }} | |
| title="Upload all pending files" | |
| > | |
| <CloudUpload className="h-4 w-4" /> | |
| Upload All | |
| </Button> | |
| )} | |
| </div> | |
| {/* Upload Area */} | |
| <div | |
| onDragOver={handleDragOver} | |
| onDragLeave={handleDragLeave} | |
| onDrop={handleDrop} | |
| className={` | |
| border-2 border-dashed rounded-lg p-4 text-center transition-colors | |
| ${isDragging ? 'border-primary bg-accent' : 'border-border'} | |
| ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} | |
| `} | |
| onClick={() => !disabled && fileInputRef.current?.click()} | |
| > | |
| <Upload className="h-6 w-6 mx-auto mb-2 text-muted-foreground" /> | |
| <p className="text-sm text-muted-foreground mb-1">{disabled ? 'Please log in to upload' : 'Drop files or click to upload'}</p> | |
| <p className="text-xs text-muted-foreground">.pdf, .docx, .pptx</p> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| multiple | |
| accept=".pdf,.docx,.pptx" | |
| onChange={handleFileSelect} | |
| className="hidden" | |
| disabled={disabled} | |
| /> | |
| </div> | |
| {/* Uploaded Files List */} | |
| {uploadedFiles.length > 0 && ( | |
| <div className="space-y-3 max-h-64 overflow-y-auto"> | |
| {uploadedFiles.map((uploadedFile, index) => { | |
| const Icon = getFileIcon(uploadedFile.file.name); | |
| const isUploaded = !!uploadedFile.uploaded; | |
| return ( | |
| <div key={index} className="p-3 bg-muted rounded-md space-y-2"> | |
| <div className="flex items-center gap-2 group"> | |
| <Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" /> | |
| <div className="flex-1 min-w-0"> | |
| <p className="text-sm truncate">{uploadedFile.file.name}</p> | |
| <p className="text-xs text-muted-foreground">{formatFileSize(uploadedFile.file.size)}</p> | |
| </div> | |
| {/* ✅ 单文件 Upload(仅未上传时显示 & 必须有回调) */} | |
| {!isUploaded && !!onUploadFile && ( | |
| <Button | |
| variant="secondary" | |
| size="sm" | |
| className="h-7 text-xs px-2" | |
| disabled={disabled} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| onUploadFile(index); | |
| }} | |
| title="Upload this file to backend" | |
| > | |
| Upload | |
| </Button> | |
| )} | |
| {/* ✅ 已上传状态 */} | |
| {isUploaded && ( | |
| <Badge variant="secondary" className="text-[10px]"> | |
| Uploaded{typeof uploadedFile.uploadedChunks === 'number' ? ` (+${uploadedFile.uploadedChunks})` : ''} | |
| </Badge> | |
| )} | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| onRemoveFile(index); | |
| }} | |
| title="Remove" | |
| > | |
| <X className="h-3 w-3" /> | |
| </Button> | |
| </div> | |
| <div className="space-y-1"> | |
| <label className="text-xs text-muted-foreground">File Type</label> | |
| <Select value={uploadedFile.type} onValueChange={(value) => onFileTypeChange(index, value as FileType)}> | |
| <SelectTrigger className="h-8 text-xs"> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="syllabus">Syllabus</SelectItem> | |
| <SelectItem value="lecture-slides">Lecture Slides / PPT</SelectItem> | |
| <SelectItem value="literature-review">Literature Review / Paper</SelectItem> | |
| <SelectItem value="other">Other Course Document</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {/* Type Selection Dialog */} | |
| {showTypeDialog && ( | |
| <Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}> | |
| <DialogContent className="sm:max-w-[425px]"> | |
| <DialogHeader> | |
| <DialogTitle>Select File Types</DialogTitle> | |
| <DialogDescription>Please select the type for each file you are uploading.</DialogDescription> | |
| </DialogHeader> | |
| <div className="space-y-3 max-h-64 overflow-y-auto"> | |
| {pendingFiles.map((pendingFile, index) => { | |
| const Icon = getFileIcon(pendingFile.file.name); | |
| return ( | |
| <div key={index} className="p-3 bg-muted rounded-md space-y-2"> | |
| <div className="flex items-center gap-2 group"> | |
| <Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" /> | |
| <div className="flex-1 min-w-0"> | |
| <p className="text-sm truncate">{pendingFile.file.name}</p> | |
| <p className="text-xs text-muted-foreground">{formatFileSize(pendingFile.file.size)}</p> | |
| </div> | |
| </div> | |
| <div className="space-y-1"> | |
| <label className="text-xs text-muted-foreground">File Type</label> | |
| <Select | |
| value={pendingFile.type} | |
| onValueChange={(value) => handlePendingFileTypeChange(index, value as FileType)} | |
| > | |
| <SelectTrigger className="h-8 text-xs"> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="syllabus">Syllabus</SelectItem> | |
| <SelectItem value="lecture-slides">Lecture Slides / PPT</SelectItem> | |
| <SelectItem value="literature-review">Literature Review / Paper</SelectItem> | |
| <SelectItem value="other">Other Course Document</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| <DialogFooter> | |
| <Button variant="outline" onClick={handleCancelUpload}> | |
| Cancel | |
| </Button> | |
| <Button onClick={handleConfirmUpload}>Upload</Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| )} | |
| </Card> | |
| ); | |
| } | |