SarahXia0405 commited on
Commit
5439b8c
·
verified ·
1 Parent(s): 846285c

Update web/src/components/FileUploadArea.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/FileUploadArea.tsx +89 -72
web/src/components/FileUploadArea.tsx CHANGED
@@ -1,10 +1,30 @@
1
  import React, { useEffect, useMemo, useRef, useState } from "react";
2
  import { Button } from "./ui/button";
3
- import { Upload, File as FileIcon, X, FileText, Presentation, Image as ImageIcon } from "lucide-react";
 
 
 
 
 
 
 
4
  import { Card } from "./ui/card";
5
  import { Badge } from "./ui/badge";
6
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
7
- import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  import type { UploadedFile, FileType } from "../App";
9
 
10
  interface FileUploadAreaProps {
@@ -24,7 +44,6 @@ const ACCEPT_EXTS = [".pdf", ".docx", ".pptx", ".png", ".jpg", ".jpeg", ".webp",
24
  const ACCEPT_ATTR = ".pdf,.docx,.pptx,.png,.jpg,.jpeg,.webp,.gif";
25
 
26
  function isImageFile(file: File) {
27
- // Prefer MIME, fallback to ext
28
  if (file.type?.startsWith("image/")) return true;
29
  const n = file.name.toLowerCase();
30
  return [".png", ".jpg", ".jpeg", ".webp", ".gif"].some((ext) => n.endsWith(ext));
@@ -34,8 +53,9 @@ function getFileIcon(filename: string) {
34
  const lower = filename.toLowerCase();
35
  if (lower.endsWith(".pdf")) return FileText;
36
  if (lower.endsWith(".pptx")) return Presentation;
37
- if ([".png", ".jpg", ".jpeg", ".webp", ".gif"].some((ext) => lower.endsWith(ext))) return ImageIcon;
38
  if (lower.endsWith(".docx")) return FileIcon;
 
 
39
  return FileIcon;
40
  }
41
 
@@ -57,99 +77,88 @@ export function FileUploadArea({
57
  const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
58
  const [showTypeDialog, setShowTypeDialog] = useState(false);
59
 
60
- /**
61
- * Preview URL cache:
62
- * - key: unique fingerprint (name+size+lastModified)
63
- * - value: objectURL
64
- */
65
- const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
66
 
67
  const fingerprint = (f: File) => `${f.name}::${f.size}::${f.lastModified}`;
68
 
69
- // Maintain preview URLs for uploadedFiles + pendingFiles
70
- useEffect(() => {
71
- const nextNeeded = new Set<string>();
72
-
73
- const allFiles: File[] = [
74
  ...uploadedFiles.map((u) => u.file),
75
  ...pendingFiles.map((p) => p.file),
76
  ];
 
77
 
 
 
 
78
  for (const f of allFiles) {
79
  if (!isImageFile(f)) continue;
80
- nextNeeded.add(fingerprint(f));
81
  }
82
 
83
- setPreviewUrls((prev) => {
84
- const next = { ...prev };
85
-
86
- // create missing
87
- for (const f of allFiles) {
88
- if (!isImageFile(f)) continue;
89
- const key = fingerprint(f);
90
- if (!next[key]) {
91
- next[key] = URL.createObjectURL(f);
92
  }
 
93
  }
 
94
 
95
- // revoke removed
96
- for (const key of Object.keys(next)) {
97
- if (!nextNeeded.has(key)) {
98
- try {
99
- URL.revokeObjectURL(next[key]);
100
- } catch {
101
- // ignore
102
- }
103
- delete next[key];
104
- }
105
  }
 
 
106
 
107
- return next;
108
- });
109
-
110
- return () => {
111
- // no-op here; we revoke on key removal & unmount below
112
- };
113
- // eslint-disable-next-line react-hooks/exhaustive-deps
114
- }, [uploadedFiles, pendingFiles]);
115
-
116
- // Revoke all on unmount
117
  useEffect(() => {
118
  return () => {
119
- try {
120
- Object.values(previewUrls).forEach((url) => URL.revokeObjectURL(url));
121
- } catch {
122
- // ignore
 
 
123
  }
 
124
  };
125
- // eslint-disable-next-line react-hooks/exhaustive-deps
126
  }, []);
127
 
128
- const handleDragOver = (e: React.DragEvent) => {
129
- e.preventDefault();
130
- if (!disabled) setIsDragging(true);
131
- };
132
-
133
- const handleDragLeave = () => {
134
- setIsDragging(false);
135
  };
136
 
137
  const filterSupportedFiles = (files: File[]) => {
138
  return files.filter((file) => {
139
- const lower = file.name.toLowerCase();
140
- // allow images by mime too
141
  if (isImageFile(file)) return true;
 
142
  return [".pdf", ".docx", ".pptx"].some((ext) => lower.endsWith(ext));
143
  });
144
  };
145
 
 
 
 
 
 
 
 
146
  const handleDrop = (e: React.DragEvent) => {
147
  e.preventDefault();
148
  setIsDragging(false);
149
  if (disabled) return;
150
 
151
  const files = filterSupportedFiles(Array.from(e.dataTransfer.files));
152
-
153
  if (files.length > 0) {
154
  setPendingFiles(files.map((file) => ({ file, type: "other" as FileType })));
155
  setShowTypeDialog(true);
@@ -167,13 +176,14 @@ export function FileUploadArea({
167
 
168
  const handleConfirmUpload = () => {
169
  onFileUpload(pendingFiles.map((pf) => pf.file));
170
- // Update the parent's file types
171
  const startIndex = uploadedFiles.length;
172
  pendingFiles.forEach((pf, idx) => {
173
  setTimeout(() => {
174
  onFileTypeChange(startIndex + idx, pf.type);
175
  }, 0);
176
  });
 
177
  setPendingFiles([]);
178
  setShowTypeDialog(false);
179
  };
@@ -189,8 +199,7 @@ export function FileUploadArea({
189
 
190
  const renderLeading = (file: File) => {
191
  if (isImageFile(file)) {
192
- const key = fingerprint(file);
193
- const src = previewUrls[key];
194
  return (
195
  <div className="h-12 w-12 rounded-md overflow-hidden bg-background border border-border flex-shrink-0">
196
  {src ? (
@@ -217,7 +226,9 @@ export function FileUploadArea({
217
  <Card className="p-4 space-y-3">
218
  <div className="flex items-center justify-between">
219
  <h4 className="text-sm">Course Materials</h4>
220
- {uploadedFiles.length > 0 && <Badge variant="secondary">{uploadedFiles.length} file(s)</Badge>}
 
 
221
  </div>
222
 
223
  {/* Upload Area */}
@@ -225,11 +236,11 @@ export function FileUploadArea({
225
  onDragOver={handleDragOver}
226
  onDragLeave={handleDragLeave}
227
  onDrop={handleDrop}
228
- className={`
229
- border-2 border-dashed rounded-lg p-4 text-center transition-colors
230
- ${isDragging ? "border-primary bg-accent" : "border-border"}
231
- ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
232
- `}
233
  onClick={() => !disabled && fileInputRef.current?.click()}
234
  >
235
  <Upload className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
@@ -237,6 +248,7 @@ export function FileUploadArea({
237
  {disabled ? "Please log in to upload" : "Drop files or click to upload"}
238
  </p>
239
  <p className="text-xs text-muted-foreground">{ACCEPT_EXTS.join(", ")}</p>
 
240
  <input
241
  ref={fileInputRef}
242
  type="file"
@@ -279,7 +291,10 @@ export function FileUploadArea({
279
 
280
  <div className="space-y-1">
281
  <label className="text-xs text-muted-foreground">File Type</label>
282
- <Select value={uploadedFile.type} onValueChange={(value) => onFileTypeChange(index, value as FileType)}>
 
 
 
283
  <SelectTrigger className="h-8 text-xs">
284
  <SelectValue />
285
  </SelectTrigger>
@@ -303,7 +318,9 @@ export function FileUploadArea({
303
  <DialogContent className="sm:max-w-[425px]">
304
  <DialogHeader>
305
  <DialogTitle>Select File Types</DialogTitle>
306
- <DialogDescription>Please select the type for each file you are uploading.</DialogDescription>
 
 
307
  </DialogHeader>
308
 
309
  <div className="space-y-3 max-h-64 overflow-y-auto">
 
1
  import React, { useEffect, useMemo, useRef, useState } from "react";
2
  import { Button } from "./ui/button";
3
+ import {
4
+ Upload,
5
+ File as FileIcon,
6
+ X,
7
+ FileText,
8
+ Presentation,
9
+ Image as ImageIcon,
10
+ } from "lucide-react";
11
  import { Card } from "./ui/card";
12
  import { Badge } from "./ui/badge";
13
+ import {
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue,
19
+ } from "./ui/select";
20
+ import {
21
+ Dialog,
22
+ DialogContent,
23
+ DialogDescription,
24
+ DialogFooter,
25
+ DialogHeader,
26
+ DialogTitle,
27
+ } from "./ui/dialog";
28
  import type { UploadedFile, FileType } from "../App";
29
 
30
  interface FileUploadAreaProps {
 
44
  const ACCEPT_ATTR = ".pdf,.docx,.pptx,.png,.jpg,.jpeg,.webp,.gif";
45
 
46
  function isImageFile(file: File) {
 
47
  if (file.type?.startsWith("image/")) return true;
48
  const n = file.name.toLowerCase();
49
  return [".png", ".jpg", ".jpeg", ".webp", ".gif"].some((ext) => n.endsWith(ext));
 
53
  const lower = filename.toLowerCase();
54
  if (lower.endsWith(".pdf")) return FileText;
55
  if (lower.endsWith(".pptx")) return Presentation;
 
56
  if (lower.endsWith(".docx")) return FileIcon;
57
+ if ([".png", ".jpg", ".jpeg", ".webp", ".gif"].some((ext) => lower.endsWith(ext)))
58
+ return ImageIcon;
59
  return FileIcon;
60
  }
61
 
 
77
  const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
78
  const [showTypeDialog, setShowTypeDialog] = useState(false);
79
 
80
+ // ===== objectURL cache(更稳:不用 state,避免时序问题)=====
81
+ const urlCacheRef = useRef<Map<string, string>>(new Map());
 
 
 
 
82
 
83
  const fingerprint = (f: File) => `${f.name}::${f.size}::${f.lastModified}`;
84
 
85
+ const allFiles = useMemo(() => {
86
+ return [
 
 
 
87
  ...uploadedFiles.map((u) => u.file),
88
  ...pendingFiles.map((p) => p.file),
89
  ];
90
+ }, [uploadedFiles, pendingFiles]);
91
 
92
+ // 维护 cache:只保留当前需要的 image url;移除的立即 revoke
93
+ useEffect(() => {
94
+ const need = new Set<string>();
95
  for (const f of allFiles) {
96
  if (!isImageFile(f)) continue;
97
+ need.add(fingerprint(f));
98
  }
99
 
100
+ // revoke removed
101
+ for (const [key, url] of urlCacheRef.current.entries()) {
102
+ if (!need.has(key)) {
103
+ try {
104
+ URL.revokeObjectURL(url);
105
+ } catch {
106
+ // ignore
 
 
107
  }
108
+ urlCacheRef.current.delete(key);
109
  }
110
+ }
111
 
112
+ // create missing
113
+ for (const f of allFiles) {
114
+ if (!isImageFile(f)) continue;
115
+ const key = fingerprint(f);
116
+ if (!urlCacheRef.current.has(key)) {
117
+ urlCacheRef.current.set(key, URL.createObjectURL(f));
 
 
 
 
118
  }
119
+ }
120
+ }, [allFiles]);
121
 
122
+ // unmount:全部 revoke
 
 
 
 
 
 
 
 
 
123
  useEffect(() => {
124
  return () => {
125
+ for (const url of urlCacheRef.current.values()) {
126
+ try {
127
+ URL.revokeObjectURL(url);
128
+ } catch {
129
+ // ignore
130
+ }
131
  }
132
+ urlCacheRef.current.clear();
133
  };
 
134
  }, []);
135
 
136
+ const getPreviewUrl = (file: File) => {
137
+ const key = fingerprint(file);
138
+ return urlCacheRef.current.get(key);
 
 
 
 
139
  };
140
 
141
  const filterSupportedFiles = (files: File[]) => {
142
  return files.filter((file) => {
 
 
143
  if (isImageFile(file)) return true;
144
+ const lower = file.name.toLowerCase();
145
  return [".pdf", ".docx", ".pptx"].some((ext) => lower.endsWith(ext));
146
  });
147
  };
148
 
149
+ const handleDragOver = (e: React.DragEvent) => {
150
+ e.preventDefault();
151
+ if (!disabled) setIsDragging(true);
152
+ };
153
+
154
+ const handleDragLeave = () => setIsDragging(false);
155
+
156
  const handleDrop = (e: React.DragEvent) => {
157
  e.preventDefault();
158
  setIsDragging(false);
159
  if (disabled) return;
160
 
161
  const files = filterSupportedFiles(Array.from(e.dataTransfer.files));
 
162
  if (files.length > 0) {
163
  setPendingFiles(files.map((file) => ({ file, type: "other" as FileType })));
164
  setShowTypeDialog(true);
 
176
 
177
  const handleConfirmUpload = () => {
178
  onFileUpload(pendingFiles.map((pf) => pf.file));
179
+
180
  const startIndex = uploadedFiles.length;
181
  pendingFiles.forEach((pf, idx) => {
182
  setTimeout(() => {
183
  onFileTypeChange(startIndex + idx, pf.type);
184
  }, 0);
185
  });
186
+
187
  setPendingFiles([]);
188
  setShowTypeDialog(false);
189
  };
 
199
 
200
  const renderLeading = (file: File) => {
201
  if (isImageFile(file)) {
202
+ const src = getPreviewUrl(file);
 
203
  return (
204
  <div className="h-12 w-12 rounded-md overflow-hidden bg-background border border-border flex-shrink-0">
205
  {src ? (
 
226
  <Card className="p-4 space-y-3">
227
  <div className="flex items-center justify-between">
228
  <h4 className="text-sm">Course Materials</h4>
229
+ {uploadedFiles.length > 0 && (
230
+ <Badge variant="secondary">{uploadedFiles.length} file(s)</Badge>
231
+ )}
232
  </div>
233
 
234
  {/* Upload Area */}
 
236
  onDragOver={handleDragOver}
237
  onDragLeave={handleDragLeave}
238
  onDrop={handleDrop}
239
+ className={[
240
+ "border-2 border-dashed rounded-lg p-4 text-center transition-colors",
241
+ isDragging ? "border-primary bg-accent" : "border-border",
242
+ disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
243
+ ].join(" ")}
244
  onClick={() => !disabled && fileInputRef.current?.click()}
245
  >
246
  <Upload className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
 
248
  {disabled ? "Please log in to upload" : "Drop files or click to upload"}
249
  </p>
250
  <p className="text-xs text-muted-foreground">{ACCEPT_EXTS.join(", ")}</p>
251
+
252
  <input
253
  ref={fileInputRef}
254
  type="file"
 
291
 
292
  <div className="space-y-1">
293
  <label className="text-xs text-muted-foreground">File Type</label>
294
+ <Select
295
+ value={uploadedFile.type}
296
+ onValueChange={(value) => onFileTypeChange(index, value as FileType)}
297
+ >
298
  <SelectTrigger className="h-8 text-xs">
299
  <SelectValue />
300
  </SelectTrigger>
 
318
  <DialogContent className="sm:max-w-[425px]">
319
  <DialogHeader>
320
  <DialogTitle>Select File Types</DialogTitle>
321
+ <DialogDescription>
322
+ Please select the type for each file you are uploading.
323
+ </DialogDescription>
324
  </DialogHeader>
325
 
326
  <div className="space-y-3 max-h-64 overflow-y-auto">