SarahXia0405 commited on
Commit
692753e
·
verified ·
1 Parent(s): daa9a58

Update web/src/components/LeftSidebar.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/LeftSidebar.tsx +418 -749
web/src/components/LeftSidebar.tsx CHANGED
@@ -1,818 +1,487 @@
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 {
7
- LogIn,
 
 
 
 
 
8
  Bookmark,
9
- Download,
10
- Copy,
11
  MessageSquare,
12
  Trash2,
13
- Edit2,
14
  Check,
15
- X as XIcon,
16
  } from "lucide-react";
17
- import { Separator } from "./ui/separator";
18
- import { GroupMembers } from "./GroupMembers";
19
- import { Card } from "./ui/card";
20
- import { Input } from "./ui/input";
21
  import type {
22
  LearningMode,
23
  Language,
24
  SpaceType,
25
  GroupMember,
26
- User as UserType,
27
  SavedItem,
28
  SavedChat,
 
 
29
  } from "../App";
30
- import { toast } from "sonner";
31
- import { Document, HeadingLevel, Packer, Paragraph, TextRun } from "docx";
32
- import { jsPDF } from "jspdf";
33
- import {
34
- Dialog,
35
- DialogContent,
36
- DialogDescription,
37
- DialogHeader,
38
- DialogTitle,
39
- } from "./ui/dialog";
40
- import type { CourseInfo } from "../App";
41
-
42
- // ================================
43
- // Saved Chat Item (unchanged)
44
- // ================================
45
- function SavedChatItem({
46
- chat,
47
- onLoadChat,
48
- onDeleteSavedChat,
49
- onRenameSavedChat,
50
- }: {
51
- chat: SavedChat;
52
- onLoadChat: (chat: SavedChat) => void;
53
- onDeleteSavedChat: (id: string) => void;
54
- onRenameSavedChat?: (id: string, newTitle: string) => void;
55
- }) {
56
- const [isEditing, setIsEditing] = useState(false);
57
- const [editTitle, setEditTitle] = useState(chat.title);
58
- const [originalTitle, setOriginalTitle] = useState(chat.title);
59
- const inputRef = useRef<HTMLInputElement>(null);
60
- const cancelButtonRef = useRef<HTMLButtonElement>(null);
61
- const saveButtonRef = useRef<HTMLButtonElement>(null);
62
-
63
- useEffect(() => {
64
- if (!isEditing) {
65
- setOriginalTitle(chat.title);
66
- setEditTitle(chat.title);
67
- }
68
- }, [chat.title, isEditing]);
69
-
70
- const handleStartEdit = (e: React.MouseEvent) => {
71
- e.preventDefault();
72
- e.stopPropagation();
73
- setOriginalTitle(chat.title);
74
- setEditTitle(chat.title);
75
- setIsEditing(true);
76
- setTimeout(() => inputRef.current?.focus(), 0);
77
- };
78
-
79
- const handleSaveEdit = (e: React.MouseEvent) => {
80
- e.preventDefault();
81
- e.stopPropagation();
82
- if (editTitle.trim() && onRenameSavedChat) {
83
- onRenameSavedChat(chat.id, editTitle.trim());
84
- setIsEditing(false);
85
- }
86
- };
87
-
88
- const handleCancelEdit = (e: React.MouseEvent) => {
89
- e.preventDefault();
90
- e.stopPropagation();
91
- setEditTitle(originalTitle);
92
- setIsEditing(false);
93
- inputRef.current?.blur();
94
- };
95
-
96
- const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
97
- const relatedTarget = e.relatedTarget as HTMLElement;
98
- if (
99
- relatedTarget &&
100
- (cancelButtonRef.current?.contains(relatedTarget) ||
101
- saveButtonRef.current?.contains(relatedTarget))
102
- ) {
103
- return;
104
- }
105
- if (editTitle.trim() && editTitle !== originalTitle && onRenameSavedChat) {
106
- onRenameSavedChat(chat.id, editTitle.trim());
107
- }
108
- setIsEditing(false);
109
- };
110
-
111
- const handleKeyDown = (e: React.KeyboardEvent) => {
112
- if (e.key === "Enter") {
113
- e.preventDefault();
114
- e.stopPropagation();
115
- if (editTitle.trim() && onRenameSavedChat) {
116
- onRenameSavedChat(chat.id, editTitle.trim());
117
- setIsEditing(false);
118
- }
119
- } else if (e.key === "Escape") {
120
- e.preventDefault();
121
- e.stopPropagation();
122
- setEditTitle(originalTitle);
123
- setIsEditing(false);
124
- }
125
- };
126
-
127
- return (
128
- <Card
129
- className="p-3 cursor-pointer hover:bg-muted/50 transition-all bg-muted/30"
130
- onClick={() => !isEditing && onLoadChat(chat)}
131
- >
132
- <div className="flex items-start gap-2">
133
- <MessageSquare className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-muted-foreground" />
134
- <div className="flex-1 min-w-0">
135
- <div className="flex items-start justify-between gap-2">
136
- {isEditing ? (
137
- <Input
138
- ref={inputRef}
139
- value={editTitle}
140
- onChange={(e) => setEditTitle(e.target.value)}
141
- onKeyDown={handleKeyDown}
142
- onClick={(e) => e.stopPropagation()}
143
- onBlur={handleInputBlur}
144
- 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"
145
- style={{ height: "auto" }}
146
- />
147
- ) : (
148
- <h4
149
- className="text-sm font-medium truncate flex-1 cursor-text"
150
- onDoubleClick={(e) => {
151
- e.preventDefault();
152
- e.stopPropagation();
153
- handleStartEdit(e);
154
- }}
155
- onClick={(e) => e.stopPropagation()}
156
- title="Double click to rename"
157
- >
158
- {chat.title}
159
- </h4>
160
- )}
161
-
162
- <div className="flex items-center gap-1 flex-shrink-0">
163
- {isEditing ? (
164
- <>
165
- <Button
166
- ref={saveButtonRef}
167
- variant="ghost"
168
- size="icon"
169
- className="h-5 w-5 flex-shrink-0 hover:bg-green-500/20"
170
- onClick={(e) => {
171
- e.preventDefault();
172
- e.stopPropagation();
173
- handleSaveEdit(e);
174
- }}
175
- title="Save"
176
- type="button"
177
- >
178
- <Check className="h-3 w-3" />
179
- </Button>
180
- <Button
181
- ref={cancelButtonRef}
182
- variant="ghost"
183
- size="icon"
184
- className="h-5 w-5 flex-shrink-0 hover:bg-destructive/20"
185
- onClick={(e) => {
186
- e.preventDefault();
187
- e.stopPropagation();
188
- handleCancelEdit(e);
189
- }}
190
- title="Cancel"
191
- type="button"
192
- >
193
- <XIcon className="h-3 w-3" />
194
- </Button>
195
- </>
196
- ) : (
197
- <>
198
- {onRenameSavedChat && (
199
- <Button
200
- variant="ghost"
201
- size="icon"
202
- className="h-5 w-5 flex-shrink-0 hover:bg-muted"
203
- onClick={handleStartEdit}
204
- title="Rename chat"
205
- >
206
- <Edit2 className="h-3 w-3" />
207
- </Button>
208
- )}
209
- <Button
210
- variant="ghost"
211
- size="icon"
212
- className="h-5 w-5 flex-shrink-0 hover:bg-destructive/20"
213
- onClick={(e) => {
214
- e.stopPropagation();
215
- onDeleteSavedChat(chat.id);
216
- }}
217
- title="Delete chat"
218
- >
219
- <Trash2 className="h-3 w-3" />
220
- </Button>
221
- </>
222
- )}
223
- </div>
224
- </div>
225
-
226
- <p className="text-xs text-muted-foreground mt-1">
227
- {chat.chatMode === "ask"
228
- ? "Ask"
229
- : chat.chatMode === "review"
230
- ? "Review"
231
- : "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>
239
- </Card>
240
- );
241
- }
242
 
243
- interface LeftSidebarProps {
244
  learningMode: LearningMode;
245
  language: Language;
246
- onLearningModeChange: (mode: LearningMode) => void;
247
- onLanguageChange: (lang: Language) => void;
 
248
  spaceType: SpaceType;
249
  groupMembers: GroupMember[];
250
- user: UserType | null;
251
- onLogin: (user: UserType) => void;
 
252
  onLogout: () => void;
253
  isLoggedIn: boolean;
254
- onEditProfile: () => void;
 
 
255
  savedItems: SavedItem[];
256
  recentlySavedId: string | null;
257
  onUnsave: (id: string) => void;
258
- onSave: (content: string, type: "export" | "quiz" | "summary") => void;
 
 
 
 
 
 
 
259
  savedChats: SavedChat[];
260
- onLoadChat: (chat: SavedChat) => void;
261
  onDeleteSavedChat: (id: string) => void;
262
  onRenameSavedChat?: (id: string, newTitle: string) => void;
 
263
  currentWorkspaceId: string;
264
- workspaces?: Array<{
265
- id: string;
266
- type: SpaceType;
267
- category?: "course" | "personal";
268
- courseName?: string;
269
- courseInfo?: CourseInfo;
270
- members?: GroupMember[];
271
- isEditable?: boolean;
272
- name?: string;
273
- }>;
274
- selectedCourse?: string;
275
- courses?: Array<{ id: string; name: string }>;
276
- availableCourses?: CourseInfo[];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  }
278
 
279
- export function LeftSidebar({
280
- learningMode,
281
- language,
282
- onLearningModeChange,
283
- onLanguageChange,
284
- spaceType,
285
- groupMembers,
286
- user,
287
- onLogin,
288
- onLogout,
289
- isLoggedIn,
290
- onEditProfile,
291
- savedItems,
292
- recentlySavedId,
293
- onUnsave,
294
- onSave,
295
- savedChats,
296
- onLoadChat,
297
- onDeleteSavedChat,
298
- onRenameSavedChat,
299
- currentWorkspaceId,
300
- workspaces = [],
301
- selectedCourse,
302
- courses = [],
303
- availableCourses = [],
304
- }: LeftSidebarProps) {
305
- const [showLoginForm, setShowLoginForm] = useState(false);
306
- const [name, setName] = useState("");
307
- const [email, setEmail] = useState("");
308
- const [selectedItem, setSelectedItem] = useState<SavedItem | null>(null);
309
- const [isDialogOpen, setIsDialogOpen] = useState(false);
310
-
311
- const [isDownloading, setIsDownloading] = useState(false);
312
- const [copied, setCopied] = useState(false);
313
-
314
- const savedScrollRef = useRef<HTMLDivElement>(null);
315
-
316
- const handleLogin = () => {
317
- if (!name.trim() || !email.trim()) {
318
- toast.error("Please fill in all fields");
319
- return;
320
- }
321
- onLogin({ name: name.trim(), email: email.trim() });
322
- setShowLoginForm(false);
323
- setName("");
324
- setEmail("");
325
- toast.success(`Welcome, ${name}!`);
326
- };
327
-
328
- const handleLogout = () => {
329
- onLogout();
330
- setShowLoginForm(false);
331
- toast.success("Logged out successfully");
332
- };
333
 
334
- const downloadBlob = (blob: Blob, filename: string) => {
335
- const url = URL.createObjectURL(blob);
336
- const a = document.createElement("a");
337
- a.href = url;
338
- a.download = filename;
339
- document.body.appendChild(a);
340
- a.click();
341
- a.remove();
342
- URL.revokeObjectURL(url);
343
- };
344
 
345
- const formatDateStamp = (date: Date) => {
346
- const yyyy = date.getFullYear();
347
- const mm = String(date.getMonth() + 1).padStart(2, "0");
348
- const dd = String(date.getDate()).padStart(2, "0");
349
- return `${yyyy}-${mm}-${dd}`;
350
- };
351
 
352
- const getDefaultFilenameBase = (item: SavedItem) => {
353
- const kind =
354
- item.type === "export" ? "export" : item.type === "summary" ? "summary" : "quiz";
355
- return `clare-${kind}-${formatDateStamp(item.timestamp)}`;
356
  };
357
 
358
- const handleDownloadMd = async (item: SavedItem) => {
359
- try {
360
- setIsDownloading(true);
361
- toast.message("Preparing .md…");
362
- const blob = new Blob([item.content], { type: "text/markdown;charset=utf-8" });
363
- downloadBlob(blob, `${getDefaultFilenameBase(item)}.md`);
364
- toast.success("Downloaded .md");
365
- } catch (e) {
366
- // eslint-disable-next-line no-console
367
- console.error(e);
368
- toast.error("Failed to download .md");
369
- } finally {
370
- setIsDownloading(false);
371
- }
372
  };
373
 
374
- const handleDownloadDocx = async (item: SavedItem) => {
375
- try {
376
- setIsDownloading(true);
377
- toast.message("Preparing .docx…");
378
- const lines = item.content.split("\n");
379
- const paragraphs: Paragraph[] = lines.map((line) => {
380
- const trimmed = line.trim();
381
- if (!trimmed) return new Paragraph({ text: "" });
382
- if (trimmed.startsWith("### "))
383
- return new Paragraph({
384
- text: trimmed.replace(/^###\s+/, ""),
385
- heading: HeadingLevel.HEADING_3,
386
- });
387
- if (trimmed.startsWith("## "))
388
- return new Paragraph({
389
- text: trimmed.replace(/^##\s+/, ""),
390
- heading: HeadingLevel.HEADING_2,
391
- });
392
- if (trimmed.startsWith("# "))
393
- return new Paragraph({
394
- text: trimmed.replace(/^#\s+/, ""),
395
- heading: HeadingLevel.HEADING_1,
396
- });
397
- return new Paragraph({ children: [new TextRun({ text: line })] });
398
- });
399
-
400
- const doc = new Document({ sections: [{ properties: {}, children: paragraphs }] });
401
- const blob = await Packer.toBlob(doc);
402
- downloadBlob(blob, `${getDefaultFilenameBase(item)}.docx`);
403
- toast.success("Downloaded .docx");
404
- } catch (e) {
405
- // eslint-disable-next-line no-console
406
- console.error(e);
407
- toast.error("Failed to download .docx");
408
- } finally {
409
- setIsDownloading(false);
410
- }
411
  };
412
 
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;
421
- const contentWidth = pageWidth - margin * 2;
422
- const lineHeight = 16;
423
-
424
- const lines = doc.splitTextToSize(item.content, contentWidth);
425
- let y = margin;
426
- lines.forEach((line) => {
427
- if (y + lineHeight > pageHeight - margin) {
428
- doc.addPage();
429
- y = margin;
430
- }
431
- doc.text(line, margin, y);
432
- y += lineHeight;
433
- });
434
-
435
- doc.save(`${getDefaultFilenameBase(item)}.pdf`);
436
- toast.success("Downloaded .pdf");
437
- } catch (e) {
438
- // eslint-disable-next-line no-console
439
- console.error(e);
440
- toast.error("Failed to download .pdf");
441
- } finally {
442
- setIsDownloading(false);
443
- }
444
- };
445
 
446
- const handleCopy = async (content: string) => {
447
- await navigator.clipboard.writeText(content);
448
- setCopied(true);
449
- toast.success("Copied to clipboard!");
450
- setTimeout(() => setCopied(false), 2000);
451
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
 
453
- const handleUnsaveItem = (id: string) => {
454
- onUnsave(id);
455
- };
 
 
 
456
 
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) setSelectedItem(updatedItem);
470
- }
471
- // eslint-disable-next-line react-hooks/exhaustive-deps
472
- }, [savedItems, isDialogOpen]);
473
-
474
- const handleToggleSave = () => {
475
- if (!selectedItem) return;
476
-
477
- if (isItemSaved) {
478
- const itemToUnsave = savedItems.find(
479
- (item) =>
480
- (selectedItem.id && item.id === selectedItem.id) ||
481
- (item.content === selectedItem.content && item.type === selectedItem.type)
482
- );
483
- if (itemToUnsave) handleUnsaveItem(itemToUnsave.id);
484
- } else {
485
- onSave(selectedItem.content, selectedItem.type);
486
- }
487
- };
488
 
489
- const defaultCourses = [
490
- { id: "course1", name: "Introduction to AI" },
491
- { id: "course2", name: "Machine Learning" },
492
- { id: "course3", name: "Data Structures" },
493
- { id: "course4", name: "Web Development" },
494
- ];
495
- const coursesList = courses.length > 0 ? courses : defaultCourses;
496
-
497
- const currentWorkspace = workspaces?.find((w) => w.id === currentWorkspaceId);
498
-
499
- const [editableTitle, setEditableTitle] = useState("Untitled");
500
- const [isEditingTitle, setIsEditingTitle] = useState(false);
501
-
502
- const getCourseDisplayInfo = () => {
503
- if (!currentWorkspace) return null;
504
-
505
- if (currentWorkspace.type === "group" && currentWorkspace.category === "course") {
506
- if (currentWorkspace.courseInfo) {
507
- return {
508
- type: "course" as const,
509
- name: currentWorkspace.courseInfo.name,
510
- instructor: currentWorkspace.courseInfo.instructor,
511
- teachingAssistant: currentWorkspace.courseInfo.teachingAssistant,
512
- };
513
- }
514
- return {
515
- type: "course" as const,
516
- name: currentWorkspace.courseName || "Unknown Course",
517
- instructor: { name: "Unknown", email: "" },
518
- teachingAssistant: { name: "Unknown", email: "" },
519
- };
520
- }
521
-
522
- if (currentWorkspace.type === "group" && currentWorkspace.category === "personal") {
523
- return {
524
- type: "personal" as const,
525
- name: editableTitle,
526
- members: currentWorkspace.members || [],
527
- };
528
- }
529
-
530
- if (currentWorkspace.type === "individual") {
531
- const saved = selectedCourse || localStorage.getItem("myspace_selected_course") || "course1";
532
- const courseInfo = availableCourses?.find((c) => c.id === saved);
533
- if (courseInfo) {
534
- return {
535
- type: "course" as const,
536
- name: courseInfo.name,
537
- instructor: courseInfo.instructor,
538
- teachingAssistant: courseInfo.teachingAssistant,
539
- };
540
- }
541
- const course = coursesList.find((c) => c.id === saved);
542
- return {
543
- type: "course" as const,
544
- name: course?.name || saved,
545
- instructor: { name: "Unknown", email: "" },
546
- teachingAssistant: { name: "Unknown", email: "" },
547
- };
548
- }
549
-
550
- return null;
551
- };
552
 
553
- const courseDisplayInfo = getCourseDisplayInfo();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
 
555
- return (
556
- // LeftSidebar itself never scrolls; only Saved list scrolls.
557
- <div className="h-full min-h-0 flex flex-col overflow-hidden">
558
- {/* Top fixed blocks */}
559
- {isLoggedIn && courseDisplayInfo && (
560
- <div className="p-4 border-b border-border flex-shrink-0">
561
- {courseDisplayInfo.type === "course" ? (
562
  <>
563
- <h3 className="text-base font-semibold mb-4">{courseDisplayInfo.name}</h3>
564
- <div className="space-y-2 text-sm">
565
- <div>
566
- <span className="text-muted-foreground">Instructor: </span>
567
- <a
568
- href={`mailto:${courseDisplayInfo.instructor.email}`}
569
- className="text-primary hover:underline"
570
- >
571
- {courseDisplayInfo.instructor.name}
572
- </a>
573
  </div>
574
- <div>
575
- <span className="text-muted-foreground">TA: </span>
576
- <a
577
- href={`mailto:${courseDisplayInfo.teachingAssistant.email}`}
578
- className="text-primary hover:underline"
579
- >
580
- {courseDisplayInfo.teachingAssistant.name}
581
- </a>
 
 
 
 
 
 
 
 
 
582
  </div>
583
  </div>
584
  </>
585
- ) : (
586
- <>
587
- <div className="mb-4">
588
- {isEditingTitle ? (
589
- <Input
590
- value={editableTitle}
591
- onChange={(e) => setEditableTitle(e.target.value)}
592
- onBlur={() => setIsEditingTitle(false)}
593
- onKeyDown={(e) => {
594
- if (e.key === "Enter") setIsEditingTitle(false);
595
- }}
596
- autoFocus
597
- className="text-base font-semibold"
598
- />
599
- ) : (
600
- <h3
601
- className="text-base font-semibold cursor-pointer hover:text-primary"
602
- onClick={() => setIsEditingTitle(true)}
603
- >
604
- {editableTitle}
605
- </h3>
606
- )}
607
  </div>
 
608
 
609
- <div className="space-y-2 text-sm">
610
- <div className="text-muted-foreground mb-2">Members:</div>
611
- {courseDisplayInfo.members.map((member: any, idx: number) => (
612
- <div key={member.id}>
613
- <span className="text-muted-foreground">{idx === 0 ? "Creator: " : "Member: "}</span>
614
- <a href={`mailto:${member.email}`} className="text-primary hover:underline">
615
- {member.name}
616
- </a>
617
- </div>
618
- ))}
619
  </div>
620
- </>
621
- )}
622
- </div>
623
- )}
624
-
625
- {!isLoggedIn && (
626
- <div className="p-4 border-b border-border flex-shrink-0">
627
- <h3 className="text-base font-medium mb-4">Login</h3>
628
- <Card className="p-4">
629
- <div className="space-y-4">
630
- <div className="flex flex-col items-center py-4">
631
- <img
632
- src="https://images.unsplash.com/photo-1588912914049-d2664f76a947?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzdHVkZW50JTIwc3R1ZHlpbmclMjBpbGx1c3RyYXRpb258ZW58MXx8fHwxNzY2MDY2NjcyfDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral"
633
- alt="Student studying"
634
- className="w-20 h-20 rounded-full object-cover mb-4"
635
- />
636
- <h3 className="mb-2">Welcome to Clare!</h3>
637
- <p className="text-sm text-muted-foreground text-center mb-4">
638
- Log in to start your personalized learning journey
639
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  </div>
641
-
642
- {!showLoginForm ? (
643
- <Button onClick={() => setShowLoginForm(true)} className="w-full gap-2">
644
- <LogIn className="h-4 w-4" />
645
- Student Login
646
- </Button>
647
- ) : (
648
- <div className="space-y-3">
649
- <div className="space-y-2">
650
- <Label htmlFor="name">Name</Label>
651
- <Input
652
- id="name"
653
- value={name}
654
- onChange={(e) => setName(e.target.value)}
655
- placeholder="Enter your name"
656
- />
657
- </div>
658
- <div className="space-y-2">
659
- <Label htmlFor="email">Email / Student ID</Label>
660
- <Input
661
- id="email"
662
- type="email"
663
- value={email}
664
- onChange={(e) => setEmail(e.target.value)}
665
- placeholder="Enter your email or ID"
666
- />
667
- </div>
668
- <div className="flex gap-2">
669
- <Button onClick={handleLogin} className="flex-1">
670
- Enter
671
- </Button>
672
- <Button variant="outline" onClick={() => setShowLoginForm(false)}>
673
- Cancel
674
- </Button>
675
- </div>
676
- </div>
677
- )}
678
- </div>
679
  </Card>
680
- </div>
681
- )}
682
-
683
- {spaceType === "group" && (
684
- <div className="p-4 border-b border-border flex-shrink-0">
685
- <GroupMembers members={groupMembers} />
686
- </div>
687
- )}
688
 
689
- {/* Saved Chat: ONLY this list scrolls */}
690
- {isLoggedIn && (
691
- <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
692
- <div className="p-4 border-b border-border flex-shrink-0">
693
- <h3 className="text-base font-medium">Saved Chat</h3>
694
- </div>
 
 
 
695
 
696
- <div
697
- ref={savedScrollRef}
698
- className="flex-1 min-h-0 overflow-y-auto p-4"
699
- style={{ overscrollBehavior: "contain" }}
700
- >
701
  {savedChats.length === 0 ? (
702
- <div className="text-sm text-muted-foreground text-center py-4">
703
- <MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
704
- <p>No saved chats yet</p>
705
- <p className="text-xs mt-1">Save conversations to view them here</p>
706
  </div>
707
  ) : (
708
  <div className="space-y-2">
709
- {savedChats.map((chat) => (
710
- <SavedChatItem
711
- key={chat.id}
712
- chat={chat}
713
- onLoadChat={onLoadChat}
714
- onDeleteSavedChat={onDeleteSavedChat}
715
- onRenameSavedChat={onRenameSavedChat}
716
- />
717
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
718
  </div>
719
  )}
720
- </div>
721
- </div>
722
- )}
723
-
724
- {/* Saved Item Dialog */}
725
- <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
726
- <DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
727
- <DialogHeader>
728
- <DialogTitle>{selectedItem?.title}</DialogTitle>
729
- <DialogDescription>
730
- {selectedItem?.type === "export"
731
- ? "Export"
732
- : selectedItem?.type === "quiz"
733
- ? "Quiz"
734
- : "Summary"}{" "}
735
- • {selectedItem?.timestamp.toLocaleDateString()}
736
- </DialogDescription>
737
- </DialogHeader>
738
-
739
- <div className="flex flex-col flex-1 min-h-0 space-y-4 mt-4">
740
- {selectedItem && (
741
- <>
742
- <div className="flex items-center justify-end gap-2 flex-shrink-0">
743
- <Button
744
- variant="outline"
745
- size="sm"
746
- disabled={isDownloading}
747
- onClick={() => handleDownloadMd(selectedItem)}
748
- title="Download as .md"
749
- className="h-7 px-2 text-xs gap-1.5"
750
- >
751
- <Download className="h-3 w-3" />
752
- .md
753
- </Button>
754
-
755
- <Button
756
- variant="outline"
757
- size="sm"
758
- disabled={isDownloading}
759
- onClick={() => handleDownloadDocx(selectedItem)}
760
- title="Download as .docx"
761
- className="h-7 px-2 text-xs gap-1.5"
762
- >
763
- <Download className="h-3 w-3" />
764
- .docx
765
- </Button>
766
-
767
- {selectedItem.format === "pdf" && (
768
- <Button
769
- variant="outline"
770
- size="sm"
771
- disabled={isDownloading}
772
- onClick={() => handleDownloadPdf(selectedItem)}
773
- title="Download as .pdf"
774
- className="h-7 px-2 text-xs gap-1.5"
775
- >
776
- <Download className="h-3 w-3" />
777
- .pdf
778
- </Button>
779
- )}
780
-
781
- <Button
782
- variant="outline"
783
- size="sm"
784
- onClick={() => handleCopy(selectedItem.content)}
785
- disabled={isDownloading}
786
- className="h-7 px-2 text-xs gap-1.5"
787
- title="Copy"
788
- >
789
- <Copy className={`h-3 w-3 ${copied ? "text-green-600" : ""}`} />
790
- </Button>
791
-
792
- <Button
793
- variant="outline"
794
- size="sm"
795
- onClick={handleToggleSave}
796
- disabled={isDownloading}
797
- className={`h-7 px-2 text-xs gap-1.5 ${
798
- isItemSaved ? "bg-red-50 dark:bg-red-950/20 border-red-300 dark:border-red-800" : ""
799
- }`}
800
- title={isItemSaved ? "Unsave" : "Save for later"}
801
- >
802
- <Bookmark className={`h-3 w-3 ${isItemSaved ? "fill-red-600 text-red-600" : ""}`} />
803
- </Button>
804
- </div>
805
-
806
- <Separator className="flex-shrink-0" />
807
 
808
- <div className="text-sm whitespace-pre-wrap text-foreground overflow-y-auto flex-1 min-h-0">
809
- {selectedItem.content}
810
- </div>
811
- </>
812
- )}
813
- </div>
814
- </DialogContent>
815
- </Dialog>
 
 
 
816
  </div>
817
  );
818
  }
 
1
  // web/src/components/LeftSidebar.tsx
2
+ import React, { useMemo, useState } from "react";
 
 
3
  import { Button } from "./ui/button";
4
+ import { Card } from "./ui/card";
5
+ import { Separator } from "./ui/separator";
6
+ import { Label } from "./ui/label";
7
+ import { Input } from "./ui/input";
8
+ import {
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ } from "./ui/select";
15
  import {
16
+ LogOut,
17
+ User as UserIcon,
18
+ BookOpen,
19
+ Languages,
20
+ GraduationCap,
21
+ Users,
22
  Bookmark,
 
 
23
  MessageSquare,
24
  Trash2,
25
+ Pencil,
26
  Check,
27
+ X,
28
  } from "lucide-react";
29
+
 
 
 
30
  import type {
31
  LearningMode,
32
  Language,
33
  SpaceType,
34
  GroupMember,
35
+ User,
36
  SavedItem,
37
  SavedChat,
38
+ Workspace,
39
+ CourseInfo,
40
  } from "../App";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
+ type Props = {
43
  learningMode: LearningMode;
44
  language: Language;
45
+ onLearningModeChange: (m: LearningMode) => void;
46
+ onLanguageChange: (l: Language) => void;
47
+
48
  spaceType: SpaceType;
49
  groupMembers: GroupMember[];
50
+
51
+ user: User | null;
52
+ onLogin: (u: User | null) => void;
53
  onLogout: () => void;
54
  isLoggedIn: boolean;
55
+
56
+ onEditProfile?: () => void;
57
+
58
  savedItems: SavedItem[];
59
  recentlySavedId: string | null;
60
  onUnsave: (id: string) => void;
61
+ onSave: (
62
+ content: string,
63
+ type: "export" | "quiz" | "summary",
64
+ saveAsChat?: boolean,
65
+ format?: "pdf" | "text",
66
+ workspaceId?: string
67
+ ) => void;
68
+
69
  savedChats: SavedChat[];
70
+ onLoadChat: (c: SavedChat) => void;
71
  onDeleteSavedChat: (id: string) => void;
72
  onRenameSavedChat?: (id: string, newTitle: string) => void;
73
+
74
  currentWorkspaceId: string;
75
+ workspaces: Workspace[];
76
+
77
+ selectedCourse: string;
78
+ availableCourses: CourseInfo[];
79
+ };
80
+
81
+ const LEARNING_MODE_OPTIONS: Array<{ value: LearningMode; label: string }> = [
82
+ { value: "general", label: "General" },
83
+ { value: "concept", label: "Concept" },
84
+ { value: "socratic", label: "Socratic" },
85
+ { value: "exam", label: "Exam" },
86
+ { value: "assignment", label: "Assignment" },
87
+ { value: "summary", label: "Summary" },
88
+ ];
89
+
90
+ const LANGUAGE_OPTIONS: Array<{ value: Language; label: string }> = [
91
+ { value: "auto", label: "Auto" },
92
+ { value: "en", label: "English" },
93
+ { value: "zh", label: "中文" },
94
+ ];
95
+
96
+ function formatWhen(d: Date) {
97
+ try {
98
+ return new Date(d).toLocaleString();
99
+ } catch {
100
+ return "";
101
+ }
102
  }
103
 
104
+ export function LeftSidebar(props: Props) {
105
+ const {
106
+ learningMode,
107
+ language,
108
+ onLearningModeChange,
109
+ onLanguageChange,
110
+ spaceType,
111
+ groupMembers,
112
+ user,
113
+ onLogout,
114
+ isLoggedIn,
115
+ onEditProfile,
116
+ savedItems,
117
+ recentlySavedId,
118
+ onUnsave,
119
+ savedChats,
120
+ onLoadChat,
121
+ onDeleteSavedChat,
122
+ onRenameSavedChat,
123
+ currentWorkspaceId,
124
+ workspaces,
125
+ selectedCourse,
126
+ availableCourses,
127
+ } = props;
128
+
129
+ const currentWorkspace = useMemo(
130
+ () => workspaces.find((w) => w.id === currentWorkspaceId),
131
+ [workspaces, currentWorkspaceId]
132
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
+ const workspaceSavedItems = useMemo(
135
+ () => savedItems.filter((x) => x.workspaceId === currentWorkspaceId),
136
+ [savedItems, currentWorkspaceId]
137
+ );
 
 
 
 
 
 
138
 
139
+ const [renameId, setRenameId] = useState<string | null>(null);
140
+ const [renameDraft, setRenameDraft] = useState<string>("");
 
 
 
 
141
 
142
+ const beginRename = (chat: SavedChat) => {
143
+ setRenameId(chat.id);
144
+ setRenameDraft(chat.title);
 
145
  };
146
 
147
+ const commitRename = () => {
148
+ if (!renameId) return;
149
+ const t = renameDraft.trim();
150
+ if (!t) return;
151
+ onRenameSavedChat?.(renameId, t);
152
+ setRenameId(null);
153
+ setRenameDraft("");
 
 
 
 
 
 
 
154
  };
155
 
156
+ const cancelRename = () => {
157
+ setRenameId(null);
158
+ setRenameDraft("");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  };
160
 
161
+ return (
162
+ <div className="col min-h-0 flex-1 overflow-hidden">
163
+ {/* Fixed top section */}
164
+ <div className="colFixed p-4 space-y-3">
165
+ {/* User card */}
166
+ <Card className="p-3">
167
+ <div className="flex items-center justify-between gap-2">
168
+ <div className="flex items-center gap-2 min-w-0">
169
+ <div className="h-9 w-9 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
170
+ <UserIcon className="h-4 w-4" />
171
+ </div>
172
+ <div className="min-w-0">
173
+ <div className="text-sm font-medium truncate">
174
+ {user?.name || "User"}
175
+ </div>
176
+ <div className="text-xs text-muted-foreground truncate">
177
+ {user?.email || ""}
178
+ </div>
179
+ </div>
180
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
+ <div className="flex items-center gap-1">
183
+ {onEditProfile && (
184
+ <Button
185
+ variant="ghost"
186
+ size="icon"
187
+ onClick={onEditProfile}
188
+ title="Edit profile"
189
+ >
190
+ <Pencil className="h-4 w-4" />
191
+ </Button>
192
+ )}
193
+ {isLoggedIn && (
194
+ <Button
195
+ variant="ghost"
196
+ size="icon"
197
+ onClick={onLogout}
198
+ title="Logout"
199
+ >
200
+ <LogOut className="h-4 w-4" />
201
+ </Button>
202
+ )}
203
+ </div>
204
+ </div>
205
+ </Card>
206
 
207
+ {/* Settings */}
208
+ <Card className="p-3 space-y-3">
209
+ <div className="flex items-center gap-2">
210
+ <BookOpen className="h-4 w-4" />
211
+ <div className="text-sm font-medium">Learning</div>
212
+ </div>
213
 
214
+ <div className="space-y-1.5">
215
+ <Label className="text-xs">Learning Mode</Label>
216
+ <Select
217
+ value={learningMode}
218
+ onValueChange={(v) => onLearningModeChange(v as LearningMode)}
219
+ >
220
+ <SelectTrigger>
221
+ <SelectValue placeholder="Select mode" />
222
+ </SelectTrigger>
223
+ <SelectContent>
224
+ {LEARNING_MODE_OPTIONS.map((o) => (
225
+ <SelectItem key={o.value} value={o.value}>
226
+ {o.label}
227
+ </SelectItem>
228
+ ))}
229
+ </SelectContent>
230
+ </Select>
231
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
232
 
233
+ <div className="space-y-1.5">
234
+ <div className="flex items-center gap-2">
235
+ <Languages className="h-4 w-4" />
236
+ <Label className="text-xs">Language</Label>
237
+ </div>
238
+ <Select
239
+ value={language}
240
+ onValueChange={(v) => onLanguageChange(v as Language)}
241
+ >
242
+ <SelectTrigger>
243
+ <SelectValue placeholder="Select language" />
244
+ </SelectTrigger>
245
+ <SelectContent>
246
+ {LANGUAGE_OPTIONS.map((o) => (
247
+ <SelectItem key={o.value} value={o.value}>
248
+ {o.label}
249
+ </SelectItem>
250
+ ))}
251
+ </SelectContent>
252
+ </Select>
253
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
+ {/* Optional: course indicator (for course workspaces) */}
256
+ {currentWorkspace?.category === "course" && (
257
+ <div className="space-y-1.5">
258
+ <div className="flex items-center gap-2">
259
+ <GraduationCap className="h-4 w-4" />
260
+ <Label className="text-xs">Course</Label>
261
+ </div>
262
+ <div className="text-xs text-muted-foreground">
263
+ {currentWorkspace.courseName || currentWorkspace.courseInfo?.name || "Course"}
264
+ </div>
265
+ {currentWorkspace.courseInfo?.instructor?.name && (
266
+ <div className="text-xs text-muted-foreground">
267
+ Instructor: {currentWorkspace.courseInfo.instructor.name}
268
+ </div>
269
+ )}
270
+ </div>
271
+ )}
272
 
273
+ {spaceType === "group" && (
 
 
 
 
 
 
274
  <>
275
+ <Separator />
276
+ <div className="space-y-2">
277
+ <div className="flex items-center gap-2">
278
+ <Users className="h-4 w-4" />
279
+ <div className="text-sm font-medium">Members</div>
 
 
 
 
 
280
  </div>
281
+ <div className="space-y-2">
282
+ {groupMembers.map((m) => (
283
+ <div
284
+ key={m.id}
285
+ className="flex items-center justify-between gap-2"
286
+ >
287
+ <div className="min-w-0">
288
+ <div className="text-xs font-medium truncate">
289
+ {m.name}
290
+ {m.isAI ? " (AI)" : ""}
291
+ </div>
292
+ <div className="text-[11px] text-muted-foreground truncate">
293
+ {m.email}
294
+ </div>
295
+ </div>
296
+ </div>
297
+ ))}
298
  </div>
299
  </div>
300
  </>
301
+ )}
302
+ </Card>
303
+ </div>
304
+
305
+ {/* Scrollable content (ONLY this scrolls in LeftSidebar) */}
306
+ <div className="colFlex min-h-0 overflow-hidden">
307
+ <div className="panelScroll flex-1 min-h-0 p-4 space-y-4">
308
+ {/* Saved Items */}
309
+ <Card className="p-3">
310
+ <div className="flex items-center gap-2 mb-2">
311
+ <Bookmark className="h-4 w-4" />
312
+ <div className="text-sm font-medium">Saved</div>
313
+ <div className="ml-auto text-xs text-muted-foreground">
314
+ {workspaceSavedItems.length}
 
 
 
 
 
 
 
 
315
  </div>
316
+ </div>
317
 
318
+ {workspaceSavedItems.length === 0 ? (
319
+ <div className="text-xs text-muted-foreground">
320
+ No saved items in this workspace yet.
 
 
 
 
 
 
 
321
  </div>
322
+ ) : (
323
+ <div className="space-y-2">
324
+ {workspaceSavedItems.map((it) => {
325
+ const highlight = recentlySavedId && recentlySavedId === it.id;
326
+ return (
327
+ <div
328
+ key={it.id}
329
+ className={[
330
+ "rounded-md border border-border p-2",
331
+ highlight ? "ring-2 ring-ring/30" : "",
332
+ ].join(" ")}
333
+ >
334
+ <div className="flex items-start justify-between gap-2">
335
+ <div className="min-w-0">
336
+ <div className="text-xs font-medium truncate">
337
+ {it.title}
338
+ </div>
339
+ <div className="text-[11px] text-muted-foreground">
340
+ {formatWhen(it.timestamp)}
341
+ </div>
342
+ <div className="text-[11px] text-muted-foreground">
343
+ Type: {it.type}
344
+ {it.format ? ` · ${it.format}` : ""}
345
+ </div>
346
+ </div>
347
+
348
+ <Button
349
+ variant="ghost"
350
+ size="icon"
351
+ onClick={() => onUnsave(it.id)}
352
+ title="Remove"
353
+ >
354
+ <Trash2 className="h-4 w-4" />
355
+ </Button>
356
+ </div>
357
+ </div>
358
+ );
359
+ })}
360
  </div>
361
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  </Card>
 
 
 
 
 
 
 
 
363
 
364
+ {/* Saved Chats */}
365
+ <Card className="p-3">
366
+ <div className="flex items-center gap-2 mb-2">
367
+ <MessageSquare className="h-4 w-4" />
368
+ <div className="text-sm font-medium">Saved Chats</div>
369
+ <div className="ml-auto text-xs text-muted-foreground">
370
+ {savedChats.length}
371
+ </div>
372
+ </div>
373
 
 
 
 
 
 
374
  {savedChats.length === 0 ? (
375
+ <div className="text-xs text-muted-foreground">
376
+ No saved chats yet.
 
 
377
  </div>
378
  ) : (
379
  <div className="space-y-2">
380
+ {savedChats.map((c) => {
381
+ const renaming = renameId === c.id;
382
+ return (
383
+ <div
384
+ key={c.id}
385
+ className="rounded-md border border-border p-2"
386
+ >
387
+ <div className="flex items-start justify-between gap-2">
388
+ <div className="min-w-0 flex-1">
389
+ {renaming ? (
390
+ <div className="space-y-2">
391
+ <Input
392
+ value={renameDraft}
393
+ onChange={(e) => setRenameDraft(e.target.value)}
394
+ placeholder="Chat title"
395
+ />
396
+ <div className="flex items-center gap-2">
397
+ <Button
398
+ size="sm"
399
+ onClick={commitRename}
400
+ disabled={!renameDraft.trim()}
401
+ title="Save"
402
+ >
403
+ <Check className="h-4 w-4 mr-1" />
404
+ Save
405
+ </Button>
406
+ <Button
407
+ size="sm"
408
+ variant="secondary"
409
+ onClick={cancelRename}
410
+ title="Cancel"
411
+ >
412
+ <X className="h-4 w-4 mr-1" />
413
+ Cancel
414
+ </Button>
415
+ </div>
416
+ </div>
417
+ ) : (
418
+ <>
419
+ <div className="text-xs font-medium truncate">
420
+ {c.title}
421
+ </div>
422
+ <div className="text-[11px] text-muted-foreground">
423
+ {formatWhen(c.timestamp)}
424
+ </div>
425
+ <div className="text-[11px] text-muted-foreground">
426
+ Mode: {c.chatMode} · Messages: {c.messages.length}
427
+ </div>
428
+ </>
429
+ )}
430
+ </div>
431
+
432
+ {!renaming && (
433
+ <div className="flex items-center gap-1">
434
+ {onRenameSavedChat && (
435
+ <Button
436
+ variant="ghost"
437
+ size="icon"
438
+ onClick={() => beginRename(c)}
439
+ title="Rename"
440
+ >
441
+ <Pencil className="h-4 w-4" />
442
+ </Button>
443
+ )}
444
+ <Button
445
+ variant="ghost"
446
+ size="icon"
447
+ onClick={() => onDeleteSavedChat(c.id)}
448
+ title="Delete"
449
+ >
450
+ <Trash2 className="h-4 w-4" />
451
+ </Button>
452
+ </div>
453
+ )}
454
+ </div>
455
+
456
+ {!renaming && (
457
+ <div className="mt-2">
458
+ <Button
459
+ size="sm"
460
+ className="w-full"
461
+ onClick={() => onLoadChat(c)}
462
+ >
463
+ Open
464
+ </Button>
465
+ </div>
466
+ )}
467
+ </div>
468
+ );
469
+ })}
470
  </div>
471
  )}
472
+ </Card>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
 
474
+ {/* Small workspace debug/indicator (optional, helpful in dev) */}
475
+ <Card className="p-3">
476
+ <div className="text-xs text-muted-foreground">
477
+ Workspace:{" "}
478
+ <span className="text-foreground">
479
+ {currentWorkspace?.name || currentWorkspaceId}
480
+ </span>
481
+ </div>
482
+ </Card>
483
+ </div>
484
+ </div>
485
  </div>
486
  );
487
  }