Spaces:
Sleeping
Sleeping
| // web/src/components/ChatArea.tsx | |
| import React, { useRef, useLayoutEffect } from "react"; | |
| import React, { useEffect, useMemo, useState } from "react"; | |
| import { Button } from "./ui/button"; | |
| import { Textarea } from "./ui/textarea"; | |
| import { Input } from "./ui/input"; | |
| import { Label } from "./ui/label"; | |
| import { | |
| Send, | |
| ArrowDown, | |
| Share2, | |
| Upload, | |
| X, | |
| Trash2, | |
| File, | |
| FileText, | |
| Presentation, | |
| Image as ImageIcon, | |
| Bookmark, | |
| Plus, | |
| Download, | |
| Copy, | |
| } from "lucide-react"; | |
| import { Message } from "./Message"; | |
| import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; | |
| import type { | |
| Message as MessageType, | |
| LearningMode, | |
| UploadedFile, | |
| FileType, | |
| SpaceType, | |
| ChatMode, | |
| SavedChat, | |
| Workspace, | |
| } from "../App"; | |
| import { toast } from "sonner"; | |
| import { jsPDF } from "jspdf"; | |
| import { | |
| DropdownMenu, | |
| DropdownMenuContent, | |
| DropdownMenuItem, | |
| DropdownMenuTrigger, | |
| } from "./ui/dropdown-menu"; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogFooter, | |
| DialogHeader, | |
| DialogTitle, | |
| } from "./ui/dialog"; | |
| import { Checkbox } from "./ui/checkbox"; | |
| import { | |
| AlertDialog, | |
| AlertDialogAction, | |
| AlertDialogCancel, | |
| AlertDialogContent, | |
| AlertDialogDescription, | |
| AlertDialogFooter, | |
| AlertDialogHeader, | |
| AlertDialogTitle, | |
| } from "./ui/alert-dialog"; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from "./ui/select"; | |
| import { SmartReview } from "./SmartReview"; | |
| import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png"; | |
| // NEW | |
| import { useObjectUrlCache } from "../lib/useObjectUrlCache"; | |
| type ReviewEventType = "send_message" | "review_topic" | "review_all"; | |
| interface ChatAreaProps { | |
| messages: MessageType[]; | |
| onSendMessage: (content: string) => void; | |
| uploadedFiles: UploadedFile[]; | |
| onFileUpload: (files: File[]) => void; | |
| onRemoveFile: (index: number) => void; | |
| onFileTypeChange: (index: number, type: FileType) => void; | |
| memoryProgress: number; | |
| isLoggedIn: boolean; | |
| learningMode: LearningMode; | |
| onClearConversation: () => void; | |
| onSaveChat: () => void; | |
| onLearningModeChange: (mode: LearningMode) => void; | |
| spaceType: SpaceType; | |
| chatMode: ChatMode; | |
| onChatModeChange: (mode: ChatMode) => void; | |
| onNextQuestion: () => void; | |
| onStartQuiz: () => void; | |
| quizState: { | |
| currentQuestion: number; | |
| waitingForAnswer: boolean; | |
| showNextButton: boolean; | |
| }; | |
| isTyping: boolean; | |
| showClearDialog: boolean; | |
| onConfirmClear: (shouldSave: boolean) => void; | |
| onCancelClear: () => void; | |
| savedChats: SavedChat[]; | |
| workspaces: Workspace[]; | |
| currentWorkspaceId: string; | |
| onSaveFile?: ( | |
| content: string, | |
| type: "export" | "summary", | |
| format?: "pdf" | "text", | |
| workspaceId?: string | |
| ) => void; | |
| leftPanelVisible?: boolean; | |
| currentCourseId?: string; | |
| onCourseChange?: (courseId: string) => void; | |
| availableCourses?: Array<{ id: string; name: string }>; | |
| showReviewBanner?: boolean; | |
| onReviewActivity?: (event: ReviewEventType) => void; | |
| currentUserId?: string; // backend user_id | |
| docType?: string; // backend doc_type (optional) | |
| } | |
| interface PendingFile { | |
| file: File; | |
| type: FileType; | |
| } | |
| export function ChatArea({ | |
| messages, | |
| onSendMessage, | |
| uploadedFiles, | |
| onFileUpload, | |
| onRemoveFile, | |
| onFileTypeChange, | |
| memoryProgress, | |
| isLoggedIn, | |
| learningMode, | |
| onClearConversation, | |
| onSaveChat, | |
| onLearningModeChange, | |
| spaceType, | |
| chatMode, | |
| onChatModeChange, | |
| onNextQuestion, | |
| onStartQuiz, | |
| quizState, | |
| isTyping: isAppTyping, | |
| showClearDialog, | |
| onConfirmClear, | |
| onCancelClear, | |
| savedChats, | |
| workspaces, | |
| currentWorkspaceId, | |
| onSaveFile, | |
| leftPanelVisible = false, | |
| currentCourseId, | |
| onCourseChange, | |
| availableCourses = [], | |
| showReviewBanner = false, | |
| onReviewActivity, | |
| currentUserId, | |
| docType, | |
| }: ChatAreaProps) { | |
| const [input, setInput] = useState(""); | |
| const [showScrollButton, setShowScrollButton] = useState(false); | |
| const [showTopBorder, setShowTopBorder] = useState(false); | |
| const [isDragging, setIsDragging] = useState(false); | |
| const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]); | |
| const [showTypeDialog, setShowTypeDialog] = useState(false); | |
| const [showDeleteDialog, setShowDeleteDialog] = useState(false); | |
| const [fileToDelete, setFileToDelete] = useState<number | null>(null); | |
| const [selectedFile, setSelectedFile] = useState<{ | |
| file: File; | |
| index: number; | |
| } | null>(null); | |
| const [showFileViewer, setShowFileViewer] = useState(false); | |
| const [showDownloadDialog, setShowDownloadDialog] = useState(false); | |
| const [downloadPreview, setDownloadPreview] = useState(""); | |
| const [downloadTab, setDownloadTab] = useState<"chat" | "summary">("chat"); | |
| const [downloadOptions, setDownloadOptions] = useState({ | |
| chat: true, | |
| summary: false, | |
| }); | |
| const [showShareDialog, setShowShareDialog] = useState(false); | |
| const [shareLink, setShareLink] = useState(""); | |
| const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>(""); | |
| const courses = | |
| availableCourses.length > 0 | |
| ? availableCourses | |
| : [ | |
| { id: "course1", name: "Introduction to AI" }, | |
| { id: "course2", name: "Machine Learning" }, | |
| { id: "course3", name: "Data Structures" }, | |
| { id: "course4", name: "Web Development" }, | |
| ]; | |
| // Scroll refs | |
| const scrollContainerRef = useRef<HTMLDivElement>(null); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| // ✅ Composer measured height (dynamic) to reserve bottom padding for messages | |
| const composerRef = useRef<HTMLDivElement>(null); | |
| const [composerHeight, setComposerHeight] = useState<number>(160); | |
| useLayoutEffect(() => { | |
| const el = composerRef.current; | |
| if (!el) return; | |
| const update = () => setComposerHeight(el.getBoundingClientRect().height); | |
| update(); | |
| const ro = new ResizeObserver(() => update()); | |
| ro.observe(el); | |
| return () => ro.disconnect(); | |
| }, []); | |
| const isInitialMount = useRef(true); | |
| const previousMessagesLength = useRef(messages.length); | |
| const scrollToBottom = (behavior: ScrollBehavior = "smooth") => { | |
| const el = scrollContainerRef.current; | |
| if (!el) return; | |
| const top = el.scrollHeight - el.clientHeight; | |
| if (behavior === "auto") { | |
| el.scrollTop = top; | |
| return; | |
| } | |
| el.scrollTo({ top, behavior }); | |
| }; | |
| useEffect(() => { | |
| if (isInitialMount.current) { | |
| isInitialMount.current = false; | |
| previousMessagesLength.current = messages.length; | |
| const el = scrollContainerRef.current; | |
| if (el) el.scrollTop = 0; | |
| return; | |
| } | |
| if (messages.length > previousMessagesLength.current) { | |
| const el = scrollContainerRef.current; | |
| if (el) { | |
| const nearBottom = | |
| el.scrollHeight - el.scrollTop - el.clientHeight < 240; | |
| if (nearBottom) scrollToBottom("smooth"); | |
| } | |
| } | |
| previousMessagesLength.current = messages.length; | |
| }, [messages]); | |
| useEffect(() => { | |
| const container = scrollContainerRef.current; | |
| if (!container) return; | |
| const handleScroll = () => { | |
| const { scrollTop, scrollHeight, clientHeight } = container; | |
| const isAtBottom = scrollHeight - scrollTop - clientHeight < 120; | |
| setShowScrollButton(!isAtBottom); | |
| setShowTopBorder(scrollTop > 0); | |
| }; | |
| handleScroll(); | |
| container.addEventListener("scroll", handleScroll, { passive: true }); | |
| return () => container.removeEventListener("scroll", handleScroll); | |
| }, []); | |
| const handleSubmit = (e: React.FormEvent | React.KeyboardEvent) => { | |
| e.preventDefault(); | |
| if (!input.trim() || !isLoggedIn) return; | |
| if (chatMode === "review") onReviewActivity?.("send_message"); | |
| onSendMessage(input); | |
| setInput(""); | |
| }; | |
| const handleKeyDown = (e: React.KeyboardEvent) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSubmit(e); | |
| } | |
| }; | |
| const modeLabels: Record<LearningMode, string> = { | |
| general: "General", | |
| concept: "Concept Explainer", | |
| socratic: "Socratic Tutor", | |
| exam: "Exam Prep", | |
| assignment: "Assignment Helper", | |
| summary: "Quick Summary", | |
| }; | |
| const handleReviewTopic = (item: { | |
| title: string; | |
| previousQuestion: string; | |
| memoryRetention: number; | |
| schedule: string; | |
| status: string; | |
| weight: number; | |
| lastReviewed: string; | |
| }) => { | |
| onReviewActivity?.("review_topic"); | |
| const userMessage = `Please help me review: ${item.title}`; | |
| const reviewData = `REVIEW_TOPIC:${item.title}|${item.previousQuestion}|${item.memoryRetention}|${item.schedule}|${item.status}|${item.weight}|${item.lastReviewed}`; | |
| (window as any).__lastReviewData = reviewData; | |
| onSendMessage(userMessage); | |
| }; | |
| const handleReviewAll = () => { | |
| onReviewActivity?.("review_all"); | |
| (window as any).__lastReviewData = "REVIEW_ALL"; | |
| onSendMessage("Please help me review all topics that need attention."); | |
| }; | |
| const buildPreviewContent = () => { | |
| if (!messages.length) return ""; | |
| return messages | |
| .map( | |
| (msg) => | |
| `${msg.role === "user" ? "You" : "Clare"}: ${msg.content}` | |
| ) | |
| .join("\n\n"); | |
| }; | |
| const buildSummaryContent = () => { | |
| if (!messages.length) return "No messages to summarize."; | |
| const userMessages = messages.filter((msg) => msg.role === "user"); | |
| const assistantMessages = messages.filter((msg) => msg.role === "assistant"); | |
| let summary = `Chat Summary\n================\n\n`; | |
| summary += `Total Messages: ${messages.length}\n`; | |
| summary += `- User Messages: ${userMessages.length}\n`; | |
| summary += `- Assistant Responses: ${assistantMessages.length}\n\n`; | |
| summary += `Key Points:\n`; | |
| userMessages.slice(0, 3).forEach((msg, idx) => { | |
| const preview = msg.content.substring(0, 80); | |
| summary += `${idx + 1}. ${preview}${ | |
| msg.content.length > 80 ? "..." : "" | |
| }\n`; | |
| }); | |
| return summary; | |
| }; | |
| const handleOpenDownloadDialog = () => { | |
| setDownloadTab("chat"); | |
| setDownloadOptions({ chat: true, summary: false }); | |
| setDownloadPreview(buildPreviewContent()); | |
| setShowDownloadDialog(true); | |
| }; | |
| const handleCopyPreview = async () => { | |
| try { | |
| await navigator.clipboard.writeText(downloadPreview); | |
| toast.success("Copied preview"); | |
| } catch { | |
| toast.error("Copy failed"); | |
| } | |
| }; | |
| const handleDownloadFile = async () => { | |
| try { | |
| let contentToPdf = ""; | |
| if (downloadOptions.chat) contentToPdf += buildPreviewContent(); | |
| if (downloadOptions.summary) { | |
| if (downloadOptions.chat) contentToPdf += "\n\n================\n\n"; | |
| contentToPdf += buildSummaryContent(); | |
| } | |
| if (!contentToPdf.trim()) { | |
| toast.error("Please select at least one option"); | |
| return; | |
| } | |
| const pdf = new jsPDF({ | |
| orientation: "portrait", | |
| unit: "mm", | |
| format: "a4", | |
| }); | |
| pdf.setFontSize(14); | |
| pdf.text("Chat Export", 10, 10); | |
| pdf.setFontSize(11); | |
| const pageHeight = pdf.internal.pageSize.getHeight(); | |
| const margin = 10; | |
| const maxWidth = 190; | |
| const lineHeight = 5; | |
| let y = 20; | |
| const lines = pdf.splitTextToSize(contentToPdf, maxWidth); | |
| lines.forEach((line: string) => { | |
| if (y > pageHeight - margin) { | |
| pdf.addPage(); | |
| y = margin; | |
| } | |
| pdf.text(line, margin, y); | |
| y += lineHeight; | |
| }); | |
| pdf.save("chat-export.pdf"); | |
| setShowDownloadDialog(false); | |
| toast.success("PDF downloaded successfully"); | |
| } catch (error) { | |
| // eslint-disable-next-line no-console | |
| console.error("PDF generation error:", error); | |
| toast.error("Failed to generate PDF"); | |
| } | |
| }; | |
| const isCurrentChatSaved = (): boolean => { | |
| if (messages.length <= 1) return false; | |
| return savedChats.some((chat) => { | |
| if (chat.chatMode !== chatMode) return false; | |
| if (chat.messages.length !== messages.length) return false; | |
| return chat.messages.every((savedMsg, idx) => { | |
| const currentMsg = messages[idx]; | |
| return ( | |
| savedMsg.id === currentMsg.id && | |
| savedMsg.role === currentMsg.role && | |
| savedMsg.content === currentMsg.content | |
| ); | |
| }); | |
| }); | |
| }; | |
| const handleSaveClick = () => { | |
| if (messages.length <= 1) { | |
| toast.info("No conversation to save"); | |
| return; | |
| } | |
| onSaveChat(); | |
| }; | |
| const handleShareClick = () => { | |
| if (messages.length <= 1) { | |
| toast.info("No conversation to share"); | |
| return; | |
| } | |
| const conversationText = buildPreviewContent(); | |
| const blob = new Blob([conversationText], { type: "text/plain" }); | |
| const url = URL.createObjectURL(blob); | |
| setShareLink(url); | |
| setTargetWorkspaceId(currentWorkspaceId); | |
| setShowShareDialog(true); | |
| }; | |
| const handleCopyShareLink = async () => { | |
| try { | |
| await navigator.clipboard.writeText(shareLink); | |
| toast.success("Link copied"); | |
| } catch { | |
| toast.error("Failed to copy link"); | |
| } | |
| }; | |
| const handleShareSendToWorkspace = () => { | |
| const content = buildPreviewContent(); | |
| onSaveFile?.(content, "export", "text", targetWorkspaceId); | |
| setShowShareDialog(false); | |
| toast.success("Sent to workspace Saved Files"); | |
| }; | |
| const handleClearClick = () => { | |
| const saved = isCurrentChatSaved(); | |
| if (saved) { | |
| onConfirmClear(false as any); | |
| return; | |
| } | |
| const hasUserMessages = messages.some((m) => m.role === "user"); | |
| if (!hasUserMessages) { | |
| onClearConversation(); | |
| return; | |
| } | |
| onClearConversation(); | |
| }; | |
| // DnD | |
| const handleDragOver = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (!isLoggedIn) return; | |
| setIsDragging(true); | |
| }; | |
| const handleDragLeave = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setIsDragging(false); | |
| }; | |
| const handleDrop = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setIsDragging(false); | |
| if (!isLoggedIn) return; | |
| const fileList = e.dataTransfer.files; | |
| const files: File[] = []; | |
| for (let i = 0; i < fileList.length; i++) { | |
| const f = fileList.item(i); | |
| if (f) files.push(f); | |
| } | |
| const validFiles = files.filter((file) => { | |
| const ext = file.name.toLowerCase(); | |
| return [ | |
| ".pdf", | |
| ".docx", | |
| ".pptx", | |
| ".jpg", | |
| ".jpeg", | |
| ".png", | |
| ".gif", | |
| ".webp", | |
| ".doc", | |
| ".ppt", | |
| ].some((allowed) => ext.endsWith(allowed)); | |
| }); | |
| if (validFiles.length > 0) { | |
| setPendingFiles( | |
| validFiles.map((file) => ({ file, type: "other" as FileType })) | |
| ); | |
| setShowTypeDialog(true); | |
| } else { | |
| toast.error("Please upload .pdf, .docx, .pptx, or image files"); | |
| } | |
| }; | |
| const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const files = Array.from(e.target.files || []) as File[]; | |
| if (files.length > 0) { | |
| const validFiles = files.filter((file) => { | |
| const ext = file.name.toLowerCase(); | |
| return [ | |
| ".pdf", | |
| ".docx", | |
| ".pptx", | |
| ".jpg", | |
| ".jpeg", | |
| ".png", | |
| ".gif", | |
| ".webp", | |
| ".doc", | |
| ".ppt", | |
| ].some((allowed) => ext.endsWith(allowed)); | |
| }); | |
| if (validFiles.length > 0) { | |
| setPendingFiles( | |
| validFiles.map((file) => ({ file, type: "other" as FileType })) | |
| ); | |
| setShowTypeDialog(true); | |
| } else { | |
| toast.error("Please upload .pdf, .docx, .pptx, or image files"); | |
| } | |
| } | |
| 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); | |
| }); | |
| const count = pendingFiles.length; | |
| setPendingFiles([]); | |
| setShowTypeDialog(false); | |
| toast.success(`${count} file(s) uploaded successfully`); | |
| }; | |
| const handleCancelUpload = () => { | |
| setPendingFiles([]); | |
| setShowTypeDialog(false); | |
| }; | |
| const handlePendingFileTypeChange = (index: number, type: FileType) => { | |
| setPendingFiles((prev) => | |
| prev.map((pf, i) => (i === index ? { ...pf, type } : pf)) | |
| ); | |
| }; | |
| // File helpers | |
| const getFileIcon = (filename: string) => { | |
| const ext = filename.toLowerCase(); | |
| if (ext.endsWith(".pdf")) return FileText; | |
| if (ext.endsWith(".docx") || ext.endsWith(".doc")) return File; | |
| if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return Presentation; | |
| if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e))) | |
| return ImageIcon; | |
| return File; | |
| }; | |
| const 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`; | |
| }; | |
| const fileKey = (f: File) => `${f.name}::${f.size}::${f.lastModified}`; | |
| // ✅ useObjectUrlCache: for image thumbnails (uploaded + pending) | |
| const allThumbFiles = React.useMemo(() => { | |
| return [...uploadedFiles.map((u) => u.file), ...pendingFiles.map((p) => p.file)]; | |
| }, [uploadedFiles, pendingFiles]); | |
| const { getOrCreate } = useObjectUrlCache(allThumbFiles); | |
| // ✅ NEW: a compact "chip" UI (the one with left X) | |
| const FileChip = ({ | |
| file, | |
| index, | |
| source, | |
| }: { | |
| file: File; | |
| index: number; | |
| source: "uploaded" | "pending"; | |
| }) => { | |
| const ext = file.name.toLowerCase(); | |
| const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e)); | |
| const label = ext.endsWith(".pdf") | |
| ? "PDF" | |
| : ext.endsWith(".pptx") || ext.endsWith(".ppt") | |
| ? "Presentation" | |
| : ext.endsWith(".docx") || ext.endsWith(".doc") | |
| ? "Document" | |
| : isImage | |
| ? "Image" | |
| : "File"; | |
| const thumbUrl = isImage ? getOrCreate(file) : null; | |
| const handleRemove = () => { | |
| if (source === "uploaded") onRemoveFile(index); | |
| else setPendingFiles((prev) => prev.filter((p) => fileKey(p.file) !== fileKey(file))); | |
| }; | |
| return ( | |
| <div className="flex items-center gap-2 rounded-xl border border-border bg-card px-3 py-2 shadow-sm w-[320px] max-w-full"> | |
| <button | |
| type="button" | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| handleRemove(); | |
| }} | |
| className="ml-2 inline-flex h-7 w-7 items-center justify-center rounded-md border border-border bg-card hover:bg-muted" | |
| title="Remove" | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| </button> | |
| <div className="min-w-0 flex-1"> | |
| <div className="text-sm font-medium truncate" title={file.name}> | |
| {file.name} | |
| </div> | |
| <div className="text-xs text-muted-foreground">{label}</div> | |
| </div> | |
| {isImage ? ( | |
| <div className="relative h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted"> | |
| {thumbUrl ? ( | |
| <img | |
| src={thumbUrl} | |
| alt={file.name} | |
| className="h-full w-full object-cover" | |
| draggable={false} | |
| /> | |
| ) : ( | |
| <div className="h-full w-full flex items-center justify-center"> | |
| <ImageIcon className="h-4 w-4 text-muted-foreground" /> | |
| </div> | |
| )} | |
| </div> | |
| ) : null} | |
| </div> | |
| ); | |
| }; | |
| const bottomPad = Math.max(24, composerHeight + 24); | |
| return ( | |
| <div className="relative flex flex-col h-full min-h-0 w-full overflow-hidden"> | |
| {/* Top Bar */} | |
| <div | |
| className={`flex-shrink-0 flex items-center justify-between px-4 bg-card z-20 ${ | |
| showTopBorder ? "border-b border-border" : "" | |
| }`} | |
| style={{ | |
| height: "4.5rem", | |
| margin: 0, | |
| padding: "1rem 1rem", | |
| boxSizing: "border-box", | |
| }} | |
| > | |
| {/* Course Selector - Left */} | |
| <div className="flex-shrink-0"> | |
| {(() => { | |
| const current = workspaces.find((w) => w.id === currentWorkspaceId); | |
| if (current?.type === "group") { | |
| if (current.category === "course" && current.courseName) { | |
| return ( | |
| <div className="h-9 px-3 inline-flex items-center rounded-md border font-semibold"> | |
| {current.courseName} | |
| </div> | |
| ); | |
| } | |
| return null; | |
| } | |
| return ( | |
| <Select | |
| value={currentCourseId || "course1"} | |
| onValueChange={(val) => onCourseChange && onCourseChange(val)} | |
| > | |
| <SelectTrigger className="w-[200px] h-9 font-semibold"> | |
| <SelectValue placeholder="Select course" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| {courses.map((course) => ( | |
| <SelectItem key={course.id} value={course.id}> | |
| {course.name} | |
| </SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| ); | |
| })()} | |
| </div> | |
| {/* Tabs - Center */} | |
| <div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0"> | |
| <Tabs | |
| value={chatMode} | |
| onValueChange={(value) => onChatModeChange(value as ChatMode)} | |
| className="w-auto" | |
| orientation="horizontal" | |
| > | |
| <TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground"> | |
| <TabsTrigger value="ask" className="w-[140px] px-3 text-sm"> | |
| Ask | |
| </TabsTrigger> | |
| <TabsTrigger value="review" className="w-[140px] px-3 text-sm relative"> | |
| Review | |
| <span | |
| className="absolute top-0 right-0 bg-red-500 rounded-full border-2" | |
| style={{ | |
| width: 10, | |
| height: 10, | |
| transform: "translate(25%, -25%)", | |
| zIndex: 10, | |
| borderColor: "var(--muted)", | |
| }} | |
| /> | |
| </TabsTrigger> | |
| <TabsTrigger value="quiz" className="w-[140px] px-3 text-sm"> | |
| Quiz | |
| </TabsTrigger> | |
| </TabsList> | |
| </Tabs> | |
| </div> | |
| {/* Action Buttons - Right */} | |
| <div className="flex items-center gap-2 flex-shrink-0"> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={handleSaveClick} | |
| disabled={!isLoggedIn} | |
| className={`h-8 w-8 rounded-md hover:bg-muted/50 ${ | |
| isCurrentChatSaved() ? "text-primary" : "" | |
| }`} | |
| title={isCurrentChatSaved() ? "Unsave" : "Save"} | |
| > | |
| <Bookmark | |
| className={`h-4 w-4 ${ | |
| isCurrentChatSaved() ? "fill-primary text-primary" : "" | |
| }`} | |
| /> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={handleOpenDownloadDialog} | |
| disabled={!isLoggedIn} | |
| className="h-8 w-8 rounded-md hover:bg-muted/50" | |
| title="Download" | |
| > | |
| <Download className="h-4 w-4" /> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={handleShareClick} | |
| disabled={!isLoggedIn} | |
| className="h-8 w-8 rounded-md hover:bg-muted/50" | |
| title="Share" | |
| > | |
| <Share2 className="h-4 w-4" /> | |
| </Button> | |
| <Button | |
| variant="outline" | |
| onClick={handleClearClick} | |
| disabled={!isLoggedIn} | |
| className="h-8 px-3 gap-2 rounded-md border border-border disabled:opacity-60 !bg-[var(--card)] !text-[var(--card-foreground)] hover:!opacity-90 [&_svg]:!text-[var(--card-foreground)] [&_span]:!text-[var(--card-foreground)]" | |
| title="New Chat" | |
| > | |
| <Plus className="h-4 w-4" /> | |
| <span className="text-sm font-medium">New chat</span> | |
| </Button> | |
| </div> | |
| </div> | |
| {/* Scroll Container */} | |
| <div | |
| ref={scrollContainerRef} | |
| className="flex-1 min-h-0 overflow-y-auto overscroll-contain" | |
| style={{ overscrollBehavior: "contain" }} | |
| > | |
| <div className="py-6" style={{ paddingBottom: bottomPad }}> | |
| <div className="w-full space-y-6 max-w-4xl mx-auto"> | |
| {messages.map((message) => ( | |
| <React.Fragment key={message.id}> | |
| <Message | |
| message={message} | |
| showSenderInfo={spaceType === "group"} | |
| isFirstGreeting={ | |
| (message.id === "1" || | |
| message.id === "review-1" || | |
| message.id === "quiz-1") && | |
| message.role === "assistant" | |
| } | |
| showNextButton={message.showNextButton && !isAppTyping} | |
| onNextQuestion={onNextQuestion} | |
| chatMode={chatMode} | |
| currentUserId={currentUserId} | |
| learningMode={learningMode} | |
| docType={docType} | |
| /> | |
| {chatMode === "review" && | |
| message.id === "review-1" && | |
| message.role === "assistant" && ( | |
| <div className="flex gap-2 justify-start px-4"> | |
| <div className="w-10 h-10 flex-shrink-0" /> | |
| <div | |
| className="w-full" | |
| style={{ | |
| maxWidth: "min(770px, calc(100% - 2rem))", | |
| }} | |
| > | |
| <SmartReview | |
| onReviewTopic={handleReviewTopic} | |
| onReviewAll={handleReviewAll} | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| {chatMode === "quiz" && | |
| message.id === "quiz-1" && | |
| message.role === "assistant" && | |
| quizState.currentQuestion === 0 && | |
| !quizState.waitingForAnswer && | |
| !isAppTyping && ( | |
| <div className="flex justify-center py-4"> | |
| <Button | |
| onClick={onStartQuiz} | |
| className="bg-red-500 hover:bg-red-600 text-white" | |
| > | |
| Start Quiz | |
| </Button> | |
| </div> | |
| )} | |
| </React.Fragment> | |
| ))} | |
| {isAppTyping && ( | |
| <div className="flex gap-2 justify-start px-4"> | |
| <div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0"> | |
| <img | |
| src={clareAvatar} | |
| alt="Clare" | |
| className="w-full h-full object-cover" | |
| /> | |
| </div> | |
| <div className="bg-muted rounded-2xl px-4 py-3"> | |
| <div className="flex gap-1"> | |
| <div | |
| className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" | |
| style={{ animationDelay: "0ms" }} | |
| /> | |
| <div | |
| className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" | |
| style={{ animationDelay: "150ms" }} | |
| /> | |
| <div | |
| className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" | |
| style={{ animationDelay: "300ms" }} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Scroll-to-bottom button */} | |
| {showScrollButton && ( | |
| <div | |
| className="absolute z-30 left-0 right-0 flex justify-center pointer-events-none" | |
| style={{ bottom: composerHeight + 16 }} | |
| > | |
| <Button | |
| variant="secondary" | |
| size="icon" | |
| className="rounded-full shadow-lg hover:shadow-xl transition-shadow bg-background border border-border pointer-events-auto w-10 h-10" | |
| onClick={() => scrollToBottom("smooth")} | |
| title="Scroll to bottom" | |
| > | |
| <ArrowDown className="h-5 w-5" /> | |
| </Button> | |
| </div> | |
| )} | |
| {/* Composer */} | |
| <div | |
| ref={composerRef} | |
| className="flex-shrink-0 bg-background/95 backdrop-blur-sm z-20 border-t border-border" | |
| > | |
| <div className="max-w-4xl mx-auto px-4 py-4"> | |
| {/* Uploaded Files Preview (chip UI) */} | |
| {(uploadedFiles.length > 0 || pendingFiles.length > 0) && ( | |
| <div className="mb-2 flex flex-wrap gap-2 max-h-32 overflow-y-auto"> | |
| {/* uploaded */} | |
| {uploadedFiles.map((uf, i) => { | |
| const key = `${uf.file.name}::${uf.file.size}::${uf.file.lastModified}`; | |
| const ext = uf.file.name.toLowerCase(); | |
| const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => | |
| ext.endsWith(e) | |
| ); | |
| const thumbUrl = isImage ? getOrCreate(uf.file) : null; | |
| return ( | |
| <div | |
| key={key} | |
| className="flex items-center justify-between gap-2 rounded-md border px-3 py-2" | |
| > | |
| {/* ✅ Thumbnail (only for images) */} | |
| {isImage ? ( | |
| <div className="h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted"> | |
| {thumbUrl ? ( | |
| <img | |
| src={thumbUrl} | |
| alt={uf.file.name} | |
| className="h-full w-full object-cover" | |
| draggable={false} | |
| /> | |
| ) : ( | |
| <div className="h-full w-full flex items-center justify-center"> | |
| <ImageIcon className="h-4 w-4 text-muted-foreground" /> | |
| </div> | |
| )} | |
| </div> | |
| ) : null} | |
| <div className="min-w-0 flex-1"> | |
| <div className="truncate text-sm font-medium"> | |
| {uf.file.name} | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| {uf.type} | |
| </div> | |
| </div> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={() => onRemoveFile(i)} | |
| title="Remove" | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| ); | |
| })} | |
| {/* pending */} | |
| {pendingFiles.map((p, idx) => ( | |
| <FileChip | |
| key={`p-${p.file.name}-${p.file.size}-${p.file.lastModified}`} | |
| file={p.file} | |
| index={idx} | |
| source="pending" | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| <form | |
| onSubmit={handleSubmit as any} | |
| onDragOver={handleDragOver} | |
| onDragLeave={handleDragLeave} | |
| onDrop={handleDrop} | |
| className={isDragging ? "opacity-75" : ""} | |
| > | |
| <div className="relative"> | |
| {/* Mode Selector + Upload */} | |
| <div className="absolute bottom-3 left-2 flex items-center gap-1 z-10"> | |
| {chatMode === "ask" && ( | |
| <DropdownMenu> | |
| <DropdownMenuTrigger asChild> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="gap-1.5 h-8 px-2 text-xs hover:bg-muted/50" | |
| disabled={!isLoggedIn} | |
| type="button" | |
| > | |
| <span>{modeLabels[learningMode]}</span> | |
| <svg | |
| className="h-3 w-3 opacity-50" | |
| fill="none" | |
| stroke="currentColor" | |
| viewBox="0 0 24 24" | |
| > | |
| <path | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| strokeWidth={2} | |
| d="M19 9l-7 7-7-7" | |
| /> | |
| </svg> | |
| </Button> | |
| </DropdownMenuTrigger> | |
| <DropdownMenuContent align="start" className="w-56"> | |
| <DropdownMenuItem | |
| onClick={() => onLearningModeChange("general")} | |
| className={learningMode === "general" ? "bg-accent" : ""} | |
| > | |
| <div className="flex flex-col"> | |
| <span className="font-medium">General</span> | |
| <span className="text-xs text-muted-foreground"> | |
| Answer various questions (context required) | |
| </span> | |
| </div> | |
| </DropdownMenuItem> | |
| <DropdownMenuItem | |
| onClick={() => onLearningModeChange("concept")} | |
| className={learningMode === "concept" ? "bg-accent" : ""} | |
| > | |
| <div className="flex flex-col"> | |
| <span className="font-medium">Concept Explainer</span> | |
| <span className="text-xs text-muted-foreground"> | |
| Get detailed explanations of concepts | |
| </span> | |
| </div> | |
| </DropdownMenuItem> | |
| <DropdownMenuItem | |
| onClick={() => onLearningModeChange("socratic")} | |
| className={learningMode === "socratic" ? "bg-accent" : ""} | |
| > | |
| <div className="flex flex-col"> | |
| <span className="font-medium">Socratic Tutor</span> | |
| <span className="text-xs text-muted-foreground"> | |
| Learn through guided questions | |
| </span> | |
| </div> | |
| </DropdownMenuItem> | |
| <DropdownMenuItem | |
| onClick={() => onLearningModeChange("exam")} | |
| className={learningMode === "exam" ? "bg-accent" : ""} | |
| > | |
| <div className="flex flex-col"> | |
| <span className="font-medium">Exam Prep</span> | |
| <span className="text-xs text-muted-foreground"> | |
| Practice with quiz questions | |
| </span> | |
| </div> | |
| </DropdownMenuItem> | |
| <DropdownMenuItem | |
| onClick={() => onLearningModeChange("assignment")} | |
| className={learningMode === "assignment" ? "bg-accent" : ""} | |
| > | |
| <div className="flex flex-col"> | |
| <span className="font-medium">Assignment Helper</span> | |
| <span className="text-xs text-muted-foreground"> | |
| Get help with assignments | |
| </span> | |
| </div> | |
| </DropdownMenuItem> | |
| <DropdownMenuItem | |
| onClick={() => onLearningModeChange("summary")} | |
| className={learningMode === "summary" ? "bg-accent" : ""} | |
| > | |
| <div className="flex flex-col"> | |
| <span className="font-medium">Quick Summary</span> | |
| <span className="text-xs text-muted-foreground"> | |
| Get concise summaries | |
| </span> | |
| </div> | |
| </DropdownMenuItem> | |
| </DropdownMenuContent> | |
| </DropdownMenu> | |
| )} | |
| <Button | |
| type="button" | |
| size="icon" | |
| variant="ghost" | |
| disabled={ | |
| !isLoggedIn || | |
| (chatMode === "quiz" && !quizState.waitingForAnswer) | |
| } | |
| className="h-8 w-8 hover:bg-muted/50" | |
| onClick={() => fileInputRef.current?.click()} | |
| title="Upload files" | |
| > | |
| <Upload className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| <Textarea | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder={ | |
| !isLoggedIn | |
| ? "Please log in on the right to start chatting..." | |
| : chatMode === "quiz" | |
| ? quizState.waitingForAnswer | |
| ? "Type your answer here..." | |
| : quizState.currentQuestion > 0 | |
| ? "Click 'Next Question' to continue..." | |
| : "Click 'Start Quiz' to begin..." | |
| : spaceType === "group" | |
| ? "Type a message or drag files here... (mention @Clare to get AI assistance)" | |
| : learningMode === "general" | |
| ? "Ask me anything! Please provide context about your question..." | |
| : "Ask Clare anything about the course or drag files here..." | |
| } | |
| disabled={ | |
| !isLoggedIn || | |
| (chatMode === "quiz" && !quizState.waitingForAnswer) | |
| } | |
| className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${ | |
| isDragging ? "border-primary border-dashed" : "border-border" | |
| }`} | |
| /> | |
| <div className="absolute bottom-2 right-2 flex gap-1"> | |
| <Button | |
| type="submit" | |
| size="icon" | |
| disabled={!input.trim() || !isLoggedIn} | |
| className="h-8 w-8 rounded-full" | |
| > | |
| <Send className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| multiple | |
| accept=".pdf,.docx,.pptx,.doc,.ppt,.jpg,.jpeg,.png,.gif,.webp" | |
| onChange={handleFileSelect} | |
| className="hidden" | |
| disabled={!isLoggedIn} | |
| /> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| {/* Start New Conversation Confirmation Dialog */} | |
| <AlertDialog open={showClearDialog} onOpenChange={onCancelClear}> | |
| <AlertDialogContent> | |
| <AlertDialogHeader> | |
| <AlertDialogTitle>Start New Conversation</AlertDialogTitle> | |
| <AlertDialogDescription>Would you like to save the current chat before starting a new conversation?</AlertDialogDescription> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="absolute right-4 top-4 h-6 w-6 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground" | |
| onClick={onCancelClear} | |
| > | |
| <X className="h-4 w-4" /> | |
| <span className="sr-only">Close</span> | |
| </Button> | |
| </AlertDialogHeader> | |
| <AlertDialogFooter className="flex-col sm:flex-row gap-2 sm:justify-end"> | |
| <Button variant="outline" onClick={() => onConfirmClear(false)} className="sm:flex-1 sm:max-w-[200px]"> | |
| Start New (Don't Save) | |
| </Button> | |
| <AlertDialogAction onClick={() => onConfirmClear(true)} className="sm:flex-1 sm:max-w-[200px]"> | |
| Save & Start New | |
| </AlertDialogAction> | |
| </AlertDialogFooter> | |
| </AlertDialogContent> | |
| </AlertDialog> | |
| {/* Download Preview Dialog */} | |
| <Dialog open={showDownloadDialog} onOpenChange={setShowDownloadDialog}> | |
| <DialogContent className="max-w-3xl"> | |
| <DialogHeader> | |
| <DialogTitle>Download this chat</DialogTitle> | |
| <DialogDescription>Preview and copy before downloading.</DialogDescription> | |
| </DialogHeader> | |
| <Tabs | |
| value={downloadTab} | |
| onValueChange={(value) => { | |
| const v = value as "chat" | "summary"; | |
| setDownloadTab(v); | |
| setDownloadPreview(v === "chat" ? buildPreviewContent() : buildSummaryContent()); | |
| if (v === "summary") setDownloadOptions({ chat: false, summary: true }); | |
| else setDownloadOptions({ chat: true, summary: false }); | |
| }} | |
| className="w-full" | |
| > | |
| <TabsList className="grid w-full grid-cols-2"> | |
| <TabsTrigger value="chat">Download chat</TabsTrigger> | |
| <TabsTrigger value="summary">Summary of the chat</TabsTrigger> | |
| </TabsList> | |
| </Tabs> | |
| <div className="border rounded-lg bg-muted/40 flex flex-col max-h-64"> | |
| <div className="flex items-center justify-between p-4 sticky top-0 bg-muted/40 border-b z-10"> | |
| <span className="text-sm font-medium">Preview</span> | |
| <Button variant="outline" size="sm" className="h-7 px-2 text-xs gap-1.5" onClick={handleCopyPreview} title="Copy preview"> | |
| <Copy className="h-3 w-3" /> | |
| Copy | |
| </Button> | |
| </div> | |
| <div className="text-sm text-foreground overflow-y-auto flex-1 p-4"> | |
| <div className="whitespace-pre-wrap">{downloadPreview}</div> | |
| </div> | |
| </div> | |
| <div className="space-y-3"> | |
| <div className="flex items-center space-x-2"> | |
| <Checkbox | |
| id="download-chat" | |
| checked={downloadOptions.chat} | |
| onCheckedChange={(checked) => setDownloadOptions({ ...downloadOptions, chat: checked === true })} | |
| /> | |
| <label htmlFor="download-chat" className="text-sm font-medium cursor-pointer"> | |
| Download chat | |
| </label> | |
| </div> | |
| <div className="flex items-center space-x-2"> | |
| <Checkbox | |
| id="download-summary" | |
| checked={downloadOptions.summary} | |
| onCheckedChange={(checked) => setDownloadOptions({ ...downloadOptions, summary: checked === true })} | |
| /> | |
| <label htmlFor="download-summary" className="text-sm font-medium cursor-pointer"> | |
| Download summary | |
| </label> | |
| </div> | |
| </div> | |
| <DialogFooter> | |
| <Button variant="outline" onClick={() => setShowDownloadDialog(false)}> | |
| Cancel | |
| </Button> | |
| <Button onClick={handleDownloadFile}>Download</Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| {/* Share Dialog */} | |
| <Dialog open={showShareDialog} onOpenChange={setShowShareDialog}> | |
| <DialogContent className="w-[600px] max-w-[600px] sm:max-w-[600px]"> | |
| <DialogHeader> | |
| <DialogTitle>Share Conversation</DialogTitle> | |
| <DialogDescription>Select how you want to share.</DialogDescription> | |
| </DialogHeader> | |
| <div className="space-y-4"> | |
| <div className="space-y-2"> | |
| <Label>Copy Link</Label> | |
| <div className="flex gap-2 items-center"> | |
| <Input value={shareLink} readOnly className="flex-1" /> | |
| <Button variant="secondary" onClick={handleCopyShareLink}> | |
| Copy | |
| </Button> | |
| </div> | |
| <p className="text-xs text-muted-foreground">Temporary link valid for this session.</p> | |
| </div> | |
| <div className="space-y-2"> | |
| <Label>Send to Workspace</Label> | |
| <Select value={targetWorkspaceId} onValueChange={setTargetWorkspaceId}> | |
| <SelectTrigger className="w-full"> | |
| <SelectValue placeholder="Choose a workspace" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| {workspaces.map((w) => ( | |
| <SelectItem key={w.id} value={w.id}> | |
| {w.name} | |
| </SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| <p className="text-xs text-muted-foreground">Sends this conversation to the selected workspace's Saved Files.</p> | |
| <Button onClick={handleShareSendToWorkspace} className="w-full"> | |
| Send | |
| </Button> | |
| </div> | |
| </div> | |
| </DialogContent> | |
| </Dialog> | |
| {/* Delete File Confirmation Dialog (kept, but if triggered, delete is correct now) */} | |
| <AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> | |
| <AlertDialogContent> | |
| <AlertDialogHeader> | |
| <AlertDialogTitle>Delete File</AlertDialogTitle> | |
| <AlertDialogDescription> | |
| Are you sure you want to delete "{fileToDelete !== null ? uploadedFiles[fileToDelete]?.file.name : ""}"? This action cannot be undone. | |
| </AlertDialogDescription> | |
| </AlertDialogHeader> | |
| <AlertDialogFooter> | |
| <AlertDialogCancel>Cancel</AlertDialogCancel> | |
| <AlertDialogAction | |
| onClick={() => { | |
| if (fileToDelete !== null) { | |
| onRemoveFile(fileToDelete); // ✅ pass index | |
| setFileToDelete(null); | |
| } | |
| setShowDeleteDialog(false); | |
| }} | |
| > | |
| Delete | |
| </AlertDialogAction> | |
| </AlertDialogFooter> | |
| </AlertDialogContent> | |
| </AlertDialog> | |
| {/* File Viewer Dialog */} | |
| <Dialog open={showFileViewer} onOpenChange={setShowFileViewer}> | |
| <DialogContent className="max-w-4xl max-h-[85vh] flex flex-col overflow-hidden"> | |
| <DialogHeader className="min-w-0 flex-shrink-0"> | |
| <DialogTitle | |
| className="pr-8 break-words break-all overflow-wrap-anywhere leading-relaxed" | |
| style={{ wordBreak: "break-all", overflowWrap: "anywhere", maxWidth: "100%", lineHeight: "1.6" }} | |
| > | |
| {selectedFile?.file.name} | |
| </DialogTitle> | |
| <DialogDescription>File size: {selectedFile ? formatFileSize(selectedFile.file.size) : ""}</DialogDescription> | |
| </DialogHeader> | |
| <div className="flex-1 min-h-0 overflow-y-auto mt-4">{selectedFile && <FileViewerContent file={selectedFile.file} />}</div> | |
| </DialogContent> | |
| </Dialog> | |
| {/* File Type Selection Dialog */} | |
| {showTypeDialog && ( | |
| <Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}> | |
| <DialogContent className="sm:max-w-[425px]" style={{ zIndex: 99999 }}> | |
| <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 Icon = getFileIcon(pendingFile.file.name); | |
| return ( | |
| <div key={index} className="p-3 bg-muted rounded-md space-y-2"> | |
| <div className="flex items-center gap-2 group"> | |
| <Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" /> | |
| <div className="flex-1 min-w-0"> | |
| <p className="text-sm truncate">{pendingFile.file.name}</p> | |
| <p className="text-xs text-muted-foreground">{formatFileSize(pendingFile.file.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 className="!z-[100000] !bg-background !text-foreground" style={{ zIndex: 100000 }}> | |
| <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> | |
| )} | |
| </div> | |
| ); | |
| } |