SarahXia0405 commited on
Commit
15402d8
·
verified ·
1 Parent(s): 5439b8c

Update web/src/components/Message.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/Message.tsx +335 -320
web/src/components/Message.tsx CHANGED
@@ -1,347 +1,362 @@
1
- import React, { useState } from 'react';
2
- import { Button } from './ui/button';
3
- import {
4
- Copy,
5
- ThumbsUp,
6
- ThumbsDown,
7
- ChevronDown,
8
- ChevronUp,
9
- Check,
10
- Bot,
11
- X
12
- } from 'lucide-react';
13
- import { Badge } from './ui/badge';
14
- import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
15
- import { Textarea } from './ui/textarea';
16
- import type { Message as MessageType } from '../App';
17
- import { toast } from 'sonner';
18
- import clareAvatar from '../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png';
19
-
20
- interface MessageProps {
21
- key?: React.Key;
22
- message: MessageType;
23
- showSenderInfo?: boolean; // For group chat mode
24
- isFirstGreeting?: boolean; // Indicates if this is the first greeting message
25
- showNextButton?: boolean; // For quiz mode
26
- onNextQuestion?: () => void; // For quiz mode
27
- chatMode?: 'ask' | 'review' | 'quiz'; // Current chat mode
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
29
 
30
- // 反馈标签选项
31
- const FEEDBACK_TAGS = {
32
- 'not-helpful': [
33
- 'Code was incorrect',
34
- "Shouldn't have used Memory",
35
- "Don't like the personality",
36
- "Don't like the style",
37
- 'Not factually correct',
38
- ],
39
- 'helpful': [
40
- 'Accurate and helpful',
41
- 'Clear explanation',
42
- 'Good examples',
43
- 'Solved my problem',
44
- 'Well structured',
45
- ],
46
- };
47
-
48
- export function Message({
49
- message,
50
- showSenderInfo = false,
51
- isFirstGreeting = false,
52
- showNextButton = false,
53
- onNextQuestion,
54
- chatMode = 'ask',
55
- }: MessageProps) {
56
- const [feedback, setFeedback] = useState<'helpful' | 'not-helpful' | null>(null);
57
- const [copied, setCopied] = useState(false);
58
- const [referencesOpen, setReferencesOpen] = useState(false);
59
- const [showFeedbackArea, setShowFeedbackArea] = useState(false);
60
- const [feedbackType, setFeedbackType] = useState<'helpful' | 'not-helpful' | null>(null);
61
- const [feedbackText, setFeedbackText] = useState('');
62
- const [selectedTags, setSelectedTags] = useState<string[]>([]);
63
- const [isHovered, setIsHovered] = useState(false);
64
- const [nextButtonClicked, setNextButtonClicked] = useState(false);
65
-
66
- const isUser = message.role === 'user';
67
- // For user messages: always show. For assistant messages: always show except first greeting (or review-1, quiz-1)
68
- const isWelcomeMessage = isFirstGreeting || message.id === 'review-1' || message.id === 'quiz-1';
69
- const shouldShowActions = isUser ? true : !isWelcomeMessage;
70
-
71
- const handleCopy = async () => {
72
- await navigator.clipboard.writeText(message.content);
73
- setCopied(true);
74
- toast.success('Message copied to clipboard');
75
- setTimeout(() => setCopied(false), 2000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  };
77
 
78
- const handleFeedbackClick = (type: 'helpful' | 'not-helpful') => {
79
- if (feedback === type) {
80
- // 如果点击的是已选中的反馈,则关闭反馈区域
81
- setFeedback(null);
82
- setShowFeedbackArea(false);
83
- setFeedbackType(null);
84
- setFeedbackText('');
85
- setSelectedTags([]);
86
- } else {
87
- // 立即设置反馈状态(按钮会立即显示颜色)
88
- setFeedback(type);
89
- // 打开反馈区域
90
- setFeedbackType(type);
91
- setShowFeedbackArea(true);
92
  }
93
  };
94
 
95
- const handleFeedbackClose = () => {
96
- setShowFeedbackArea(false);
97
- setFeedbackType(null);
98
- setFeedbackText('');
99
- setSelectedTags([]);
100
- // 注意:不重置 feedback,保持点赞/点踩的状态
 
101
  };
102
 
103
- const handleTagToggle = (tag: string) => {
104
- setSelectedTags(prev =>
105
- prev.includes(tag)
106
- ? prev.filter(t => t !== tag)
107
- : [...prev, tag]
108
- );
 
 
 
 
 
 
109
  };
110
 
111
- const handleFeedbackSubmit = () => {
112
- // 这里可以发送反馈到后端
113
- const feedbackData = {
114
- type: feedbackType,
115
- tags: selectedTags,
116
- text: feedbackText,
117
- messageId: message.id || message.content.substring(0, 50),
118
- };
119
-
120
- console.log('Feedback submitted:', feedbackData);
121
- toast.success('感谢您的反馈!');
122
- handleFeedbackClose();
123
- // 保持反馈状态(点赞/点踩)
124
  };
125
 
126
- return (
127
- <div className={`flex gap-2 ${isUser && !showSenderInfo ? 'justify-end' : 'justify-start'} px-4`}>
128
- {/* Avatar */}
129
- {showSenderInfo && message.sender ? (
130
- <div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
131
- message.sender.isAI
132
- ? 'overflow-hidden bg-white'
133
- : 'overflow-hidden bg-white'
134
- }`}>
135
- {message.sender.isAI ? (
136
- <img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
137
- ) : (
138
- <img
139
- src={
140
- message.sender.avatar ||
141
- `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(message.sender.email || message.sender.name)}`
142
- }
143
- alt={message.sender.name}
144
- className="w-full h-full object-cover"
145
- />
146
- )}
147
- </div>
148
- ) : !isUser ? (
149
- <div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
150
- <img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
151
  </div>
152
- ) : null}
 
153
 
154
- <div
155
- className={`group flex flex-col gap-2 ${isUser && !showSenderInfo ? 'items-end' : 'items-start'}`}
156
- style={{ maxWidth: 'min(770px, calc(100% - 2rem))' }}
157
- onMouseEnter={() => setIsHovered(true)}
158
- onMouseLeave={() => setIsHovered(false)}
159
- >
160
- {/* Sender name in group chat */}
161
- {showSenderInfo && message.sender && (
162
- <div className="flex items-center gap-2 px-1">
163
- <span className="text-xs">{message.sender.name}</span>
164
- {message.sender.isAI && (
165
- <Badge variant="secondary" className="text-xs h-4 px-1">AI</Badge>
166
- )}
167
- </div>
168
  )}
 
169
 
170
- <div
171
- className={`
172
- rounded-2xl px-4 py-3
173
- ${isUser && !showSenderInfo
174
- ? 'bg-primary text-primary-foreground'
175
- : 'bg-muted'
176
- }
177
- `}
178
- >
179
- <p className="whitespace-pre-wrap text-base">{message.content}</p>
180
- </div>
 
 
 
 
 
 
181
 
182
- {/* Next Question Button for Quiz Mode */}
183
- {!isUser && showNextButton && !nextButtonClicked && chatMode === 'quiz' && onNextQuestion && (
184
- <div className="mt-2">
185
- <Button
186
- onClick={() => {
187
- setNextButtonClicked(true);
188
- onNextQuestion();
189
- }}
190
- className="bg-primary hover:bg-primary/90"
191
- >
192
- Next Question
193
- </Button>
194
- </div>
195
- )}
196
 
197
- {/* References */}
198
- {message.references && message.references.length > 0 && (
199
- <Collapsible open={referencesOpen} onOpenChange={setReferencesOpen}>
200
- <CollapsibleTrigger asChild>
201
- <Button variant="ghost" size="sm" className="gap-1 h-7 text-xs">
202
- {referencesOpen ? (
203
- <ChevronUp className="h-3 w-3" />
204
- ) : (
205
- <ChevronDown className="h-3 w-3" />
206
- )}
207
- {message.references.length} {message.references.length === 1 ? 'reference' : 'references'}
208
- </Button>
209
- </CollapsibleTrigger>
210
- <CollapsibleContent className="space-y-1 mt-1">
211
- {message.references.map((ref, index) => (
212
- <Badge key={index} variant="outline" className="text-xs">
213
- {ref}
214
- </Badge>
215
- ))}
216
- </CollapsibleContent>
217
- </Collapsible>
218
- )}
219
 
220
- {/* Message Actions */}
221
- {shouldShowActions && (
222
- <div className="flex items-center gap-1">
223
- <Button
224
- variant="ghost"
225
- size="icon"
226
- className={`h-7 w-7 rounded-md transition-all hover:bg-accent hover:scale-110 active:scale-95 ${
227
- copied ? 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400' : ''
228
- }`}
229
- onClick={handleCopy}
230
- title="Copy"
231
- >
232
- {copied ? (
233
- <Check className="h-4 w-4" />
234
- ) : (
235
- <Copy className="h-4 w-4" />
236
- )}
237
- </Button>
238
-
239
- {!isUser && (
240
- <>
241
- <Button
242
- variant="ghost"
243
- size="icon"
244
- className={`h-7 w-7 rounded-md transition-all hover:bg-accent hover:scale-110 active:scale-95 ${
245
- feedback === 'helpful' ? 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400' : ''
246
- }`}
247
- onClick={() => handleFeedbackClick('helpful')}
248
- title="Helpful"
249
- >
250
- <ThumbsUp className="h-4 w-4" />
251
- </Button>
252
- <Button
253
- variant="ghost"
254
- size="icon"
255
- className={`h-7 w-7 rounded-md transition-all hover:bg-accent hover:scale-110 active:scale-95 ${
256
- feedback === 'not-helpful' ? 'bg-red-100 text-red-600 dark:bg-red-900/20 dark:text-red-400' : ''
257
- }`}
258
- onClick={() => handleFeedbackClick('not-helpful')}
259
- title="Not helpful"
260
- >
261
- <ThumbsDown className="h-4 w-4" />
262
- </Button>
263
- </>
264
- )}
265
- </div>
266
- )}
267
 
268
- {/* Feedback Area - 展开在消息下方 */}
269
- {!isUser && showFeedbackArea && feedbackType && (
270
- <div className="w-full mt-2 bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
271
- <div className="flex items-start justify-between mb-4">
272
- <h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
273
- Tell us more:
274
- </h4>
275
- <Button
276
- variant="ghost"
277
- size="sm"
278
- className="h-6 w-6 p-0"
279
- onClick={handleFeedbackClose}
280
- >
281
- <X className="h-4 w-4" />
282
- </Button>
283
- </div>
284
-
285
- {/* 标签按钮 */}
286
- <div className="flex flex-wrap gap-2 mb-4">
287
- {FEEDBACK_TAGS[feedbackType].map((tag) => (
288
- <Button
289
- key={tag}
290
- variant={selectedTags.includes(tag) ? 'default' : 'outline'}
291
- size="sm"
292
- className="h-7 text-xs"
293
- onClick={() => handleTagToggle(tag)}
294
- >
295
- {tag}
296
- </Button>
297
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  </div>
299
 
300
- {/* 文本输入框 */}
301
- <Textarea
302
- className="min-h-[60px] mb-4 bg-gray-100/50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600"
303
- value={feedbackText}
304
- onChange={(e) => setFeedbackText(e.target.value)}
305
- placeholder="Additional feedback (optional)..."
306
- />
307
-
308
- {/* 提交按钮 */}
309
- <div className="flex justify-end gap-2">
310
- <Button
311
- variant="outline"
312
- size="sm"
313
- onClick={handleFeedbackClose}
314
- >
315
  Cancel
316
  </Button>
317
- <Button
318
- size="sm"
319
- onClick={handleFeedbackSubmit}
320
- disabled={selectedTags.length === 0 && !feedbackText.trim()}
321
- >
322
- Submit
323
- </Button>
324
- </div>
325
- </div>
326
- )}
327
- </div>
328
-
329
- {isUser && !showSenderInfo && (
330
- <div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
331
- {message.sender ? (
332
- <img
333
- src={
334
- message.sender.avatar ||
335
- `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(message.sender.email || message.sender.name)}`
336
- }
337
- alt={message.sender.name}
338
- className="w-full h-full object-cover"
339
- />
340
- ) : (
341
- <span className="text-base">👤</span>
342
- )}
343
- </div>
344
  )}
345
- </div>
346
  );
347
- }
 
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 {
31
+ uploadedFiles: UploadedFile[];
32
+ onFileUpload: (files: File[]) => void;
33
+ onRemoveFile: (index: number) => void;
34
+ onFileTypeChange: (index: number, type: FileType) => void;
35
+ disabled?: boolean;
36
+ }
37
+
38
+ interface PendingFile {
39
+ file: File;
40
+ type: FileType;
41
+ }
42
+
43
+ const ACCEPT_EXTS = [".pdf", ".docx", ".pptx", ".png", ".jpg", ".jpeg", ".webp", ".gif"];
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));
50
+ }
51
+
52
+ function getFileIcon(filename: string) {
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
 
62
+ function formatFileSize(bytes: number) {
63
+ if (bytes < 1024) return bytes + " B";
64
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
65
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
66
+ }
67
+
68
+ export function FileUploadArea({
69
+ uploadedFiles,
70
+ onFileUpload,
71
+ onRemoveFile,
72
+ onFileTypeChange,
73
+ disabled = false,
74
+ }: FileUploadAreaProps) {
75
+ const [isDragging, setIsDragging] = useState(false);
76
+ const fileInputRef = useRef<HTMLInputElement>(null);
77
+ const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
78
+ const [showTypeDialog, setShowTypeDialog] = useState(false);
79
+
80
+ // objectURL cache(ref 不触发渲染,所以 URL 必须在 render 时按需创建)
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
+ // 当文件列表变化:revoke 掉不再需要的 url
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
+ for (const [key, url] of urlCacheRef.current.entries()) {
101
+ if (!need.has(key)) {
102
+ try {
103
+ URL.revokeObjectURL(url);
104
+ } catch {
105
+ // ignore
106
+ }
107
+ urlCacheRef.current.delete(key);
108
+ }
109
+ }
110
+ }, [allFiles]);
111
+
112
+ // ✅ 卸载:全部 revoke
113
+ useEffect(() => {
114
+ return () => {
115
+ for (const url of urlCacheRef.current.values()) {
116
+ try {
117
+ URL.revokeObjectURL(url);
118
+ } catch {
119
+ // ignore
120
+ }
121
+ }
122
+ urlCacheRef.current.clear();
123
+ };
124
+ }, []);
125
+
126
+ // ✅ 关键:render 时同步 getOrCreate,这样第一次渲染就能拿到 src
127
+ const getOrCreatePreviewUrl = (file: File) => {
128
+ const key = fingerprint(file);
129
+ const existed = urlCacheRef.current.get(key);
130
+ if (existed) return existed;
131
+
132
+ const url = URL.createObjectURL(file);
133
+ urlCacheRef.current.set(key, url);
134
+ return url;
135
+ };
136
+
137
+ const filterSupportedFiles = (files: File[]) => {
138
+ return files.filter((file) => {
139
+ if (isImageFile(file)) return true;
140
+ const lower = file.name.toLowerCase();
141
+ return [".pdf", ".docx", ".pptx"].some((ext) => lower.endsWith(ext));
142
+ });
143
+ };
144
+
145
+ const handleDragOver = (e: React.DragEvent) => {
146
+ e.preventDefault();
147
+ if (!disabled) setIsDragging(true);
148
  };
149
 
150
+ const handleDragLeave = () => setIsDragging(false);
151
+
152
+ const handleDrop = (e: React.DragEvent) => {
153
+ e.preventDefault();
154
+ setIsDragging(false);
155
+ if (disabled) return;
156
+
157
+ const files = filterSupportedFiles(Array.from(e.dataTransfer.files));
158
+ if (files.length > 0) {
159
+ setPendingFiles(files.map((file) => ({ file, type: "other" as FileType })));
160
+ setShowTypeDialog(true);
 
 
 
161
  }
162
  };
163
 
164
+ const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
165
+ const files = filterSupportedFiles(Array.from(e.target.files || []));
166
+ if (files.length > 0) {
167
+ setPendingFiles(files.map((file) => ({ file, type: "other" as FileType })));
168
+ setShowTypeDialog(true);
169
+ }
170
+ e.target.value = "";
171
  };
172
 
173
+ const handleConfirmUpload = () => {
174
+ onFileUpload(pendingFiles.map((pf) => pf.file));
175
+
176
+ const startIndex = uploadedFiles.length;
177
+ pendingFiles.forEach((pf, idx) => {
178
+ setTimeout(() => {
179
+ onFileTypeChange(startIndex + idx, pf.type);
180
+ }, 0);
181
+ });
182
+
183
+ setPendingFiles([]);
184
+ setShowTypeDialog(false);
185
  };
186
 
187
+ const handleCancelUpload = () => {
188
+ setPendingFiles([]);
189
+ setShowTypeDialog(false);
 
 
 
 
 
 
 
 
 
 
190
  };
191
 
192
+ const handlePendingFileTypeChange = (index: number, type: FileType) => {
193
+ setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf)));
194
+ };
195
+
196
+ const renderLeading = (file: File) => {
197
+ if (isImageFile(file)) {
198
+ const src = getOrCreatePreviewUrl(file);
199
+ return (
200
+ <div className="h-12 w-12 rounded-md overflow-hidden bg-background border border-border flex-shrink-0">
201
+ <img
202
+ src={src}
203
+ alt={file.name}
204
+ className="h-full w-full object-cover"
205
+ draggable={false}
206
+ />
 
 
 
 
 
 
 
 
 
 
207
  </div>
208
+ );
209
+ }
210
 
211
+ const Icon = getFileIcon(file.name);
212
+ return <Icon className="h-5 w-5 text-muted-foreground flex-shrink-0" />;
213
+ };
214
+
215
+ return (
216
+ <Card className="p-4 space-y-3">
217
+ <div className="flex items-center justify-between">
218
+ <h4 className="text-sm">Course Materials</h4>
219
+ {uploadedFiles.length > 0 && (
220
+ <Badge variant="secondary">{uploadedFiles.length} file(s)</Badge>
 
 
 
 
221
  )}
222
+ </div>
223
 
224
+ {/* Upload Area */}
225
+ <div
226
+ onDragOver={handleDragOver}
227
+ onDragLeave={handleDragLeave}
228
+ onDrop={handleDrop}
229
+ className={[
230
+ "border-2 border-dashed rounded-lg p-4 text-center transition-colors",
231
+ isDragging ? "border-primary bg-accent" : "border-border",
232
+ disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
233
+ ].join(" ")}
234
+ onClick={() => !disabled && fileInputRef.current?.click()}
235
+ >
236
+ <Upload className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
237
+ <p className="text-sm text-muted-foreground mb-1">
238
+ {disabled ? "Please log in to upload" : "Drop files or click to upload"}
239
+ </p>
240
+ <p className="text-xs text-muted-foreground">{ACCEPT_EXTS.join(", ")}</p>
241
 
242
+ <input
243
+ ref={fileInputRef}
244
+ type="file"
245
+ multiple
246
+ accept={ACCEPT_ATTR}
247
+ onChange={handleFileSelect}
248
+ className="hidden"
249
+ disabled={disabled}
250
+ />
251
+ </div>
 
 
 
 
252
 
253
+ {/* Uploaded Files List */}
254
+ {uploadedFiles.length > 0 && (
255
+ <div className="space-y-3 max-h-64 overflow-y-auto">
256
+ {uploadedFiles.map((uploadedFile, index) => {
257
+ const f = uploadedFile.file;
258
+ return (
259
+ <div key={index} className="p-3 bg-muted rounded-md space-y-2">
260
+ <div className="flex items-center gap-3 group">
261
+ {renderLeading(f)}
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
263
+ <div className="flex-1 min-w-0">
264
+ <p className="text-sm truncate">{f.name}</p>
265
+ <p className="text-xs text-muted-foreground">{formatFileSize(f.size)}</p>
266
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
+ <Button
269
+ variant="ghost"
270
+ size="icon"
271
+ className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
272
+ onClick={(e) => {
273
+ e.stopPropagation();
274
+ onRemoveFile(index);
275
+ }}
276
+ title="Remove"
277
+ >
278
+ <X className="h-3 w-3" />
279
+ </Button>
280
+ </div>
281
+
282
+ <div className="space-y-1">
283
+ <label className="text-xs text-muted-foreground">File Type</label>
284
+ <Select
285
+ value={uploadedFile.type}
286
+ onValueChange={(value) => onFileTypeChange(index, value as FileType)}
287
+ >
288
+ <SelectTrigger className="h-8 text-xs">
289
+ <SelectValue />
290
+ </SelectTrigger>
291
+ <SelectContent>
292
+ <SelectItem value="syllabus">Syllabus</SelectItem>
293
+ <SelectItem value="lecture-slides">Lecture Slides / PPT</SelectItem>
294
+ <SelectItem value="literature-review">Literature Review / Paper</SelectItem>
295
+ <SelectItem value="other">Other Course Document</SelectItem>
296
+ </SelectContent>
297
+ </Select>
298
+ </div>
299
+ </div>
300
+ );
301
+ })}
302
+ </div>
303
+ )}
304
+
305
+ {/* Type Selection Dialog */}
306
+ {showTypeDialog && (
307
+ <Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}>
308
+ <DialogContent className="sm:max-w-[425px]">
309
+ <DialogHeader>
310
+ <DialogTitle>Select File Types</DialogTitle>
311
+ <DialogDescription>
312
+ Please select the type for each file you are uploading.
313
+ </DialogDescription>
314
+ </DialogHeader>
315
+
316
+ <div className="space-y-3 max-h-64 overflow-y-auto">
317
+ {pendingFiles.map((pendingFile, index) => {
318
+ const f = pendingFile.file;
319
+ return (
320
+ <div key={index} className="p-3 bg-muted rounded-md space-y-2">
321
+ <div className="flex items-center gap-3">
322
+ {renderLeading(f)}
323
+ <div className="flex-1 min-w-0">
324
+ <p className="text-sm truncate">{f.name}</p>
325
+ <p className="text-xs text-muted-foreground">{formatFileSize(f.size)}</p>
326
+ </div>
327
+ </div>
328
+
329
+ <div className="space-y-1">
330
+ <label className="text-xs text-muted-foreground">File Type</label>
331
+ <Select
332
+ value={pendingFile.type}
333
+ onValueChange={(value) => handlePendingFileTypeChange(index, value as FileType)}
334
+ >
335
+ <SelectTrigger className="h-8 text-xs">
336
+ <SelectValue />
337
+ </SelectTrigger>
338
+ <SelectContent>
339
+ <SelectItem value="syllabus">Syllabus</SelectItem>
340
+ <SelectItem value="lecture-slides">Lecture Slides / PPT</SelectItem>
341
+ <SelectItem value="literature-review">Literature Review / Paper</SelectItem>
342
+ <SelectItem value="other">Other Course Document</SelectItem>
343
+ </SelectContent>
344
+ </Select>
345
+ </div>
346
+ </div>
347
+ );
348
+ })}
349
  </div>
350
 
351
+ <DialogFooter>
352
+ <Button variant="outline" onClick={handleCancelUpload}>
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  Cancel
354
  </Button>
355
+ <Button onClick={handleConfirmUpload}>Upload</Button>
356
+ </DialogFooter>
357
+ </DialogContent>
358
+ </Dialog>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  )}
360
+ </Card>
361
  );
362
+ }