SarahXia0405 commited on
Commit
2d89a22
·
verified ·
1 Parent(s): 25ab7de

Update web/src/components/FileUploadArea.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/FileUploadArea.tsx +172 -90
web/src/components/FileUploadArea.tsx CHANGED
@@ -1,11 +1,11 @@
1
- import React, { useRef, useState } from 'react';
2
- import { Button } from './ui/button';
3
- import { Upload, File, X, FileText, FileSpreadsheet, Presentation } 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 {
11
  uploadedFiles: UploadedFile[];
@@ -20,6 +20,31 @@ interface PendingFile {
20
  type: FileType;
21
  }
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  export function FileUploadArea({
24
  uploadedFiles,
25
  onFileUpload,
@@ -32,6 +57,74 @@ export function FileUploadArea({
32
  const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
33
  const [showTypeDialog, setShowTypeDialog] = useState(false);
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  const handleDragOver = (e: React.DragEvent) => {
36
  e.preventDefault();
37
  if (!disabled) setIsDragging(true);
@@ -41,32 +134,39 @@ export function FileUploadArea({
41
  setIsDragging(false);
42
  };
43
 
 
 
 
 
 
 
 
 
 
44
  const handleDrop = (e: React.DragEvent) => {
45
  e.preventDefault();
46
  setIsDragging(false);
47
  if (disabled) return;
48
 
49
- const files = Array.from(e.dataTransfer.files).filter((file) =>
50
- ['.pdf', '.docx', '.pptx'].some((ext) => file.name.toLowerCase().endsWith(ext))
51
- );
52
 
53
  if (files.length > 0) {
54
- setPendingFiles(files.map(file => ({ file, type: 'other' as FileType })));
55
  setShowTypeDialog(true);
56
  }
57
  };
58
 
59
  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
60
- const files = Array.from(e.target.files || []);
61
  if (files.length > 0) {
62
- setPendingFiles(files.map(file => ({ file, type: 'other' as FileType })));
63
  setShowTypeDialog(true);
64
  }
65
- e.target.value = '';
66
  };
67
 
68
  const handleConfirmUpload = () => {
69
- onFileUpload(pendingFiles.map(pf => pf.file));
70
  // Update the parent's file types
71
  const startIndex = uploadedFiles.length;
72
  pendingFiles.forEach((pf, idx) => {
@@ -84,41 +184,40 @@ export function FileUploadArea({
84
  };
85
 
86
  const handlePendingFileTypeChange = (index: number, type: FileType) => {
87
- setPendingFiles(prev => prev.map((pf, i) =>
88
- i === index ? { ...pf, type } : pf
89
- ));
90
  };
91
 
92
- const getFileIcon = (filename: string) => {
93
- if (filename.endsWith('.pdf')) return FileText;
94
- if (filename.endsWith('.docx')) return File;
95
- if (filename.endsWith('.pptx')) return Presentation;
96
- return File;
97
- };
98
-
99
- const formatFileSize = (bytes: number) => {
100
- if (bytes < 1024) return bytes + ' B';
101
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
102
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
103
- };
 
 
 
 
 
 
 
 
 
104
 
105
- const getFileTypeLabel = (type: FileType) => {
106
- const labels: Record<FileType, string> = {
107
- 'syllabus': 'Syllabus',
108
- 'lecture-slides': 'Lecture Slides / PPT',
109
- 'literature-review': 'Literature Review / Paper',
110
- 'other': 'Other Course Document',
111
- };
112
- return labels[type];
113
  };
114
 
115
  return (
116
  <Card className="p-4 space-y-3">
117
  <div className="flex items-center justify-between">
118
  <h4 className="text-sm">Course Materials</h4>
119
- {uploadedFiles.length > 0 && (
120
- <Badge variant="secondary">{uploadedFiles.length} file(s)</Badge>
121
- )}
122
  </div>
123
 
124
  {/* Upload Area */}
@@ -128,23 +227,21 @@ export function FileUploadArea({
128
  onDrop={handleDrop}
129
  className={`
130
  border-2 border-dashed rounded-lg p-4 text-center transition-colors
131
- ${isDragging ? 'border-primary bg-accent' : 'border-border'}
132
- ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
133
  `}
134
  onClick={() => !disabled && fileInputRef.current?.click()}
135
  >
136
  <Upload className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
137
  <p className="text-sm text-muted-foreground mb-1">
138
- {disabled ? 'Please log in to upload' : 'Drop files or click to upload'}
139
- </p>
140
- <p className="text-xs text-muted-foreground">
141
- .pdf, .docx, .pptx
142
  </p>
 
143
  <input
144
  ref={fileInputRef}
145
  type="file"
146
  multiple
147
- accept=".pdf,.docx,.pptx"
148
  onChange={handleFileSelect}
149
  className="hidden"
150
  disabled={disabled}
@@ -155,20 +252,17 @@ export function FileUploadArea({
155
  {uploadedFiles.length > 0 && (
156
  <div className="space-y-3 max-h-64 overflow-y-auto">
157
  {uploadedFiles.map((uploadedFile, index) => {
158
- const Icon = getFileIcon(uploadedFile.file.name);
159
  return (
160
- <div
161
- key={index}
162
- className="p-3 bg-muted rounded-md space-y-2"
163
- >
164
- <div className="flex items-center gap-2 group">
165
- <Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
166
  <div className="flex-1 min-w-0">
167
- <p className="text-sm truncate">{uploadedFile.file.name}</p>
168
- <p className="text-xs text-muted-foreground">
169
- {formatFileSize(uploadedFile.file.size)}
170
- </p>
171
  </div>
 
172
  <Button
173
  variant="ghost"
174
  size="icon"
@@ -177,16 +271,15 @@ export function FileUploadArea({
177
  e.stopPropagation();
178
  onRemoveFile(index);
179
  }}
 
180
  >
181
  <X className="h-3 w-3" />
182
  </Button>
183
  </div>
 
184
  <div className="space-y-1">
185
  <label className="text-xs text-muted-foreground">File Type</label>
186
- <Select
187
- value={uploadedFile.type}
188
- onValueChange={(value) => onFileTypeChange(index, value as FileType)}
189
- >
190
  <SelectTrigger className="h-8 text-xs">
191
  <SelectValue />
192
  </SelectTrigger>
@@ -210,31 +303,26 @@ export function FileUploadArea({
210
  <DialogContent className="sm:max-w-[425px]">
211
  <DialogHeader>
212
  <DialogTitle>Select File Types</DialogTitle>
213
- <DialogDescription>
214
- Please select the type for each file you are uploading.
215
- </DialogDescription>
216
  </DialogHeader>
 
217
  <div className="space-y-3 max-h-64 overflow-y-auto">
218
  {pendingFiles.map((pendingFile, index) => {
219
- const Icon = getFileIcon(pendingFile.file.name);
220
  return (
221
- <div
222
- key={index}
223
- className="p-3 bg-muted rounded-md space-y-2"
224
- >
225
- <div className="flex items-center gap-2 group">
226
- <Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
227
  <div className="flex-1 min-w-0">
228
- <p className="text-sm truncate">{pendingFile.file.name}</p>
229
- <p className="text-xs text-muted-foreground">
230
- {formatFileSize(pendingFile.file.size)}
231
- </p>
232
  </div>
233
  </div>
 
234
  <div className="space-y-1">
235
  <label className="text-xs text-muted-foreground">File Type</label>
236
- <Select
237
- value={pendingFile.type}
238
  onValueChange={(value) => handlePendingFileTypeChange(index, value as FileType)}
239
  >
240
  <SelectTrigger className="h-8 text-xs">
@@ -252,22 +340,16 @@ export function FileUploadArea({
252
  );
253
  })}
254
  </div>
 
255
  <DialogFooter>
256
- <Button
257
- variant="outline"
258
- onClick={handleCancelUpload}
259
- >
260
  Cancel
261
  </Button>
262
- <Button
263
- onClick={handleConfirmUpload}
264
- >
265
- Upload
266
- </Button>
267
  </DialogFooter>
268
  </DialogContent>
269
  </Dialog>
270
  )}
271
  </Card>
272
  );
273
- }
 
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 {
11
  uploadedFiles: UploadedFile[];
 
20
  type: FileType;
21
  }
22
 
23
+ const ACCEPT_EXTS = [".pdf", ".docx", ".pptx", ".png", ".jpg", ".jpeg", ".webp", ".gif"];
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));
31
+ }
32
+
33
+ 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
+
42
+ function formatFileSize(bytes: number) {
43
+ if (bytes < 1024) return bytes + " B";
44
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
45
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
46
+ }
47
+
48
  export function FileUploadArea({
49
  uploadedFiles,
50
  onFileUpload,
 
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);
 
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);
156
  }
157
  };
158
 
159
  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
160
+ const files = filterSupportedFiles(Array.from(e.target.files || []));
161
  if (files.length > 0) {
162
+ setPendingFiles(files.map((file) => ({ file, type: "other" as FileType })));
163
  setShowTypeDialog(true);
164
  }
165
+ e.target.value = "";
166
  };
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) => {
 
184
  };
185
 
186
  const handlePendingFileTypeChange = (index: number, type: FileType) => {
187
+ setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf)));
 
 
188
  };
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 ? (
197
+ <img
198
+ src={src}
199
+ alt={file.name}
200
+ className="h-full w-full object-cover"
201
+ draggable={false}
202
+ />
203
+ ) : (
204
+ <div className="h-full w-full flex items-center justify-center text-muted-foreground">
205
+ <ImageIcon className="h-5 w-5" />
206
+ </div>
207
+ )}
208
+ </div>
209
+ );
210
+ }
211
 
212
+ const Icon = getFileIcon(file.name);
213
+ return <Icon className="h-5 w-5 text-muted-foreground flex-shrink-0" />;
 
 
 
 
 
 
214
  };
215
 
216
  return (
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 */}
 
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" />
236
  <p className="text-sm text-muted-foreground mb-1">
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"
243
  multiple
244
+ accept={ACCEPT_ATTR}
245
  onChange={handleFileSelect}
246
  className="hidden"
247
  disabled={disabled}
 
252
  {uploadedFiles.length > 0 && (
253
  <div className="space-y-3 max-h-64 overflow-y-auto">
254
  {uploadedFiles.map((uploadedFile, index) => {
255
+ const f = uploadedFile.file;
256
  return (
257
+ <div key={index} className="p-3 bg-muted rounded-md space-y-2">
258
+ <div className="flex items-center gap-3 group">
259
+ {renderLeading(f)}
260
+
 
 
261
  <div className="flex-1 min-w-0">
262
+ <p className="text-sm truncate">{f.name}</p>
263
+ <p className="text-xs text-muted-foreground">{formatFileSize(f.size)}</p>
 
 
264
  </div>
265
+
266
  <Button
267
  variant="ghost"
268
  size="icon"
 
271
  e.stopPropagation();
272
  onRemoveFile(index);
273
  }}
274
+ title="Remove"
275
  >
276
  <X className="h-3 w-3" />
277
  </Button>
278
  </div>
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
  <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">
310
  {pendingFiles.map((pendingFile, index) => {
311
+ const f = pendingFile.file;
312
  return (
313
+ <div key={index} className="p-3 bg-muted rounded-md space-y-2">
314
+ <div className="flex items-center gap-3">
315
+ {renderLeading(f)}
 
 
 
316
  <div className="flex-1 min-w-0">
317
+ <p className="text-sm truncate">{f.name}</p>
318
+ <p className="text-xs text-muted-foreground">{formatFileSize(f.size)}</p>
 
 
319
  </div>
320
  </div>
321
+
322
  <div className="space-y-1">
323
  <label className="text-xs text-muted-foreground">File Type</label>
324
+ <Select
325
+ value={pendingFile.type}
326
  onValueChange={(value) => handlePendingFileTypeChange(index, value as FileType)}
327
  >
328
  <SelectTrigger className="h-8 text-xs">
 
340
  );
341
  })}
342
  </div>
343
+
344
  <DialogFooter>
345
+ <Button variant="outline" onClick={handleCancelUpload}>
 
 
 
346
  Cancel
347
  </Button>
348
+ <Button onClick={handleConfirmUpload}>Upload</Button>
 
 
 
 
349
  </DialogFooter>
350
  </DialogContent>
351
  </Dialog>
352
  )}
353
  </Card>
354
  );
355
+ }