SarahXia0405 commited on
Commit
3e0f21f
·
verified ·
1 Parent(s): a537970

Update web/src/components/LeftSidebar.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/LeftSidebar.tsx +127 -161
web/src/components/LeftSidebar.tsx CHANGED
@@ -2,12 +2,33 @@ import React, { useState, useRef, useEffect } from 'react';
2
  import { LearningModeSelector } from './LearningModeSelector';
3
  import { Label } from './ui/label';
4
  import { Button } from './ui/button';
5
- import { LogIn, LogOut, Bookmark, Download, Copy, Search, X, MessageSquare, Trash2, Edit2, Check, X as XIcon } from 'lucide-react';
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  import { Separator } from './ui/separator';
7
  import { GroupMembers } from './GroupMembers';
8
  import { Card } from './ui/card';
9
  import { Input } from './ui/input';
10
- import type { LearningMode, Language, SpaceType, GroupMember, User as UserType, SavedItem, SavedChat, Message as MessageType } from '../App';
 
 
 
 
 
 
 
 
11
  import { toast } from 'sonner';
12
  import { Document, HeadingLevel, Packer, Paragraph, TextRun } from 'docx';
13
  import { jsPDF } from 'jspdf';
@@ -20,7 +41,9 @@ import {
20
  } from './ui/dialog';
21
  import type { CourseInfo } from '../App';
22
 
23
- // Saved Chat Item Component with rename functionality
 
 
24
  function SavedChatItem({
25
  chat,
26
  onLoadChat,
@@ -39,7 +62,6 @@ function SavedChatItem({
39
  const cancelButtonRef = useRef<HTMLButtonElement>(null);
40
  const saveButtonRef = useRef<HTMLButtonElement>(null);
41
 
42
- // Update originalTitle when chat.title changes (e.g., from external rename)
43
  useEffect(() => {
44
  if (!isEditing) {
45
  setOriginalTitle(chat.title);
@@ -68,25 +90,20 @@ function SavedChatItem({
68
  const handleCancelEdit = (e: React.MouseEvent) => {
69
  e.preventDefault();
70
  e.stopPropagation();
71
- // Reset to original title when canceling
72
  setEditTitle(originalTitle);
73
  setIsEditing(false);
74
- // Prevent onBlur from firing
75
- if (inputRef.current) {
76
- inputRef.current.blur();
77
- }
78
  };
79
 
80
  const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
81
- // Check if the blur is caused by clicking cancel or save button
82
  const relatedTarget = e.relatedTarget as HTMLElement;
83
- if (relatedTarget && (
84
- cancelButtonRef.current?.contains(relatedTarget) ||
85
- saveButtonRef.current?.contains(relatedTarget)
86
- )) {
87
- return; // Don't save if clicking cancel or save button
 
88
  }
89
- // Save on blur if title changed
90
  if (editTitle.trim() && editTitle !== originalTitle && onRenameSavedChat) {
91
  onRenameSavedChat(chat.id, editTitle.trim());
92
  }
@@ -130,21 +147,20 @@ function SavedChatItem({
130
  style={{ height: 'auto' }}
131
  />
132
  ) : (
133
- <h4
134
  className="text-sm font-medium truncate flex-1 cursor-text"
135
  onDoubleClick={(e) => {
136
  e.preventDefault();
137
  e.stopPropagation();
138
  handleStartEdit(e);
139
  }}
140
- onClick={(e) => {
141
- e.stopPropagation();
142
- }}
143
  title="Double click to rename"
144
  >
145
  {chat.title}
146
  </h4>
147
  )}
 
148
  <div className="flex items-center gap-1 flex-shrink-0">
149
  {isEditing ? (
150
  <>
@@ -188,6 +204,7 @@ function SavedChatItem({
188
  className="h-5 w-5 flex-shrink-0 hover:bg-muted"
189
  onClick={handleStartEdit}
190
  title="Rename chat"
 
191
  >
192
  <Edit2 className="h-3 w-3" />
193
  </Button>
@@ -201,6 +218,7 @@ function SavedChatItem({
201
  onDeleteSavedChat(chat.id);
202
  }}
203
  title="Delete chat"
 
204
  >
205
  <Trash2 className="h-3 w-3" />
206
  </Button>
@@ -208,8 +226,10 @@ function SavedChatItem({
208
  )}
209
  </div>
210
  </div>
 
211
  <p className="text-xs text-muted-foreground mt-1">
212
- {chat.chatMode === 'ask' ? 'Ask' : chat.chatMode === 'review' ? 'Review' : 'Quiz'} • {chat.timestamp.toLocaleDateString()}
 
213
  </p>
214
  <p className="text-xs text-muted-foreground/70 mt-1">
215
  {chat.messages.length} message{chat.messages.length !== 1 ? 's' : ''}
@@ -220,6 +240,9 @@ function SavedChatItem({
220
  );
221
  }
222
 
 
 
 
223
  interface LeftSidebarProps {
224
  learningMode: LearningMode;
225
  language: Language;
@@ -241,7 +264,16 @@ interface LeftSidebarProps {
241
  onDeleteSavedChat: (id: string) => void;
242
  onRenameSavedChat?: (id: string, newTitle: string) => void;
243
  currentWorkspaceId: string;
244
- workspaces?: Array<{ id: string; type: SpaceType; category?: 'course' | 'personal'; courseName?: string; courseInfo?: CourseInfo; members?: GroupMember[]; isEditable?: boolean; name?: string }>;
 
 
 
 
 
 
 
 
 
245
  selectedCourse?: string;
246
  courses?: Array<{ id: string; name: string }>;
247
  availableCourses?: CourseInfo[];
@@ -276,17 +308,25 @@ export function LeftSidebar({
276
  const [showLoginForm, setShowLoginForm] = useState(false);
277
  const [name, setName] = useState('');
278
  const [email, setEmail] = useState('');
 
279
  const [selectedItem, setSelectedItem] = useState<SavedItem | null>(null);
280
  const [isDialogOpen, setIsDialogOpen] = useState(false);
 
281
  const [showSearch, setShowSearch] = useState(false);
282
  const [searchQuery, setSearchQuery] = useState('');
283
 
 
 
 
 
 
 
 
284
  const handleLogin = () => {
285
  if (!name.trim() || !email.trim()) {
286
  toast.error('Please fill in all fields');
287
  return;
288
  }
289
-
290
  onLogin({ name: name.trim(), email: email.trim() });
291
  setShowLoginForm(false);
292
  setName('');
@@ -300,10 +340,6 @@ export function LeftSidebar({
300
  toast.success('Logged out successfully');
301
  };
302
 
303
- const scrollContainerRef = useRef<HTMLDivElement>(null);
304
- const [isDownloading, setIsDownloading] = useState(false);
305
- const [copied, setCopied] = useState(false);
306
-
307
  const downloadBlob = (blob: Blob, filename: string) => {
308
  const url = URL.createObjectURL(blob);
309
  const a = document.createElement('a');
@@ -361,9 +397,8 @@ export function LeftSidebar({
361
  }
362
  return new Paragraph({ children: [new TextRun({ text: line })] });
363
  });
364
- const doc = new Document({
365
- sections: [{ properties: {}, children: paragraphs }],
366
- });
367
  const blob = await Packer.toBlob(doc);
368
  downloadBlob(blob, `${getDefaultFilenameBase(item)}.docx`);
369
  toast.success('Downloaded .docx');
@@ -397,8 +432,7 @@ export function LeftSidebar({
397
  y += lineHeight;
398
  });
399
 
400
- const filenameBase = getDefaultFilenameBase(item);
401
- doc.save(`${filenameBase}.pdf`);
402
  toast.success('Downloaded .pdf');
403
  } catch (e) {
404
  console.error(e);
@@ -417,28 +451,22 @@ export function LeftSidebar({
417
 
418
  const handleUnsaveItem = (id: string) => {
419
  onUnsave(id);
420
- // Don't close the dialog when unsaving - keep the window open
421
  };
422
 
423
- // Check if selectedItem is still in savedItems (by id if it has one, or by content+type)
424
- const isItemSaved = selectedItem
425
- ? savedItems.some(item => {
426
- // First try to match by id if selectedItem has one
427
  if (selectedItem.id && item.id === selectedItem.id) return true;
428
- // Otherwise match by content and type
429
  return item.content === selectedItem.content && item.type === selectedItem.type;
430
  })
431
  : false;
432
 
433
- // Update selectedItem when savedItems changes (e.g., after saving)
434
  useEffect(() => {
435
  if (selectedItem && isDialogOpen) {
436
- // Find the item in savedItems by content and type
437
- const updatedItem = savedItems.find(item =>
438
- item.content === selectedItem.content && item.type === selectedItem.type
439
  );
440
  if (updatedItem && updatedItem.id !== selectedItem.id) {
441
- // Update selectedItem to have the correct id from savedItems (only if id changed)
442
  setSelectedItem(updatedItem);
443
  }
444
  }
@@ -447,63 +475,33 @@ export function LeftSidebar({
447
 
448
  const handleToggleSave = () => {
449
  if (!selectedItem) return;
450
-
451
  if (isItemSaved) {
452
- // Unsave the item - find it by id or content+type
453
- const itemToUnsave = savedItems.find(item =>
454
- (selectedItem.id && item.id === selectedItem.id) ||
455
- (item.content === selectedItem.content && item.type === selectedItem.type)
456
  );
457
- if (itemToUnsave) {
458
- handleUnsaveItem(itemToUnsave.id);
459
- }
460
  } else {
461
- // Save the item
462
  onSave(selectedItem.content, selectedItem.type);
463
  }
464
  };
465
 
466
- // Filter saved items based on search query
467
- const filteredSavedItems = savedItems.filter(item => {
468
- if (!searchQuery.trim()) return true;
469
- const query = searchQuery.toLowerCase();
470
- return (
471
- item.title.toLowerCase().includes(query) ||
472
- item.content.toLowerCase().includes(query) ||
473
- item.type.toLowerCase().includes(query)
474
- );
475
- }).filter(item => item.workspaceId === currentWorkspaceId);
476
-
477
- // Use native event listeners to prevent scroll propagation
478
- useEffect(() => {
479
- const container = scrollContainerRef.current;
480
- if (!container) return;
481
-
482
- const handleWheel = (e: WheelEvent) => {
483
- // Always stop propagation to prevent scrolling other panels
484
- e.stopPropagation();
485
- e.stopImmediatePropagation();
486
-
487
- // Only prevent default if we're at the boundaries
488
- const { scrollTop, scrollHeight, clientHeight } = container;
489
- const isScrollable = scrollHeight > clientHeight;
490
- const isAtTop = scrollTop === 0;
491
- const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1;
492
-
493
- // If scrolling up at top or down at bottom, prevent default to stop propagation
494
- if (isScrollable && ((isAtTop && e.deltaY < 0) || (isAtBottom && e.deltaY > 0))) {
495
- e.preventDefault();
496
- }
497
- };
498
-
499
- container.addEventListener('wheel', handleWheel, { passive: false, capture: true });
500
-
501
- return () => {
502
- container.removeEventListener('wheel', handleWheel, { capture: true });
503
- };
504
- }, []);
505
 
506
- // Sample courses - same as ChatArea
507
  const defaultCourses = [
508
  { id: 'course1', name: 'Introduction to AI' },
509
  { id: 'course2', name: 'Machine Learning' },
@@ -512,18 +510,12 @@ export function LeftSidebar({
512
  ];
513
  const coursesList = courses.length > 0 ? courses : defaultCourses;
514
 
515
- // Get current workspace
516
- const currentWorkspace = workspaces?.find(w => w.id === currentWorkspaceId);
517
-
518
- // State for editable workspace title (for personal interest workspaces)
519
- const [editableTitle, setEditableTitle] = useState('Untitled');
520
- const [isEditingTitle, setIsEditingTitle] = useState(false);
521
 
522
- // Determine what to display based on workspace type
523
  const getCourseDisplayInfo = () => {
524
  if (!currentWorkspace) return null;
525
-
526
- // Group workspace with course
527
  if (currentWorkspace.type === 'group' && currentWorkspace.category === 'course') {
528
  if (currentWorkspace.courseInfo) {
529
  return {
@@ -533,7 +525,6 @@ export function LeftSidebar({
533
  teachingAssistant: currentWorkspace.courseInfo.teachingAssistant,
534
  };
535
  }
536
- // Fallback if courseInfo is missing
537
  return {
538
  type: 'course' as const,
539
  name: currentWorkspace.courseName || 'Unknown Course',
@@ -541,8 +532,7 @@ export function LeftSidebar({
541
  teachingAssistant: { name: 'Unknown', email: '' },
542
  };
543
  }
544
-
545
- // Group workspace with personal interest
546
  if (currentWorkspace.type === 'group' && currentWorkspace.category === 'personal') {
547
  return {
548
  type: 'personal' as const,
@@ -550,11 +540,10 @@ export function LeftSidebar({
550
  members: currentWorkspace.members || [],
551
  };
552
  }
553
-
554
- // Individual workspace (My Space)
555
  if (currentWorkspace.type === 'individual') {
556
  const saved = selectedCourse || localStorage.getItem('myspace_selected_course') || 'course1';
557
- const courseInfo = availableCourses?.find(c => c.id === saved);
558
  if (courseInfo) {
559
  return {
560
  type: 'course' as const,
@@ -563,7 +552,7 @@ export function LeftSidebar({
563
  teachingAssistant: courseInfo.teachingAssistant,
564
  };
565
  }
566
- const course = coursesList.find(c => c.id === saved);
567
  return {
568
  type: 'course' as const,
569
  name: course?.name || saved,
@@ -571,18 +560,16 @@ export function LeftSidebar({
571
  teachingAssistant: { name: 'Unknown', email: '' },
572
  };
573
  }
574
-
575
  return null;
576
  };
577
 
578
  const courseDisplayInfo = getCourseDisplayInfo();
579
 
580
  return (
581
- <div
582
- ref={scrollContainerRef}
583
- className="flex-1 overflow-auto overscroll-contain flex flex-col"
584
- style={{ overscrollBehavior: 'contain' }}
585
- >
586
  {/* Course Information Section */}
587
  {isLoggedIn && courseDisplayInfo && (
588
  <div className="p-4 border-b border-border flex-shrink-0">
@@ -592,19 +579,13 @@ export function LeftSidebar({
592
  <div className="space-y-2 text-sm">
593
  <div>
594
  <span className="text-muted-foreground">Instructor: </span>
595
- <a
596
- href={`mailto:${courseDisplayInfo.instructor.email}`}
597
- className="text-primary hover:underline"
598
- >
599
  {courseDisplayInfo.instructor.name}
600
  </a>
601
  </div>
602
  <div>
603
  <span className="text-muted-foreground">TA: </span>
604
- <a
605
- href={`mailto:${courseDisplayInfo.teachingAssistant.email}`}
606
- className="text-primary hover:underline"
607
- >
608
  {courseDisplayInfo.teachingAssistant.name}
609
  </a>
610
  </div>
@@ -612,7 +593,6 @@ export function LeftSidebar({
612
  </>
613
  ) : (
614
  <>
615
- {/* Personal Interest Workspace - Editable Title */}
616
  <div className="mb-4">
617
  {isEditingTitle ? (
618
  <Input
@@ -620,18 +600,13 @@ export function LeftSidebar({
620
  onChange={(e) => setEditableTitle(e.target.value)}
621
  onBlur={() => setIsEditingTitle(false)}
622
  onKeyDown={(e) => {
623
- if (e.key === 'Enter') {
624
- setIsEditingTitle(false);
625
- }
626
  }}
627
  autoFocus
628
  className="text-base font-semibold"
629
  />
630
  ) : (
631
- <h3
632
- className="text-base font-semibold cursor-pointer hover:text-primary"
633
- onClick={() => setIsEditingTitle(true)}
634
- >
635
  {editableTitle}
636
  </h3>
637
  )}
@@ -641,10 +616,7 @@ export function LeftSidebar({
641
  {courseDisplayInfo.members.map((member, idx) => (
642
  <div key={member.id}>
643
  <span className="text-muted-foreground">{idx === 0 ? 'Creator: ' : 'Member: '}</span>
644
- <a
645
- href={`mailto:${member.email}`}
646
- className="text-primary hover:underline"
647
- >
648
  {member.name}
649
  </a>
650
  </div>
@@ -655,7 +627,7 @@ export function LeftSidebar({
655
  </div>
656
  )}
657
 
658
- {/* Login Section - Only show when not logged in */}
659
  {!isLoggedIn && (
660
  <div className="p-4 border-b border-border flex-shrink-0">
661
  <h3 className="text-base font-medium mb-4">Login</h3>
@@ -682,33 +654,15 @@ export function LeftSidebar({
682
  <div className="space-y-3">
683
  <div className="space-y-2">
684
  <Label htmlFor="name">Name</Label>
685
- <Input
686
- id="name"
687
- value={name}
688
- onChange={(e) => setName(e.target.value)}
689
- placeholder="Enter your name"
690
- />
691
  </div>
692
  <div className="space-y-2">
693
  <Label htmlFor="email">Email / Student ID</Label>
694
- <Input
695
- id="email"
696
- type="email"
697
- value={email}
698
- onChange={(e) => setEmail(e.target.value)}
699
- placeholder="Enter your email or ID"
700
- />
701
  </div>
702
  <div className="flex gap-2">
703
- <Button onClick={handleLogin} className="flex-1">
704
- Enter
705
- </Button>
706
- <Button
707
- variant="outline"
708
- onClick={() => setShowLoginForm(false)}
709
- >
710
- Cancel
711
- </Button>
712
  </div>
713
  </div>
714
  )}
@@ -717,17 +671,18 @@ export function LeftSidebar({
717
  </div>
718
  )}
719
 
720
- {/* Group Members - Only show in group mode */}
721
  {spaceType === 'group' && (
722
  <div className="p-4 border-b border-border flex-shrink-0">
723
  <GroupMembers members={groupMembers} />
724
  </div>
725
  )}
726
 
727
- {/* Saved Chat Section - Above Saved Files */}
728
  {isLoggedIn && (
729
  <div className="border-b border-border p-4 flex-1 min-h-0 flex flex-col">
730
  <h3 className="text-base font-medium mb-4 flex-shrink-0">Saved Chat</h3>
 
731
  {savedChats.length === 0 ? (
732
  <div className="text-sm text-muted-foreground text-center py-4 flex-shrink-0">
733
  <MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
@@ -735,7 +690,10 @@ export function LeftSidebar({
735
  <p className="text-xs mt-1">Save conversations to view them here</p>
736
  </div>
737
  ) : (
738
- <div className="space-y-2 overflow-y-auto flex-1 min-h-0">
 
 
 
739
  {savedChats.map((chat) => (
740
  <div key={chat.id}>
741
  <SavedChatItem
@@ -757,9 +715,11 @@ export function LeftSidebar({
757
  <DialogHeader>
758
  <DialogTitle>{selectedItem?.title}</DialogTitle>
759
  <DialogDescription>
760
- {selectedItem?.type === 'export' ? 'Export' : selectedItem?.type === 'quiz' ? 'Quiz' : 'Summary'} • {selectedItem?.timestamp.toLocaleDateString()}
 
761
  </DialogDescription>
762
  </DialogHeader>
 
763
  <div className="flex flex-col flex-1 min-h-0 space-y-4 mt-4">
764
  {selectedItem && (
765
  <>
@@ -775,6 +735,7 @@ export function LeftSidebar({
775
  <Download className="h-3 w-3" />
776
  .md
777
  </Button>
 
778
  <Button
779
  variant="outline"
780
  size="sm"
@@ -786,6 +747,7 @@ export function LeftSidebar({
786
  <Download className="h-3 w-3" />
787
  .docx
788
  </Button>
 
789
  {selectedItem.format === 'pdf' && (
790
  <Button
791
  variant="outline"
@@ -799,6 +761,7 @@ export function LeftSidebar({
799
  .pdf
800
  </Button>
801
  )}
 
802
  <Button
803
  variant="outline"
804
  size="sm"
@@ -809,6 +772,7 @@ export function LeftSidebar({
809
  >
810
  <Copy className={`h-3 w-3 ${copied ? 'text-green-600' : ''}`} />
811
  </Button>
 
812
  <Button
813
  variant="outline"
814
  size="sm"
@@ -820,7 +784,9 @@ export function LeftSidebar({
820
  <Bookmark className={`h-3 w-3 ${isItemSaved ? 'fill-red-600 text-red-600' : ''}`} />
821
  </Button>
822
  </div>
 
823
  <Separator className="flex-shrink-0" />
 
824
  <div className="text-sm whitespace-pre-wrap text-foreground overflow-y-auto flex-1 min-h-0">
825
  {selectedItem.content}
826
  </div>
 
2
  import { LearningModeSelector } from './LearningModeSelector';
3
  import { Label } from './ui/label';
4
  import { Button } from './ui/button';
5
+ import {
6
+ LogIn,
7
+ LogOut,
8
+ Bookmark,
9
+ Download,
10
+ Copy,
11
+ Search,
12
+ X,
13
+ MessageSquare,
14
+ Trash2,
15
+ Edit2,
16
+ Check,
17
+ X as XIcon
18
+ } from 'lucide-react';
19
  import { Separator } from './ui/separator';
20
  import { GroupMembers } from './GroupMembers';
21
  import { Card } from './ui/card';
22
  import { Input } from './ui/input';
23
+ import type {
24
+ LearningMode,
25
+ Language,
26
+ SpaceType,
27
+ GroupMember,
28
+ User as UserType,
29
+ SavedItem,
30
+ SavedChat
31
+ } from '../App';
32
  import { toast } from 'sonner';
33
  import { Document, HeadingLevel, Packer, Paragraph, TextRun } from 'docx';
34
  import { jsPDF } from 'jspdf';
 
41
  } from './ui/dialog';
42
  import type { CourseInfo } from '../App';
43
 
44
+ // ----------------------------
45
+ // Saved Chat Item Component (rename supported)
46
+ // ----------------------------
47
  function SavedChatItem({
48
  chat,
49
  onLoadChat,
 
62
  const cancelButtonRef = useRef<HTMLButtonElement>(null);
63
  const saveButtonRef = useRef<HTMLButtonElement>(null);
64
 
 
65
  useEffect(() => {
66
  if (!isEditing) {
67
  setOriginalTitle(chat.title);
 
90
  const handleCancelEdit = (e: React.MouseEvent) => {
91
  e.preventDefault();
92
  e.stopPropagation();
 
93
  setEditTitle(originalTitle);
94
  setIsEditing(false);
95
+ inputRef.current?.blur();
 
 
 
96
  };
97
 
98
  const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
 
99
  const relatedTarget = e.relatedTarget as HTMLElement;
100
+ if (
101
+ relatedTarget &&
102
+ (cancelButtonRef.current?.contains(relatedTarget) ||
103
+ saveButtonRef.current?.contains(relatedTarget))
104
+ ) {
105
+ return;
106
  }
 
107
  if (editTitle.trim() && editTitle !== originalTitle && onRenameSavedChat) {
108
  onRenameSavedChat(chat.id, editTitle.trim());
109
  }
 
147
  style={{ height: 'auto' }}
148
  />
149
  ) : (
150
+ <h4
151
  className="text-sm font-medium truncate flex-1 cursor-text"
152
  onDoubleClick={(e) => {
153
  e.preventDefault();
154
  e.stopPropagation();
155
  handleStartEdit(e);
156
  }}
157
+ onClick={(e) => e.stopPropagation()}
 
 
158
  title="Double click to rename"
159
  >
160
  {chat.title}
161
  </h4>
162
  )}
163
+
164
  <div className="flex items-center gap-1 flex-shrink-0">
165
  {isEditing ? (
166
  <>
 
204
  className="h-5 w-5 flex-shrink-0 hover:bg-muted"
205
  onClick={handleStartEdit}
206
  title="Rename chat"
207
+ type="button"
208
  >
209
  <Edit2 className="h-3 w-3" />
210
  </Button>
 
218
  onDeleteSavedChat(chat.id);
219
  }}
220
  title="Delete chat"
221
+ type="button"
222
  >
223
  <Trash2 className="h-3 w-3" />
224
  </Button>
 
226
  )}
227
  </div>
228
  </div>
229
+
230
  <p className="text-xs text-muted-foreground mt-1">
231
+ {chat.chatMode === 'ask' ? 'Ask' : chat.chatMode === 'review' ? 'Review' : 'Quiz'} •{' '}
232
+ {chat.timestamp.toLocaleDateString()}
233
  </p>
234
  <p className="text-xs text-muted-foreground/70 mt-1">
235
  {chat.messages.length} message{chat.messages.length !== 1 ? 's' : ''}
 
240
  );
241
  }
242
 
243
+ // ----------------------------
244
+ // LeftSidebar
245
+ // ----------------------------
246
  interface LeftSidebarProps {
247
  learningMode: LearningMode;
248
  language: Language;
 
264
  onDeleteSavedChat: (id: string) => void;
265
  onRenameSavedChat?: (id: string, newTitle: string) => void;
266
  currentWorkspaceId: string;
267
+ workspaces?: Array<{
268
+ id: string;
269
+ type: SpaceType;
270
+ category?: 'course' | 'personal';
271
+ courseName?: string;
272
+ courseInfo?: CourseInfo;
273
+ members?: GroupMember[];
274
+ isEditable?: boolean;
275
+ name?: string;
276
+ }>;
277
  selectedCourse?: string;
278
  courses?: Array<{ id: string; name: string }>;
279
  availableCourses?: CourseInfo[];
 
308
  const [showLoginForm, setShowLoginForm] = useState(false);
309
  const [name, setName] = useState('');
310
  const [email, setEmail] = useState('');
311
+
312
  const [selectedItem, setSelectedItem] = useState<SavedItem | null>(null);
313
  const [isDialogOpen, setIsDialogOpen] = useState(false);
314
+
315
  const [showSearch, setShowSearch] = useState(false);
316
  const [searchQuery, setSearchQuery] = useState('');
317
 
318
+ const [isDownloading, setIsDownloading] = useState(false);
319
+ const [copied, setCopied] = useState(false);
320
+
321
+ // personal workspace title
322
+ const [editableTitle, setEditableTitle] = useState('Untitled');
323
+ const [isEditingTitle, setIsEditingTitle] = useState(false);
324
+
325
  const handleLogin = () => {
326
  if (!name.trim() || !email.trim()) {
327
  toast.error('Please fill in all fields');
328
  return;
329
  }
 
330
  onLogin({ name: name.trim(), email: email.trim() });
331
  setShowLoginForm(false);
332
  setName('');
 
340
  toast.success('Logged out successfully');
341
  };
342
 
 
 
 
 
343
  const downloadBlob = (blob: Blob, filename: string) => {
344
  const url = URL.createObjectURL(blob);
345
  const a = document.createElement('a');
 
397
  }
398
  return new Paragraph({ children: [new TextRun({ text: line })] });
399
  });
400
+
401
+ const doc = new Document({ sections: [{ properties: {}, children: paragraphs }] });
 
402
  const blob = await Packer.toBlob(doc);
403
  downloadBlob(blob, `${getDefaultFilenameBase(item)}.docx`);
404
  toast.success('Downloaded .docx');
 
432
  y += lineHeight;
433
  });
434
 
435
+ doc.save(`${getDefaultFilenameBase(item)}.pdf`);
 
436
  toast.success('Downloaded .pdf');
437
  } catch (e) {
438
  console.error(e);
 
451
 
452
  const handleUnsaveItem = (id: string) => {
453
  onUnsave(id);
 
454
  };
455
 
456
+ // selected item still saved?
457
+ const isItemSaved = selectedItem
458
+ ? savedItems.some((item) => {
 
459
  if (selectedItem.id && item.id === selectedItem.id) return true;
 
460
  return item.content === selectedItem.content && item.type === selectedItem.type;
461
  })
462
  : false;
463
 
 
464
  useEffect(() => {
465
  if (selectedItem && isDialogOpen) {
466
+ const updatedItem = savedItems.find(
467
+ (item) => item.content === selectedItem.content && item.type === selectedItem.type
 
468
  );
469
  if (updatedItem && updatedItem.id !== selectedItem.id) {
 
470
  setSelectedItem(updatedItem);
471
  }
472
  }
 
475
 
476
  const handleToggleSave = () => {
477
  if (!selectedItem) return;
478
+
479
  if (isItemSaved) {
480
+ const itemToUnsave = savedItems.find(
481
+ (item) =>
482
+ (selectedItem.id && item.id === selectedItem.id) ||
483
+ (item.content === selectedItem.content && item.type === selectedItem.type)
484
  );
485
+ if (itemToUnsave) handleUnsaveItem(itemToUnsave.id);
 
 
486
  } else {
 
487
  onSave(selectedItem.content, selectedItem.type);
488
  }
489
  };
490
 
491
+ // Filter saved items based on search query + workspaceId
492
+ const filteredSavedItems = savedItems
493
+ .filter((item) => item.workspaceId === currentWorkspaceId)
494
+ .filter((item) => {
495
+ if (!searchQuery.trim()) return true;
496
+ const q = searchQuery.toLowerCase();
497
+ return (
498
+ item.title.toLowerCase().includes(q) ||
499
+ item.content.toLowerCase().includes(q) ||
500
+ item.type.toLowerCase().includes(q)
501
+ );
502
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
 
504
+ // Courses
505
  const defaultCourses = [
506
  { id: 'course1', name: 'Introduction to AI' },
507
  { id: 'course2', name: 'Machine Learning' },
 
510
  ];
511
  const coursesList = courses.length > 0 ? courses : defaultCourses;
512
 
513
+ // Current workspace display info
514
+ const currentWorkspace = workspaces?.find((w) => w.id === currentWorkspaceId);
 
 
 
 
515
 
 
516
  const getCourseDisplayInfo = () => {
517
  if (!currentWorkspace) return null;
518
+
 
519
  if (currentWorkspace.type === 'group' && currentWorkspace.category === 'course') {
520
  if (currentWorkspace.courseInfo) {
521
  return {
 
525
  teachingAssistant: currentWorkspace.courseInfo.teachingAssistant,
526
  };
527
  }
 
528
  return {
529
  type: 'course' as const,
530
  name: currentWorkspace.courseName || 'Unknown Course',
 
532
  teachingAssistant: { name: 'Unknown', email: '' },
533
  };
534
  }
535
+
 
536
  if (currentWorkspace.type === 'group' && currentWorkspace.category === 'personal') {
537
  return {
538
  type: 'personal' as const,
 
540
  members: currentWorkspace.members || [],
541
  };
542
  }
543
+
 
544
  if (currentWorkspace.type === 'individual') {
545
  const saved = selectedCourse || localStorage.getItem('myspace_selected_course') || 'course1';
546
+ const courseInfo = availableCourses?.find((c) => c.id === saved);
547
  if (courseInfo) {
548
  return {
549
  type: 'course' as const,
 
552
  teachingAssistant: courseInfo.teachingAssistant,
553
  };
554
  }
555
+ const course = coursesList.find((c) => c.id === saved);
556
  return {
557
  type: 'course' as const,
558
  name: course?.name || saved,
 
560
  teachingAssistant: { name: 'Unknown', email: '' },
561
  };
562
  }
563
+
564
  return null;
565
  };
566
 
567
  const courseDisplayInfo = getCourseDisplayInfo();
568
 
569
  return (
570
+ // ✅ IMPORTANT: LeftSidebar root should NOT be scroll container.
571
+ // The scroll container is provided by App.tsx wrapper: <div className="flex-1 min-h-0 overflow-y-auto">.
572
+ <div className="flex flex-col h-full min-h-0">
 
 
573
  {/* Course Information Section */}
574
  {isLoggedIn && courseDisplayInfo && (
575
  <div className="p-4 border-b border-border flex-shrink-0">
 
579
  <div className="space-y-2 text-sm">
580
  <div>
581
  <span className="text-muted-foreground">Instructor: </span>
582
+ <a href={`mailto:${courseDisplayInfo.instructor.email}`} className="text-primary hover:underline">
 
 
 
583
  {courseDisplayInfo.instructor.name}
584
  </a>
585
  </div>
586
  <div>
587
  <span className="text-muted-foreground">TA: </span>
588
+ <a href={`mailto:${courseDisplayInfo.teachingAssistant.email}`} className="text-primary hover:underline">
 
 
 
589
  {courseDisplayInfo.teachingAssistant.name}
590
  </a>
591
  </div>
 
593
  </>
594
  ) : (
595
  <>
 
596
  <div className="mb-4">
597
  {isEditingTitle ? (
598
  <Input
 
600
  onChange={(e) => setEditableTitle(e.target.value)}
601
  onBlur={() => setIsEditingTitle(false)}
602
  onKeyDown={(e) => {
603
+ if (e.key === 'Enter') setIsEditingTitle(false);
 
 
604
  }}
605
  autoFocus
606
  className="text-base font-semibold"
607
  />
608
  ) : (
609
+ <h3 className="text-base font-semibold cursor-pointer hover:text-primary" onClick={() => setIsEditingTitle(true)}>
 
 
 
610
  {editableTitle}
611
  </h3>
612
  )}
 
616
  {courseDisplayInfo.members.map((member, idx) => (
617
  <div key={member.id}>
618
  <span className="text-muted-foreground">{idx === 0 ? 'Creator: ' : 'Member: '}</span>
619
+ <a href={`mailto:${member.email}`} className="text-primary hover:underline">
 
 
 
620
  {member.name}
621
  </a>
622
  </div>
 
627
  </div>
628
  )}
629
 
630
+ {/* Login Section */}
631
  {!isLoggedIn && (
632
  <div className="p-4 border-b border-border flex-shrink-0">
633
  <h3 className="text-base font-medium mb-4">Login</h3>
 
654
  <div className="space-y-3">
655
  <div className="space-y-2">
656
  <Label htmlFor="name">Name</Label>
657
+ <Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Enter your name" />
 
 
 
 
 
658
  </div>
659
  <div className="space-y-2">
660
  <Label htmlFor="email">Email / Student ID</Label>
661
+ <Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Enter your email or ID" />
 
 
 
 
 
 
662
  </div>
663
  <div className="flex gap-2">
664
+ <Button onClick={handleLogin} className="flex-1">Enter</Button>
665
+ <Button variant="outline" onClick={() => setShowLoginForm(false)}>Cancel</Button>
 
 
 
 
 
 
 
666
  </div>
667
  </div>
668
  )}
 
671
  </div>
672
  )}
673
 
674
+ {/* Group members */}
675
  {spaceType === 'group' && (
676
  <div className="p-4 border-b border-border flex-shrink-0">
677
  <GroupMembers members={groupMembers} />
678
  </div>
679
  )}
680
 
681
+ {/* Saved Chat Section: only the list scrolls */}
682
  {isLoggedIn && (
683
  <div className="border-b border-border p-4 flex-1 min-h-0 flex flex-col">
684
  <h3 className="text-base font-medium mb-4 flex-shrink-0">Saved Chat</h3>
685
+
686
  {savedChats.length === 0 ? (
687
  <div className="text-sm text-muted-foreground text-center py-4 flex-shrink-0">
688
  <MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
 
690
  <p className="text-xs mt-1">Save conversations to view them here</p>
691
  </div>
692
  ) : (
693
+ <div
694
+ className="space-y-2 overflow-y-auto flex-1 min-h-0"
695
+ style={{ overscrollBehavior: 'contain' }}
696
+ >
697
  {savedChats.map((chat) => (
698
  <div key={chat.id}>
699
  <SavedChatItem
 
715
  <DialogHeader>
716
  <DialogTitle>{selectedItem?.title}</DialogTitle>
717
  <DialogDescription>
718
+ {selectedItem?.type === 'export' ? 'Export' : selectedItem?.type === 'quiz' ? 'Quiz' : 'Summary'} •{' '}
719
+ {selectedItem?.timestamp.toLocaleDateString()}
720
  </DialogDescription>
721
  </DialogHeader>
722
+
723
  <div className="flex flex-col flex-1 min-h-0 space-y-4 mt-4">
724
  {selectedItem && (
725
  <>
 
735
  <Download className="h-3 w-3" />
736
  .md
737
  </Button>
738
+
739
  <Button
740
  variant="outline"
741
  size="sm"
 
747
  <Download className="h-3 w-3" />
748
  .docx
749
  </Button>
750
+
751
  {selectedItem.format === 'pdf' && (
752
  <Button
753
  variant="outline"
 
761
  .pdf
762
  </Button>
763
  )}
764
+
765
  <Button
766
  variant="outline"
767
  size="sm"
 
772
  >
773
  <Copy className={`h-3 w-3 ${copied ? 'text-green-600' : ''}`} />
774
  </Button>
775
+
776
  <Button
777
  variant="outline"
778
  size="sm"
 
784
  <Bookmark className={`h-3 w-3 ${isItemSaved ? 'fill-red-600 text-red-600' : ''}`} />
785
  </Button>
786
  </div>
787
+
788
  <Separator className="flex-shrink-0" />
789
+
790
  <div className="text-sm whitespace-pre-wrap text-foreground overflow-y-auto flex-1 min-h-0">
791
  {selectedItem.content}
792
  </div>