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(null); const [pendingFiles, setPendingFiles] = useState([]); const [showTypeDialog, setShowTypeDialog] = useState(false); // ===== objectURL cache(更稳:不用 state,避免时序问题)===== const urlCacheRef = useRef>(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(); 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) => { 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 (
{src ? ( {file.name} ) : (
)}
); } const Icon = getFileIcon(file.name); return ; }; return (

Course Materials

{uploadedFiles.length > 0 && ( {uploadedFiles.length} file(s) )}
{/* Upload Area */}
!disabled && fileInputRef.current?.click()} >

{disabled ? "Please log in to upload" : "Drop files or click to upload"}

{ACCEPT_EXTS.join(", ")}

{/* Uploaded Files List */} {uploadedFiles.length > 0 && (
{uploadedFiles.map((uploadedFile, index) => { const f = uploadedFile.file; return (
{renderLeading(f)}

{f.name}

{formatFileSize(f.size)}

); })}
)} {/* Type Selection Dialog */} {showTypeDialog && ( Select File Types Please select the type for each file you are uploading.
{pendingFiles.map((pendingFile, index) => { const f = pendingFile.file; return (
{renderLeading(f)}

{f.name}

{formatFileSize(f.size)}

); })}
)}
); }