SarahXia0405 commited on
Commit
cb11663
·
verified ·
1 Parent(s): 236f183

Update web/src/components/LeftSidebar.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/LeftSidebar.tsx +132 -198
web/src/components/LeftSidebar.tsx CHANGED
@@ -1,49 +1,21 @@
1
- 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 {
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';
35
- import {
36
- Dialog,
37
- DialogContent,
38
- DialogDescription,
39
- DialogHeader,
40
- DialogTitle,
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,
@@ -99,8 +71,7 @@ function SavedChatItem({
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
  }
@@ -111,14 +82,14 @@ function SavedChatItem({
111
  };
112
 
113
  const handleKeyDown = (e: React.KeyboardEvent) => {
114
- if (e.key === 'Enter') {
115
  e.preventDefault();
116
  e.stopPropagation();
117
  if (editTitle.trim() && onRenameSavedChat) {
118
  onRenameSavedChat(chat.id, editTitle.trim());
119
  setIsEditing(false);
120
  }
121
- } else if (e.key === 'Escape') {
122
  e.preventDefault();
123
  e.stopPropagation();
124
  setEditTitle(originalTitle);
@@ -127,10 +98,7 @@ function SavedChatItem({
127
  };
128
 
129
  return (
130
- <Card
131
- className="p-3 cursor-pointer hover:bg-muted/50 transition-all bg-muted/30"
132
- onClick={() => !isEditing && onLoadChat(chat)}
133
- >
134
  <div className="flex items-start gap-2">
135
  <MessageSquare className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-muted-foreground" />
136
  <div className="flex-1 min-w-0">
@@ -144,7 +112,7 @@ function SavedChatItem({
144
  onClick={(e) => e.stopPropagation()}
145
  onBlur={handleInputBlur}
146
  className="h-auto text-sm font-medium px-2 py-1 border border-border bg-background focus-visible:ring-2 focus-visible:ring-ring flex-1"
147
- style={{ height: 'auto' }}
148
  />
149
  ) : (
150
  <h4
@@ -198,14 +166,7 @@ function SavedChatItem({
198
  ) : (
199
  <>
200
  {onRenameSavedChat && (
201
- <Button
202
- variant="ghost"
203
- size="icon"
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>
211
  )}
@@ -218,7 +179,6 @@ function SavedChatItem({
218
  onDeleteSavedChat(chat.id);
219
  }}
220
  title="Delete chat"
221
- type="button"
222
  >
223
  <Trash2 className="h-3 w-3" />
224
  </Button>
@@ -228,11 +188,10 @@ function SavedChatItem({
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' : ''}
236
  </p>
237
  </div>
238
  </div>
@@ -240,9 +199,6 @@ function SavedChatItem({
240
  );
241
  }
242
 
243
- // ----------------------------
244
- // LeftSidebar
245
- // ----------------------------
246
  interface LeftSidebarProps {
247
  learningMode: LearningMode;
248
  language: Language;
@@ -258,7 +214,7 @@ interface LeftSidebarProps {
258
  savedItems: SavedItem[];
259
  recentlySavedId: string | null;
260
  onUnsave: (id: string) => void;
261
- onSave: (content: string, type: 'export' | 'quiz' | 'summary') => void;
262
  savedChats: SavedChat[];
263
  onLoadChat: (chat: SavedChat) => void;
264
  onDeleteSavedChat: (id: string) => void;
@@ -267,7 +223,7 @@ interface LeftSidebarProps {
267
  workspaces?: Array<{
268
  id: string;
269
  type: SpaceType;
270
- category?: 'course' | 'personal';
271
  courseName?: string;
272
  courseInfo?: CourseInfo;
273
  members?: GroupMember[];
@@ -306,43 +262,35 @@ export function LeftSidebar({
306
  availableCourses = [],
307
  }: LeftSidebarProps) {
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('');
333
- setEmail('');
334
  toast.success(`Welcome, ${name}!`);
335
  };
336
 
337
  const handleLogout = () => {
338
  onLogout();
339
  setShowLoginForm(false);
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');
346
  a.href = url;
347
  a.download = filename;
348
  document.body.appendChild(a);
@@ -353,26 +301,26 @@ export function LeftSidebar({
353
 
354
  const formatDateStamp = (date: Date) => {
355
  const yyyy = date.getFullYear();
356
- const mm = String(date.getMonth() + 1).padStart(2, '0');
357
- const dd = String(date.getDate()).padStart(2, '0');
358
  return `${yyyy}-${mm}-${dd}`;
359
  };
360
 
361
  const getDefaultFilenameBase = (item: SavedItem) => {
362
- const kind = item.type === 'export' ? 'export' : item.type === 'summary' ? 'summary' : 'quiz';
363
  return `clare-${kind}-${formatDateStamp(item.timestamp)}`;
364
  };
365
 
366
  const handleDownloadMd = async (item: SavedItem) => {
367
  try {
368
  setIsDownloading(true);
369
- toast.message('Preparing .md…');
370
- const blob = new Blob([item.content], { type: 'text/markdown;charset=utf-8' });
371
  downloadBlob(blob, `${getDefaultFilenameBase(item)}.md`);
372
- toast.success('Downloaded .md');
373
  } catch (e) {
374
  console.error(e);
375
- toast.error('Failed to download .md');
376
  } finally {
377
  setIsDownloading(false);
378
  }
@@ -381,30 +329,24 @@ export function LeftSidebar({
381
  const handleDownloadDocx = async (item: SavedItem) => {
382
  try {
383
  setIsDownloading(true);
384
- toast.message('Preparing .docx…');
385
- const lines = item.content.split('\n');
386
  const paragraphs: Paragraph[] = lines.map((line) => {
387
  const trimmed = line.trim();
388
- if (!trimmed) return new Paragraph({ text: '' });
389
- if (trimmed.startsWith('### ')) {
390
- return new Paragraph({ text: trimmed.replace(/^###\s+/, ''), heading: HeadingLevel.HEADING_3 });
391
- }
392
- if (trimmed.startsWith('## ')) {
393
- return new Paragraph({ text: trimmed.replace(/^##\s+/, ''), heading: HeadingLevel.HEADING_2 });
394
- }
395
- if (trimmed.startsWith('# ')) {
396
- return new Paragraph({ text: trimmed.replace(/^#\s+/, ''), heading: HeadingLevel.HEADING_1 });
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');
405
  } catch (e) {
406
  console.error(e);
407
- toast.error('Failed to download .docx');
408
  } finally {
409
  setIsDownloading(false);
410
  }
@@ -413,8 +355,8 @@ export function LeftSidebar({
413
  const handleDownloadPdf = async (item: SavedItem) => {
414
  try {
415
  setIsDownloading(true);
416
- toast.message('Preparing .pdf…');
417
- const doc = new jsPDF({ unit: 'pt', format: 'a4' });
418
  const pageWidth = doc.internal.pageSize.getWidth();
419
  const pageHeight = doc.internal.pageSize.getHeight();
420
  const margin = 40;
@@ -433,10 +375,10 @@ export function LeftSidebar({
433
  });
434
 
435
  doc.save(`${getDefaultFilenameBase(item)}.pdf`);
436
- toast.success('Downloaded .pdf');
437
  } catch (e) {
438
  console.error(e);
439
- toast.error('Failed to download .pdf');
440
  } finally {
441
  setIsDownloading(false);
442
  }
@@ -445,7 +387,7 @@ export function LeftSidebar({
445
  const handleCopy = async (content: string) => {
446
  await navigator.clipboard.writeText(content);
447
  setCopied(true);
448
- toast.success('Copied to clipboard!');
449
  setTimeout(() => setCopied(false), 2000);
450
  };
451
 
@@ -453,7 +395,6 @@ export function LeftSidebar({
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;
@@ -463,12 +404,8 @@ export function LeftSidebar({
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
  }
473
  // eslint-disable-next-line react-hooks/exhaustive-deps
474
  }, [savedItems, isDialogOpen]);
@@ -479,8 +416,7 @@ export function LeftSidebar({
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 {
@@ -488,65 +424,56 @@ export function LeftSidebar({
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' },
508
- { id: 'course3', name: 'Data Structures' },
509
- { id: 'course4', name: 'Web Development' },
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 {
522
- type: 'course' as const,
523
  name: currentWorkspace.courseInfo.name,
524
  instructor: currentWorkspace.courseInfo.instructor,
525
  teachingAssistant: currentWorkspace.courseInfo.teachingAssistant,
526
  };
527
  }
528
  return {
529
- type: 'course' as const,
530
- name: currentWorkspace.courseName || 'Unknown Course',
531
- instructor: { name: 'Unknown', email: '' },
532
- teachingAssistant: { name: 'Unknown', email: '' },
533
  };
534
  }
535
 
536
- if (currentWorkspace.type === 'group' && currentWorkspace.category === 'personal') {
537
  return {
538
- type: 'personal' as const,
539
  name: editableTitle,
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,
550
  name: courseInfo.name,
551
  instructor: courseInfo.instructor,
552
  teachingAssistant: courseInfo.teachingAssistant,
@@ -554,10 +481,10 @@ export function LeftSidebar({
554
  }
555
  const course = coursesList.find((c) => c.id === saved);
556
  return {
557
- type: 'course' as const,
558
  name: course?.name || saved,
559
- instructor: { name: 'Unknown', email: '' },
560
- teachingAssistant: { name: 'Unknown', email: '' },
561
  };
562
  }
563
 
@@ -567,13 +494,11 @@ export function LeftSidebar({
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">
576
- {courseDisplayInfo.type === 'course' ? (
577
  <>
578
  <h3 className="text-base font-semibold mb-4">{courseDisplayInfo.name}</h3>
579
  <div className="space-y-2 text-sm">
@@ -600,7 +525,7 @@ export function LeftSidebar({
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"
@@ -611,11 +536,12 @@ export function LeftSidebar({
611
  </h3>
612
  )}
613
  </div>
 
614
  <div className="space-y-2 text-sm">
615
  <div className="text-muted-foreground mb-2">Members:</div>
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>
@@ -627,7 +553,6 @@ export function LeftSidebar({
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>
@@ -640,9 +565,7 @@ export function LeftSidebar({
640
  className="w-20 h-20 rounded-full object-cover mb-4"
641
  />
642
  <h3 className="mb-2">Welcome to Clare!</h3>
643
- <p className="text-sm text-muted-foreground text-center mb-4">
644
- Log in to start your personalized learning journey
645
- </p>
646
  </div>
647
 
648
  {!showLoginForm ? (
@@ -658,11 +581,21 @@ export function LeftSidebar({
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,51 +604,50 @@ export function LeftSidebar({
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" />
689
- <p>No saved chats yet</p>
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
 
700
  chat={chat}
701
  onLoadChat={onLoadChat}
702
  onDeleteSavedChat={onDeleteSavedChat}
703
  onRenameSavedChat={onRenameSavedChat}
704
  />
705
- </div>
706
- ))}
707
- </div>
708
- )}
709
  </div>
710
  )}
711
 
712
- {/* Saved Item Dialog */}
713
  <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
714
  <DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
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>
@@ -748,7 +680,7 @@ export function LeftSidebar({
748
  .docx
749
  </Button>
750
 
751
- {selectedItem.format === 'pdf' && (
752
  <Button
753
  variant="outline"
754
  size="sm"
@@ -770,7 +702,7 @@ export function LeftSidebar({
770
  className="h-7 px-2 text-xs gap-1.5"
771
  title="Copy"
772
  >
773
- <Copy className={`h-3 w-3 ${copied ? 'text-green-600' : ''}`} />
774
  </Button>
775
 
776
  <Button
@@ -778,10 +710,12 @@ export function LeftSidebar({
778
  size="sm"
779
  onClick={handleToggleSave}
780
  disabled={isDownloading}
781
- className={`h-7 px-2 text-xs gap-1.5 ${isItemSaved ? 'bg-red-50 dark:bg-red-950/20 border-red-300 dark:border-red-800' : ''}`}
782
- title={isItemSaved ? 'Unsave' : 'Save for later'}
 
 
783
  >
784
- <Bookmark className={`h-3 w-3 ${isItemSaved ? 'fill-red-600 text-red-600' : ''}`} />
785
  </Button>
786
  </div>
787
 
 
1
+ // web/src/components/LeftSidebar.tsx
2
+ import React, { useEffect, useRef, useState } from "react";
3
+ import { LearningModeSelector } from "./LearningModeSelector";
4
+ import { Label } from "./ui/label";
5
+ import { Button } from "./ui/button";
6
+ import { LogIn, Bookmark, Download, Copy, MessageSquare, Trash2, Edit2, Check, X as XIcon } from "lucide-react";
7
+ import { Separator } from "./ui/separator";
8
+ import { GroupMembers } from "./GroupMembers";
9
+ import { Card } from "./ui/card";
10
+ import { Input } from "./ui/input";
11
+ import type { LearningMode, Language, SpaceType, GroupMember, User as UserType, SavedItem, SavedChat } from "../App";
12
+ import { toast } from "sonner";
13
+ import { Document, HeadingLevel, Packer, Paragraph, TextRun } from "docx";
14
+ import { jsPDF } from "jspdf";
15
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog";
16
+ import type { CourseInfo } from "../App";
17
+
18
+ // Saved Chat Item Component with rename functionality
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  function SavedChatItem({
20
  chat,
21
  onLoadChat,
 
71
  const relatedTarget = e.relatedTarget as HTMLElement;
72
  if (
73
  relatedTarget &&
74
+ (cancelButtonRef.current?.contains(relatedTarget) || saveButtonRef.current?.contains(relatedTarget))
 
75
  ) {
76
  return;
77
  }
 
82
  };
83
 
84
  const handleKeyDown = (e: React.KeyboardEvent) => {
85
+ if (e.key === "Enter") {
86
  e.preventDefault();
87
  e.stopPropagation();
88
  if (editTitle.trim() && onRenameSavedChat) {
89
  onRenameSavedChat(chat.id, editTitle.trim());
90
  setIsEditing(false);
91
  }
92
+ } else if (e.key === "Escape") {
93
  e.preventDefault();
94
  e.stopPropagation();
95
  setEditTitle(originalTitle);
 
98
  };
99
 
100
  return (
101
+ <Card className="p-3 cursor-pointer hover:bg-muted/50 transition-all bg-muted/30" onClick={() => !isEditing && onLoadChat(chat)}>
 
 
 
102
  <div className="flex items-start gap-2">
103
  <MessageSquare className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-muted-foreground" />
104
  <div className="flex-1 min-w-0">
 
112
  onClick={(e) => e.stopPropagation()}
113
  onBlur={handleInputBlur}
114
  className="h-auto text-sm font-medium px-2 py-1 border border-border bg-background focus-visible:ring-2 focus-visible:ring-ring flex-1"
115
+ style={{ height: "auto" }}
116
  />
117
  ) : (
118
  <h4
 
166
  ) : (
167
  <>
168
  {onRenameSavedChat && (
169
+ <Button variant="ghost" size="icon" className="h-5 w-5 flex-shrink-0 hover:bg-muted" onClick={handleStartEdit} title="Rename chat">
 
 
 
 
 
 
 
170
  <Edit2 className="h-3 w-3" />
171
  </Button>
172
  )}
 
179
  onDeleteSavedChat(chat.id);
180
  }}
181
  title="Delete chat"
 
182
  >
183
  <Trash2 className="h-3 w-3" />
184
  </Button>
 
188
  </div>
189
 
190
  <p className="text-xs text-muted-foreground mt-1">
191
+ {chat.chatMode === "ask" ? "Ask" : chat.chatMode === "review" ? "Review" : "Quiz"} • {chat.timestamp.toLocaleDateString()}
 
192
  </p>
193
  <p className="text-xs text-muted-foreground/70 mt-1">
194
+ {chat.messages.length} message{chat.messages.length !== 1 ? "s" : ""}
195
  </p>
196
  </div>
197
  </div>
 
199
  );
200
  }
201
 
 
 
 
202
  interface LeftSidebarProps {
203
  learningMode: LearningMode;
204
  language: Language;
 
214
  savedItems: SavedItem[];
215
  recentlySavedId: string | null;
216
  onUnsave: (id: string) => void;
217
+ onSave: (content: string, type: "export" | "quiz" | "summary") => void;
218
  savedChats: SavedChat[];
219
  onLoadChat: (chat: SavedChat) => void;
220
  onDeleteSavedChat: (id: string) => void;
 
223
  workspaces?: Array<{
224
  id: string;
225
  type: SpaceType;
226
+ category?: "course" | "personal";
227
  courseName?: string;
228
  courseInfo?: CourseInfo;
229
  members?: GroupMember[];
 
262
  availableCourses = [],
263
  }: LeftSidebarProps) {
264
  const [showLoginForm, setShowLoginForm] = useState(false);
265
+ const [name, setName] = useState("");
266
+ const [email, setEmail] = useState("");
 
267
  const [selectedItem, setSelectedItem] = useState<SavedItem | null>(null);
268
  const [isDialogOpen, setIsDialogOpen] = useState(false);
269
 
 
 
 
270
  const [isDownloading, setIsDownloading] = useState(false);
271
  const [copied, setCopied] = useState(false);
272
 
 
 
 
 
273
  const handleLogin = () => {
274
  if (!name.trim() || !email.trim()) {
275
+ toast.error("Please fill in all fields");
276
  return;
277
  }
278
  onLogin({ name: name.trim(), email: email.trim() });
279
  setShowLoginForm(false);
280
+ setName("");
281
+ setEmail("");
282
  toast.success(`Welcome, ${name}!`);
283
  };
284
 
285
  const handleLogout = () => {
286
  onLogout();
287
  setShowLoginForm(false);
288
+ toast.success("Logged out successfully");
289
  };
290
 
291
  const downloadBlob = (blob: Blob, filename: string) => {
292
  const url = URL.createObjectURL(blob);
293
+ const a = document.createElement("a");
294
  a.href = url;
295
  a.download = filename;
296
  document.body.appendChild(a);
 
301
 
302
  const formatDateStamp = (date: Date) => {
303
  const yyyy = date.getFullYear();
304
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
305
+ const dd = String(date.getDate()).padStart(2, "0");
306
  return `${yyyy}-${mm}-${dd}`;
307
  };
308
 
309
  const getDefaultFilenameBase = (item: SavedItem) => {
310
+ const kind = item.type === "export" ? "export" : item.type === "summary" ? "summary" : "quiz";
311
  return `clare-${kind}-${formatDateStamp(item.timestamp)}`;
312
  };
313
 
314
  const handleDownloadMd = async (item: SavedItem) => {
315
  try {
316
  setIsDownloading(true);
317
+ toast.message("Preparing .md…");
318
+ const blob = new Blob([item.content], { type: "text/markdown;charset=utf-8" });
319
  downloadBlob(blob, `${getDefaultFilenameBase(item)}.md`);
320
+ toast.success("Downloaded .md");
321
  } catch (e) {
322
  console.error(e);
323
+ toast.error("Failed to download .md");
324
  } finally {
325
  setIsDownloading(false);
326
  }
 
329
  const handleDownloadDocx = async (item: SavedItem) => {
330
  try {
331
  setIsDownloading(true);
332
+ toast.message("Preparing .docx…");
333
+ const lines = item.content.split("\n");
334
  const paragraphs: Paragraph[] = lines.map((line) => {
335
  const trimmed = line.trim();
336
+ if (!trimmed) return new Paragraph({ text: "" });
337
+ if (trimmed.startsWith("### ")) return new Paragraph({ text: trimmed.replace(/^###\s+/, ""), heading: HeadingLevel.HEADING_3 });
338
+ if (trimmed.startsWith("## ")) return new Paragraph({ text: trimmed.replace(/^##\s+/, ""), heading: HeadingLevel.HEADING_2 });
339
+ if (trimmed.startsWith("# ")) return new Paragraph({ text: trimmed.replace(/^#\s+/, ""), heading: HeadingLevel.HEADING_1 });
 
 
 
 
 
 
340
  return new Paragraph({ children: [new TextRun({ text: line })] });
341
  });
342
 
343
  const doc = new Document({ sections: [{ properties: {}, children: paragraphs }] });
344
  const blob = await Packer.toBlob(doc);
345
  downloadBlob(blob, `${getDefaultFilenameBase(item)}.docx`);
346
+ toast.success("Downloaded .docx");
347
  } catch (e) {
348
  console.error(e);
349
+ toast.error("Failed to download .docx");
350
  } finally {
351
  setIsDownloading(false);
352
  }
 
355
  const handleDownloadPdf = async (item: SavedItem) => {
356
  try {
357
  setIsDownloading(true);
358
+ toast.message("Preparing .pdf…");
359
+ const doc = new jsPDF({ unit: "pt", format: "a4" });
360
  const pageWidth = doc.internal.pageSize.getWidth();
361
  const pageHeight = doc.internal.pageSize.getHeight();
362
  const margin = 40;
 
375
  });
376
 
377
  doc.save(`${getDefaultFilenameBase(item)}.pdf`);
378
+ toast.success("Downloaded .pdf");
379
  } catch (e) {
380
  console.error(e);
381
+ toast.error("Failed to download .pdf");
382
  } finally {
383
  setIsDownloading(false);
384
  }
 
387
  const handleCopy = async (content: string) => {
388
  await navigator.clipboard.writeText(content);
389
  setCopied(true);
390
+ toast.success("Copied to clipboard!");
391
  setTimeout(() => setCopied(false), 2000);
392
  };
393
 
 
395
  onUnsave(id);
396
  };
397
 
 
398
  const isItemSaved = selectedItem
399
  ? savedItems.some((item) => {
400
  if (selectedItem.id && item.id === selectedItem.id) return true;
 
404
 
405
  useEffect(() => {
406
  if (selectedItem && isDialogOpen) {
407
+ const updatedItem = savedItems.find((item) => item.content === selectedItem.content && item.type === selectedItem.type);
408
+ if (updatedItem && updatedItem.id !== selectedItem.id) setSelectedItem(updatedItem);
 
 
 
 
409
  }
410
  // eslint-disable-next-line react-hooks/exhaustive-deps
411
  }, [savedItems, isDialogOpen]);
 
416
  if (isItemSaved) {
417
  const itemToUnsave = savedItems.find(
418
  (item) =>
419
+ (selectedItem.id && item.id === selectedItem.id) || (item.content === selectedItem.content && item.type === selectedItem.type)
 
420
  );
421
  if (itemToUnsave) handleUnsaveItem(itemToUnsave.id);
422
  } else {
 
424
  }
425
  };
426
 
 
427
  const filteredSavedItems = savedItems
428
+ .filter((item) => item.workspaceId === currentWorkspaceId);
 
 
 
 
 
 
 
 
 
429
 
 
430
  const defaultCourses = [
431
+ { id: "course1", name: "Introduction to AI" },
432
+ { id: "course2", name: "Machine Learning" },
433
+ { id: "course3", name: "Data Structures" },
434
+ { id: "course4", name: "Web Development" },
435
  ];
436
  const coursesList = courses.length > 0 ? courses : defaultCourses;
437
 
 
438
  const currentWorkspace = workspaces?.find((w) => w.id === currentWorkspaceId);
439
 
440
+ const [editableTitle, setEditableTitle] = useState("Untitled");
441
+ const [isEditingTitle, setIsEditingTitle] = useState(false);
442
+
443
  const getCourseDisplayInfo = () => {
444
  if (!currentWorkspace) return null;
445
 
446
+ if (currentWorkspace.type === "group" && currentWorkspace.category === "course") {
447
  if (currentWorkspace.courseInfo) {
448
  return {
449
+ type: "course" as const,
450
  name: currentWorkspace.courseInfo.name,
451
  instructor: currentWorkspace.courseInfo.instructor,
452
  teachingAssistant: currentWorkspace.courseInfo.teachingAssistant,
453
  };
454
  }
455
  return {
456
+ type: "course" as const,
457
+ name: currentWorkspace.courseName || "Unknown Course",
458
+ instructor: { name: "Unknown", email: "" },
459
+ teachingAssistant: { name: "Unknown", email: "" },
460
  };
461
  }
462
 
463
+ if (currentWorkspace.type === "group" && currentWorkspace.category === "personal") {
464
  return {
465
+ type: "personal" as const,
466
  name: editableTitle,
467
  members: currentWorkspace.members || [],
468
  };
469
  }
470
 
471
+ if (currentWorkspace.type === "individual") {
472
+ const saved = selectedCourse || localStorage.getItem("myspace_selected_course") || "course1";
473
  const courseInfo = availableCourses?.find((c) => c.id === saved);
474
  if (courseInfo) {
475
  return {
476
+ type: "course" as const,
477
  name: courseInfo.name,
478
  instructor: courseInfo.instructor,
479
  teachingAssistant: courseInfo.teachingAssistant,
 
481
  }
482
  const course = coursesList.find((c) => c.id === saved);
483
  return {
484
+ type: "course" as const,
485
  name: course?.name || saved,
486
+ instructor: { name: "Unknown", email: "" },
487
+ teachingAssistant: { name: "Unknown", email: "" },
488
  };
489
  }
490
 
 
494
  const courseDisplayInfo = getCourseDisplayInfo();
495
 
496
  return (
497
+ <div className="h-full min-h-0 flex flex-col overflow-hidden">
498
+ {/* Top fixed blocks */}
 
 
499
  {isLoggedIn && courseDisplayInfo && (
500
  <div className="p-4 border-b border-border flex-shrink-0">
501
+ {courseDisplayInfo.type === "course" ? (
502
  <>
503
  <h3 className="text-base font-semibold mb-4">{courseDisplayInfo.name}</h3>
504
  <div className="space-y-2 text-sm">
 
525
  onChange={(e) => setEditableTitle(e.target.value)}
526
  onBlur={() => setIsEditingTitle(false)}
527
  onKeyDown={(e) => {
528
+ if (e.key === "Enter") setIsEditingTitle(false);
529
  }}
530
  autoFocus
531
  className="text-base font-semibold"
 
536
  </h3>
537
  )}
538
  </div>
539
+
540
  <div className="space-y-2 text-sm">
541
  <div className="text-muted-foreground mb-2">Members:</div>
542
+ {courseDisplayInfo.members.map((member: any, idx: number) => (
543
  <div key={member.id}>
544
+ <span className="text-muted-foreground">{idx === 0 ? "Creator: " : "Member: "}</span>
545
  <a href={`mailto:${member.email}`} className="text-primary hover:underline">
546
  {member.name}
547
  </a>
 
553
  </div>
554
  )}
555
 
 
556
  {!isLoggedIn && (
557
  <div className="p-4 border-b border-border flex-shrink-0">
558
  <h3 className="text-base font-medium mb-4">Login</h3>
 
565
  className="w-20 h-20 rounded-full object-cover mb-4"
566
  />
567
  <h3 className="mb-2">Welcome to Clare!</h3>
568
+ <p className="text-sm text-muted-foreground text-center mb-4">Log in to start your personalized learning journey</p>
 
 
569
  </div>
570
 
571
  {!showLoginForm ? (
 
581
  </div>
582
  <div className="space-y-2">
583
  <Label htmlFor="email">Email / Student ID</Label>
584
+ <Input
585
+ id="email"
586
+ type="email"
587
+ value={email}
588
+ onChange={(e) => setEmail(e.target.value)}
589
+ placeholder="Enter your email or ID"
590
+ />
591
  </div>
592
  <div className="flex gap-2">
593
+ <Button onClick={handleLogin} className="flex-1">
594
+ Enter
595
+ </Button>
596
+ <Button variant="outline" onClick={() => setShowLoginForm(false)}>
597
+ Cancel
598
+ </Button>
599
  </div>
600
  </div>
601
  )}
 
604
  </div>
605
  )}
606
 
607
+ {spaceType === "group" && (
 
608
  <div className="p-4 border-b border-border flex-shrink-0">
609
  <GroupMembers members={groupMembers} />
610
  </div>
611
  )}
612
 
613
+ {/* Saved Chat: ONLY this list scrolls */}
614
  {isLoggedIn && (
615
+ <div className="flex-1 min-h-0 flex flex-col">
616
+ <div className="p-4 border-b border-border flex-shrink-0">
617
+ <h3 className="text-base font-medium">Saved Chat</h3>
618
+ </div>
619
+
620
+ <div className="flex-1 min-h-0 overflow-y-auto overscroll-contain p-4">
621
+ {savedChats.length === 0 ? (
622
+ <div className="text-sm text-muted-foreground text-center py-4">
623
+ <MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
624
+ <p>No saved chats yet</p>
625
+ <p className="text-xs mt-1">Save conversations to view them here</p>
626
+ </div>
627
+ ) : (
628
+ <div className="space-y-2">
629
+ {savedChats.map((chat) => (
 
630
  <SavedChatItem
631
+ key={chat.id}
632
  chat={chat}
633
  onLoadChat={onLoadChat}
634
  onDeleteSavedChat={onDeleteSavedChat}
635
  onRenameSavedChat={onRenameSavedChat}
636
  />
637
+ ))}
638
+ </div>
639
+ )}
640
+ </div>
641
  </div>
642
  )}
643
 
644
+ {/* Saved Item Dialog (保持你原逻辑) */}
645
  <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
646
  <DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
647
  <DialogHeader>
648
  <DialogTitle>{selectedItem?.title}</DialogTitle>
649
  <DialogDescription>
650
+ {selectedItem?.type === "export" ? "Export" : selectedItem?.type === "quiz" ? "Quiz" : "Summary"} •{" "}
651
  {selectedItem?.timestamp.toLocaleDateString()}
652
  </DialogDescription>
653
  </DialogHeader>
 
680
  .docx
681
  </Button>
682
 
683
+ {selectedItem.format === "pdf" && (
684
  <Button
685
  variant="outline"
686
  size="sm"
 
702
  className="h-7 px-2 text-xs gap-1.5"
703
  title="Copy"
704
  >
705
+ <Copy className={`h-3 w-3 ${copied ? "text-green-600" : ""}`} />
706
  </Button>
707
 
708
  <Button
 
710
  size="sm"
711
  onClick={handleToggleSave}
712
  disabled={isDownloading}
713
+ className={`h-7 px-2 text-xs gap-1.5 ${
714
+ isItemSaved ? "bg-red-50 dark:bg-red-950/20 border-red-300 dark:border-red-800" : ""
715
+ }`}
716
+ title={isItemSaved ? "Unsave" : "Save for later"}
717
  >
718
+ <Bookmark className={`h-3 w-3 ${isItemSaved ? "fill-red-600 text-red-600" : ""}`} />
719
  </Button>
720
  </div>
721