Spaces:
Sleeping
Sleeping
| import React, { useEffect, useMemo, useRef, useState } from "react"; | |
| import { Button } from "./ui/button"; | |
| import { | |
| Upload, | |
| File as FileIcon, | |
| X, | |
| FileText, | |
| Presentation, | |
| Image as ImageIcon, | |
| } 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; | |
| disabled?: boolean; | |
| } | |
| interface PendingFile { | |
| file: File; | |
| type: FileType; | |
| } | |
| const ACCEPT_EXTS = [".pdf", ".docx", ".pptx", ".png", ".jpg", ".jpeg", ".webp", ".gif"]; | |
| const ACCEPT_ATTR = ".pdf,.docx,.pptx,.png,.jpg,.jpeg,.webp,.gif"; | |
| function isImageFile(file: File) { | |
| if (file.type?.startsWith("image/")) return true; | |
| const n = file.name.toLowerCase(); | |
| return [".png", ".jpg", ".jpeg", ".webp", ".gif"].some((ext) => n.endsWith(ext)); | |
| } | |
| function getFileIcon(filename: string) { | |
| const lower = filename.toLowerCase(); | |
| if (lower.endsWith(".pdf")) return FileText; | |
| if (lower.endsWith(".pptx")) return Presentation; | |
| if (lower.endsWith(".docx")) return FileIcon; | |
| if ([".png", ".jpg", ".jpeg", ".webp", ".gif"].some((ext) => lower.endsWith(ext))) | |
| return ImageIcon; | |
| return FileIcon; | |
| } | |
| function 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"; | |
| } | |
| export function FileUploadArea({ | |
| uploadedFiles, | |
| onFileUpload, | |
| onRemoveFile, | |
| onFileTypeChange, | |
| disabled = false, | |
| }: FileUploadAreaProps) { | |
| const [isDragging, setIsDragging] = useState(false); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]); | |
| const [showTypeDialog, setShowTypeDialog] = useState(false); | |
| // ===== objectURL cache(更稳:不用 state,避免时序问题)===== | |
| const urlCacheRef = useRef<Map<string, string>>(new Map()); | |
| const fingerprint = (f: File) => `${f.name}::${f.size}::${f.lastModified}`; | |
| const allFiles = useMemo(() => { | |
| return [ | |
| ...uploadedFiles.map((u) => u.file), | |
| ...pendingFiles.map((p) => p.file), | |
| ]; | |
| }, [uploadedFiles, pendingFiles]); | |
| // 维护 cache:只保留当前需要的 image url;移除的立即 revoke | |
| useEffect(() => { | |
| const need = new Set<string>(); | |
| for (const f of allFiles) { | |
| if (!isImageFile(f)) continue; | |
| need.add(fingerprint(f)); | |
| } | |
| // revoke removed | |
| for (const [key, url] of urlCacheRef.current.entries()) { | |
| if (!need.has(key)) { | |
| try { | |
| URL.revokeObjectURL(url); | |
| } catch { | |
| // ignore | |
| } | |
| urlCacheRef.current.delete(key); | |
| } | |
| } | |
| // create missing | |
| for (const f of allFiles) { | |
| if (!isImageFile(f)) continue; | |
| const key = fingerprint(f); | |
| if (!urlCacheRef.current.has(key)) { | |
| urlCacheRef.current.set(key, URL.createObjectURL(f)); | |
| } | |
| } | |
| }, [allFiles]); | |
| // unmount:全部 revoke | |
| useEffect(() => { | |
| return () => { | |
| for (const url of urlCacheRef.current.values()) { | |
| try { | |
| URL.revokeObjectURL(url); | |
| } catch { | |
| // ignore | |
| } | |
| } | |
| urlCacheRef.current.clear(); | |
| }; | |
| }, []); | |
| const getPreviewUrl = (file: File) => { | |
| const key = fingerprint(file); | |
| return urlCacheRef.current.get(key); | |
| }; | |
| const filterSupportedFiles = (files: File[]) => { | |
| return files.filter((file) => { | |
| if (isImageFile(file)) return true; | |
| const lower = file.name.toLowerCase(); | |
| return [".pdf", ".docx", ".pptx"].some((ext) => lower.endsWith(ext)); | |
| }); | |
| }; | |
| 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 = filterSupportedFiles(Array.from(e.dataTransfer.files)); | |
| if (files.length > 0) { | |
| setPendingFiles(files.map((file) => ({ file, type: "other" as FileType }))); | |
| setShowTypeDialog(true); | |
| } | |
| }; | |
| const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const files = filterSupportedFiles(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)); | |
| 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 renderLeading = (file: File) => { | |
| if (isImageFile(file)) { | |
| const src = getPreviewUrl(file); | |
| return ( | |
| <div className="h-12 w-12 rounded-md overflow-hidden bg-background border border-border flex-shrink-0"> | |
| {src ? ( | |
| <img | |
| src={src} | |
| alt={file.name} | |
| className="h-full w-full object-cover" | |
| draggable={false} | |
| /> | |
| ) : ( | |
| <div className="h-full w-full flex items-center justify-center text-muted-foreground"> | |
| <ImageIcon className="h-5 w-5" /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| const Icon = getFileIcon(file.name); | |
| return <Icon className="h-5 w-5 text-muted-foreground flex-shrink-0" />; | |
| }; | |
| return ( | |
| <Card className="p-4 space-y-3"> | |
| <div className="flex items-center justify-between"> | |
| <h4 className="text-sm">Course Materials</h4> | |
| {uploadedFiles.length > 0 && ( | |
| <Badge variant="secondary">{uploadedFiles.length} file(s)</Badge> | |
| )} | |
| </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", | |
| ].join(" ")} | |
| 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">{ACCEPT_EXTS.join(", ")}</p> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| multiple | |
| accept={ACCEPT_ATTR} | |
| 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 f = uploadedFile.file; | |
| return ( | |
| <div key={index} className="p-3 bg-muted rounded-md space-y-2"> | |
| <div className="flex items-center gap-3 group"> | |
| {renderLeading(f)} | |
| <div className="flex-1 min-w-0"> | |
| <p className="text-sm truncate">{f.name}</p> | |
| <p className="text-xs text-muted-foreground">{formatFileSize(f.size)}</p> | |
| </div> | |
| <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 f = pendingFile.file; | |
| return ( | |
| <div key={index} className="p-3 bg-muted rounded-md space-y-2"> | |
| <div className="flex items-center gap-3"> | |
| {renderLeading(f)} | |
| <div className="flex-1 min-w-0"> | |
| <p className="text-sm truncate">{f.name}</p> | |
| <p className="text-xs text-muted-foreground">{formatFileSize(f.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> | |
| ); | |
| } | |