SarahXia0405 commited on
Commit
28dfe30
·
verified ·
1 Parent(s): 832b480

Update web/src/components/ChatArea.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/ChatArea.tsx +108 -52
web/src/components/ChatArea.tsx CHANGED
@@ -33,8 +33,20 @@ import type {
33
  } from "../App";
34
  import { toast } from "sonner";
35
  import { jsPDF } from "jspdf";
36
- import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
37
- import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
 
 
 
 
 
 
 
 
 
 
 
 
38
  import { Checkbox } from "./ui/checkbox";
39
  import {
40
  AlertDialog,
@@ -46,10 +58,19 @@ import {
46
  AlertDialogHeader,
47
  AlertDialogTitle,
48
  } from "./ui/alert-dialog";
49
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
 
 
 
 
 
 
50
  import { SmartReview } from "./SmartReview";
51
  import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
52
 
 
 
 
53
  type ReviewEventType = "send_message" | "review_topic" | "review_all";
54
 
55
  interface ChatAreaProps {
@@ -82,7 +103,12 @@ interface ChatAreaProps {
82
  savedChats: SavedChat[];
83
  workspaces: Workspace[];
84
  currentWorkspaceId: string;
85
- onSaveFile?: (content: string, type: "export" | "summary", format?: "pdf" | "text", workspaceId?: string) => void;
 
 
 
 
 
86
  leftPanelVisible?: boolean;
87
  currentCourseId?: string;
88
  onCourseChange?: (courseId: string) => void;
@@ -155,6 +181,9 @@ export function ChatArea({
155
  const [shareLink, setShareLink] = useState("");
156
  const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
157
 
 
 
 
158
  const courses =
159
  availableCourses.length > 0
160
  ? availableCourses
@@ -298,7 +327,9 @@ export function ChatArea({
298
 
299
  const buildPreviewContent = () => {
300
  if (!messages.length) return "";
301
- return messages.map((msg) => `${msg.role === "user" ? "You" : "Clare"}: ${msg.content}`).join("\n\n");
 
 
302
  };
303
 
304
  const buildSummaryContent = () => {
@@ -487,8 +518,8 @@ export function ChatArea({
487
 
488
  const validFiles = files.filter((file) => {
489
  const ext = file.name.toLowerCase();
490
- return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some((allowed) =>
491
- ext.endsWith(allowed)
492
  );
493
  });
494
 
@@ -505,8 +536,8 @@ export function ChatArea({
505
  if (files.length > 0) {
506
  const validFiles = files.filter((file) => {
507
  const ext = file.name.toLowerCase();
508
- return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some((allowed) =>
509
- ext.endsWith(allowed)
510
  );
511
  });
512
 
@@ -560,8 +591,7 @@ export function ChatArea({
560
  if (ext.endsWith(".pdf")) return { bgColor: "bg-red-500", type: "PDF" };
561
  if (ext.endsWith(".docx") || ext.endsWith(".doc")) return { bgColor: "bg-blue-500", type: "Document" };
562
  if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return { bgColor: "bg-orange-500", type: "Presentation" };
563
- if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e)))
564
- return { bgColor: "bg-green-500", type: "Image" };
565
  return { bgColor: "bg-gray-500", type: "File" };
566
  };
567
 
@@ -571,6 +601,12 @@ export function ChatArea({
571
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
572
  };
573
 
 
 
 
 
 
 
574
  const FileThumbnail = ({
575
  file,
576
  Icon,
@@ -586,25 +622,8 @@ export function ChatArea({
586
  onPreview: () => void;
587
  onRemove: (e: React.MouseEvent) => void;
588
  }) => {
589
- const [imagePreview, setImagePreview] = useState<string | null>(null);
590
- const [imageLoading, setImageLoading] = useState(true);
591
-
592
- useEffect(() => {
593
- if (!isImage) {
594
- setImagePreview(null);
595
- setImageLoading(false);
596
- return;
597
- }
598
-
599
- setImageLoading(true);
600
- const reader = new FileReader();
601
- reader.onload = (e) => {
602
- setImagePreview(e.target?.result as string);
603
- setImageLoading(false);
604
- };
605
- reader.onerror = () => setImageLoading(false);
606
- reader.readAsDataURL(file);
607
- }, [file, isImage]);
608
 
609
  if (isImage) {
610
  return (
@@ -615,23 +634,19 @@ export function ChatArea({
615
  >
616
  <div className="w-full h-full relative bg-card border border-border rounded-lg hover:border-primary/50 transition-colors">
617
  <div className="w-full h-full overflow-hidden rounded-lg absolute inset-0">
618
- {imageLoading ? (
619
- <div className="w-full h-full flex items-center justify-center bg-muted">
620
- <Icon className="h-5 w-5 text-muted-foreground animate-pulse" />
621
- </div>
622
- ) : imagePreview ? (
623
  <img
624
- src={imagePreview}
625
  alt={file.name}
626
  className="w-full h-full object-cover"
 
627
  onError={(e) => {
628
  e.currentTarget.style.display = "none";
629
- setImageLoading(false);
630
  }}
631
  />
632
  ) : (
633
  <div className="w-full h-full flex items-center justify-center bg-muted">
634
- <Icon className="h-5 w-5 text-muted-foreground" />
635
  </div>
636
  )}
637
  </div>
@@ -644,6 +659,7 @@ export function ChatArea({
644
  onRemove(e);
645
  }}
646
  style={{ zIndex: 100 }}
 
647
  >
648
  <X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
649
  </button>
@@ -674,6 +690,7 @@ export function ChatArea({
674
  onRemove(e);
675
  }}
676
  style={{ zIndex: 10 }}
 
677
  >
678
  <X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
679
  </button>
@@ -748,7 +765,11 @@ export function ChatArea({
748
  );
749
  }
750
 
751
- return <div className="whitespace-pre-wrap text-sm font-mono p-4 bg-muted rounded-lg max-h-[60vh] overflow-y-auto">{content}</div>;
 
 
 
 
752
  };
753
 
754
  // ✅ Reserve space for composer so last message is never hidden
@@ -781,7 +802,10 @@ export function ChatArea({
781
  }
782
 
783
  return (
784
- <Select value={currentCourseId || "course1"} onValueChange={(val) => onCourseChange && onCourseChange(val)}>
 
 
 
785
  <SelectTrigger className="w-[200px] h-9 font-semibold">
786
  <SelectValue placeholder="Select course" />
787
  </SelectTrigger>
@@ -799,7 +823,12 @@ export function ChatArea({
799
 
800
  {/* Chat Mode Tabs - Center */}
801
  <div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
802
- <Tabs value={chatMode} onValueChange={(value) => onChatModeChange(value as ChatMode)} className="w-auto" orientation="horizontal">
 
 
 
 
 
803
  <TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
804
  <TabsTrigger value="ask" className="w-[140px] px-3 text-sm">
805
  Ask
@@ -875,7 +904,11 @@ export function ChatArea({
875
  {/* =========================
876
  1) Scroll Container (ONLY this scrolls)
877
  ========================= */}
878
- <div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto overscroll-contain" style={{ overscrollBehavior: "contain" }}>
 
 
 
 
879
  {/* Messages */}
880
  <div className="py-6" style={{ paddingBottom: bottomPad }}>
881
  <div className="w-full space-y-6 max-w-4xl mx-auto">
@@ -885,7 +918,8 @@ export function ChatArea({
885
  message={message}
886
  showSenderInfo={spaceType === "group"}
887
  isFirstGreeting={
888
- (message.id === "1" || message.id === "review-1" || message.id === "quiz-1") && message.role === "assistant"
 
889
  }
890
  showNextButton={message.showNextButton && !isAppTyping}
891
  onNextQuestion={onNextQuestion}
@@ -923,9 +957,18 @@ export function ChatArea({
923
  </div>
924
  <div className="bg-muted rounded-2xl px-4 py-3">
925
  <div className="flex gap-1">
926
- <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: "0ms" }} />
927
- <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: "150ms" }} />
928
- <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: "300ms" }} />
 
 
 
 
 
 
 
 
 
929
  </div>
930
  </div>
931
  </div>
@@ -961,9 +1004,7 @@ export function ChatArea({
961
  {uploadedFiles.map((uploadedFile, index) => {
962
  const Icon = getFileIcon(uploadedFile.file.name);
963
  const fileInfo = getFileTypeInfo(uploadedFile.file.name);
964
- const isImage = ["jpg", "jpeg", "png", "gif", "webp"].some((ext) =>
965
- uploadedFile.file.name.toLowerCase().endsWith(`.${ext}`)
966
- );
967
 
968
  return (
969
  <div key={index}>
@@ -978,6 +1019,14 @@ export function ChatArea({
978
  }}
979
  onRemove={(e) => {
980
  e.stopPropagation();
 
 
 
 
 
 
 
 
981
  setFileToDelete(index);
982
  setShowDeleteDialog(true);
983
  }}
@@ -1045,7 +1094,10 @@ export function ChatArea({
1045
  </div>
1046
  </DropdownMenuItem>
1047
 
1048
- <DropdownMenuItem onClick={() => onLearningModeChange("exam")} className={learningMode === "exam" ? "bg-accent" : ""}>
 
 
 
1049
  <div className="flex flex-col">
1050
  <span className="font-medium">Exam Prep</span>
1051
  <span className="text-xs text-muted-foreground">Practice with quiz questions</span>
@@ -1138,7 +1190,9 @@ export function ChatArea({
1138
  <AlertDialogContent>
1139
  <AlertDialogHeader>
1140
  <AlertDialogTitle>Start New Conversation</AlertDialogTitle>
1141
- <AlertDialogDescription>Would you like to save the current chat before starting a new conversation?</AlertDialogDescription>
 
 
1142
 
1143
  <Button
1144
  variant="ghost"
@@ -1319,7 +1373,9 @@ export function ChatArea({
1319
  </DialogTitle>
1320
  <DialogDescription>File size: {selectedFile ? formatFileSize(selectedFile.file.size) : ""}</DialogDescription>
1321
  </DialogHeader>
1322
- <div className="flex-1 min-h-0 overflow-y-auto mt-4">{selectedFile && <FileViewerContent file={selectedFile.file} />}</div>
 
 
1323
  </DialogContent>
1324
  </Dialog>
1325
 
 
33
  } from "../App";
34
  import { toast } from "sonner";
35
  import { jsPDF } from "jspdf";
36
+ import {
37
+ DropdownMenu,
38
+ DropdownMenuContent,
39
+ DropdownMenuItem,
40
+ DropdownMenuTrigger,
41
+ } from "./ui/dropdown-menu";
42
+ import {
43
+ Dialog,
44
+ DialogContent,
45
+ DialogDescription,
46
+ DialogFooter,
47
+ DialogHeader,
48
+ DialogTitle,
49
+ } from "./ui/dialog";
50
  import { Checkbox } from "./ui/checkbox";
51
  import {
52
  AlertDialog,
 
58
  AlertDialogHeader,
59
  AlertDialogTitle,
60
  } from "./ui/alert-dialog";
61
+ import {
62
+ Select,
63
+ SelectContent,
64
+ SelectItem,
65
+ SelectTrigger,
66
+ SelectValue,
67
+ } from "./ui/select";
68
  import { SmartReview } from "./SmartReview";
69
  import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
70
 
71
+ // ✅ NEW: objectURL cache hook (you added this file)
72
+ import { useObjectUrlCache } from "../lib/useObjectUrlCache";
73
+
74
  type ReviewEventType = "send_message" | "review_topic" | "review_all";
75
 
76
  interface ChatAreaProps {
 
103
  savedChats: SavedChat[];
104
  workspaces: Workspace[];
105
  currentWorkspaceId: string;
106
+ onSaveFile?: (
107
+ content: string,
108
+ type: "export" | "summary",
109
+ format?: "pdf" | "text",
110
+ workspaceId?: string
111
+ ) => void;
112
  leftPanelVisible?: boolean;
113
  currentCourseId?: string;
114
  onCourseChange?: (courseId: string) => void;
 
181
  const [shareLink, setShareLink] = useState("");
182
  const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
183
 
184
+ // ✅ NEW: objectURL cache for image thumbnail rendering
185
+ const { getObjectUrl, revokeObjectUrl } = useObjectUrlCache();
186
+
187
  const courses =
188
  availableCourses.length > 0
189
  ? availableCourses
 
327
 
328
  const buildPreviewContent = () => {
329
  if (!messages.length) return "";
330
+ return messages
331
+ .map((msg) => `${msg.role === "user" ? "You" : "Clare"}: ${msg.content}`)
332
+ .join("\n\n");
333
  };
334
 
335
  const buildSummaryContent = () => {
 
518
 
519
  const validFiles = files.filter((file) => {
520
  const ext = file.name.toLowerCase();
521
+ return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some(
522
+ (allowed) => ext.endsWith(allowed)
523
  );
524
  });
525
 
 
536
  if (files.length > 0) {
537
  const validFiles = files.filter((file) => {
538
  const ext = file.name.toLowerCase();
539
+ return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some(
540
+ (allowed) => ext.endsWith(allowed)
541
  );
542
  });
543
 
 
591
  if (ext.endsWith(".pdf")) return { bgColor: "bg-red-500", type: "PDF" };
592
  if (ext.endsWith(".docx") || ext.endsWith(".doc")) return { bgColor: "bg-blue-500", type: "Document" };
593
  if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return { bgColor: "bg-orange-500", type: "Presentation" };
594
+ if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e))) return { bgColor: "bg-green-500", type: "Image" };
 
595
  return { bgColor: "bg-gray-500", type: "File" };
596
  };
597
 
 
601
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
602
  };
603
 
604
+ // ✅ (display-only helper)
605
+ const isImageByName = (name: string) => {
606
+ const ext = name.toLowerCase();
607
+ return [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e));
608
+ };
609
+
610
  const FileThumbnail = ({
611
  file,
612
  Icon,
 
622
  onPreview: () => void;
623
  onRemove: (e: React.MouseEvent) => void;
624
  }) => {
625
+ // Use cached objectURL for stable image preview
626
+ const src = isImage ? getObjectUrl(file) : null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
 
628
  if (isImage) {
629
  return (
 
634
  >
635
  <div className="w-full h-full relative bg-card border border-border rounded-lg hover:border-primary/50 transition-colors">
636
  <div className="w-full h-full overflow-hidden rounded-lg absolute inset-0">
637
+ {src ? (
 
 
 
 
638
  <img
639
+ src={src}
640
  alt={file.name}
641
  className="w-full h-full object-cover"
642
+ draggable={false}
643
  onError={(e) => {
644
  e.currentTarget.style.display = "none";
 
645
  }}
646
  />
647
  ) : (
648
  <div className="w-full h-full flex items-center justify-center bg-muted">
649
+ <Icon className="h-5 w-5 text-muted-foreground animate-pulse" />
650
  </div>
651
  )}
652
  </div>
 
659
  onRemove(e);
660
  }}
661
  style={{ zIndex: 100 }}
662
+ aria-label="Remove file"
663
  >
664
  <X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
665
  </button>
 
690
  onRemove(e);
691
  }}
692
  style={{ zIndex: 10 }}
693
+ aria-label="Remove file"
694
  >
695
  <X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
696
  </button>
 
765
  );
766
  }
767
 
768
+ return (
769
+ <div className="whitespace-pre-wrap text-sm font-mono p-4 bg-muted rounded-lg max-h-[60vh] overflow-y-auto">
770
+ {content}
771
+ </div>
772
+ );
773
  };
774
 
775
  // ✅ Reserve space for composer so last message is never hidden
 
802
  }
803
 
804
  return (
805
+ <Select
806
+ value={currentCourseId || "course1"}
807
+ onValueChange={(val) => onCourseChange && onCourseChange(val)}
808
+ >
809
  <SelectTrigger className="w-[200px] h-9 font-semibold">
810
  <SelectValue placeholder="Select course" />
811
  </SelectTrigger>
 
823
 
824
  {/* Chat Mode Tabs - Center */}
825
  <div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
826
+ <Tabs
827
+ value={chatMode}
828
+ onValueChange={(value) => onChatModeChange(value as ChatMode)}
829
+ className="w-auto"
830
+ orientation="horizontal"
831
+ >
832
  <TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
833
  <TabsTrigger value="ask" className="w-[140px] px-3 text-sm">
834
  Ask
 
904
  {/* =========================
905
  1) Scroll Container (ONLY this scrolls)
906
  ========================= */}
907
+ <div
908
+ ref={scrollContainerRef}
909
+ className="flex-1 min-h-0 overflow-y-auto overscroll-contain"
910
+ style={{ overscrollBehavior: "contain" }}
911
+ >
912
  {/* Messages */}
913
  <div className="py-6" style={{ paddingBottom: bottomPad }}>
914
  <div className="w-full space-y-6 max-w-4xl mx-auto">
 
918
  message={message}
919
  showSenderInfo={spaceType === "group"}
920
  isFirstGreeting={
921
+ (message.id === "1" || message.id === "review-1" || message.id === "quiz-1") &&
922
+ message.role === "assistant"
923
  }
924
  showNextButton={message.showNextButton && !isAppTyping}
925
  onNextQuestion={onNextQuestion}
 
957
  </div>
958
  <div className="bg-muted rounded-2xl px-4 py-3">
959
  <div className="flex gap-1">
960
+ <div
961
+ className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
962
+ style={{ animationDelay: "0ms" }}
963
+ />
964
+ <div
965
+ className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
966
+ style={{ animationDelay: "150ms" }}
967
+ />
968
+ <div
969
+ className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
970
+ style={{ animationDelay: "300ms" }}
971
+ />
972
  </div>
973
  </div>
974
  </div>
 
1004
  {uploadedFiles.map((uploadedFile, index) => {
1005
  const Icon = getFileIcon(uploadedFile.file.name);
1006
  const fileInfo = getFileTypeInfo(uploadedFile.file.name);
1007
+ const isImage = isImageByName(uploadedFile.file.name);
 
 
1008
 
1009
  return (
1010
  <div key={index}>
 
1019
  }}
1020
  onRemove={(e) => {
1021
  e.stopPropagation();
1022
+ // ✅ revoke objectURL for this image file (display-only change)
1023
+ if (isImage) {
1024
+ try {
1025
+ revokeObjectUrl(uploadedFile.file);
1026
+ } catch {
1027
+ // ignore
1028
+ }
1029
+ }
1030
  setFileToDelete(index);
1031
  setShowDeleteDialog(true);
1032
  }}
 
1094
  </div>
1095
  </DropdownMenuItem>
1096
 
1097
+ <DropdownMenuItem
1098
+ onClick={() => onLearningModeChange("exam")}
1099
+ className={learningMode === "exam" ? "bg-accent" : ""}
1100
+ >
1101
  <div className="flex flex-col">
1102
  <span className="font-medium">Exam Prep</span>
1103
  <span className="text-xs text-muted-foreground">Practice with quiz questions</span>
 
1190
  <AlertDialogContent>
1191
  <AlertDialogHeader>
1192
  <AlertDialogTitle>Start New Conversation</AlertDialogTitle>
1193
+ <AlertDialogDescription>
1194
+ Would you like to save the current chat before starting a new conversation?
1195
+ </AlertDialogDescription>
1196
 
1197
  <Button
1198
  variant="ghost"
 
1373
  </DialogTitle>
1374
  <DialogDescription>File size: {selectedFile ? formatFileSize(selectedFile.file.size) : ""}</DialogDescription>
1375
  </DialogHeader>
1376
+ <div className="flex-1 min-h-0 overflow-y-auto mt-4">
1377
+ {selectedFile && <FileViewerContent file={selectedFile.file} />}
1378
+ </div>
1379
  </DialogContent>
1380
  </Dialog>
1381