AI_Agent_Final / web /src /components /FileUploadArea.tsx
SarahXia0405's picture
Update web/src/components/FileUploadArea.tsx
5439b8c verified
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>
);
}