SarahXia0405 commited on
Commit
25fcd9d
·
verified ·
1 Parent(s): 943edcf

Update web/src/components/LeftSidebar.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/LeftSidebar.tsx +748 -407
web/src/components/LeftSidebar.tsx CHANGED
@@ -1,477 +1,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
- } = props;
126
-
127
- const currentWorkspace = useMemo(
128
- () => workspaces.find((w) => w.id === currentWorkspaceId),
129
- [workspaces, currentWorkspaceId]
130
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
- const workspaceSavedItems = useMemo(
133
- () => savedItems.filter((x) => x.workspaceId === currentWorkspaceId),
134
- [savedItems, currentWorkspaceId]
135
- );
 
136
 
137
- const [renameId, setRenameId] = useState<string | null>(null);
138
- const [renameDraft, setRenameDraft] = useState<string>("");
 
 
 
 
 
 
 
 
139
 
140
- const beginRename = (chat: SavedChat) => {
141
- setRenameId(chat.id);
142
- setRenameDraft(chat.title);
 
 
143
  };
144
 
145
- const commitRename = () => {
146
- if (!renameId) return;
147
- const t = renameDraft.trim();
148
- if (!t) return;
149
- onRenameSavedChat?.(renameId, t);
150
- setRenameId(null);
151
- setRenameDraft("");
152
  };
153
 
154
- const cancelRename = () => {
155
- setRenameId(null);
156
- setRenameDraft("");
 
 
 
 
 
 
 
 
 
 
 
157
  };
158
 
159
- return (
160
- <div className="flex flex-col min-h-0 h-full overflow-hidden">
161
- {/* ===== Top fixed area ===== */}
162
- <div className="flex-shrink-0 p-4 space-y-3">
163
- <Card className="p-3">
164
- <div className="flex items-center justify-between gap-2">
165
- <div className="flex items-center gap-2 min-w-0">
166
- <div className="h-9 w-9 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
167
- <UserIcon className="h-4 w-4" />
168
- </div>
169
- <div className="min-w-0">
170
- <div className="text-sm font-medium truncate">
171
- {user?.name || "User"}
172
- </div>
173
- <div className="text-xs text-muted-foreground truncate">
174
- {user?.email || ""}
175
- </div>
176
- </div>
177
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
 
179
- <div className="flex items-center gap-1">
180
- {onEditProfile && (
181
- <Button
182
- variant="ghost"
183
- size="icon"
184
- onClick={onEditProfile}
185
- title="Edit profile"
186
- >
187
- <Pencil className="h-4 w-4" />
188
- </Button>
189
- )}
190
- {isLoggedIn && (
191
- <Button
192
- variant="ghost"
193
- size="icon"
194
- onClick={onLogout}
195
- title="Logout"
196
- >
197
- <LogOut className="h-4 w-4" />
198
- </Button>
199
- )}
200
- </div>
201
- </div>
202
- </Card>
 
 
 
 
 
 
 
 
203
 
204
- <Card className="p-3 space-y-3">
205
- <div className="flex items-center gap-2">
206
- <BookOpen className="h-4 w-4" />
207
- <div className="text-sm font-medium">Learning</div>
208
- </div>
 
209
 
210
- <div className="space-y-1.5">
211
- <Label className="text-xs">Learning Mode</Label>
212
- <Select
213
- value={learningMode}
214
- onValueChange={(v) => onLearningModeChange(v as LearningMode)}
215
- >
216
- <SelectTrigger>
217
- <SelectValue placeholder="Select mode" />
218
- </SelectTrigger>
219
- <SelectContent>
220
- {LEARNING_MODE_OPTIONS.map((o) => (
221
- <SelectItem key={o.value} value={o.value}>
222
- {o.label}
223
- </SelectItem>
224
- ))}
225
- </SelectContent>
226
- </Select>
227
- </div>
228
 
229
- <div className="space-y-1.5">
230
- <div className="flex items-center gap-2">
231
- <Languages className="h-4 w-4" />
232
- <Label className="text-xs">Language</Label>
233
- </div>
234
- <Select
235
- value={language}
236
- onValueChange={(v) => onLanguageChange(v as Language)}
237
- >
238
- <SelectTrigger>
239
- <SelectValue placeholder="Select language" />
240
- </SelectTrigger>
241
- <SelectContent>
242
- {LANGUAGE_OPTIONS.map((o) => (
243
- <SelectItem key={o.value} value={o.value}>
244
- {o.label}
245
- </SelectItem>
246
- ))}
247
- </SelectContent>
248
- </Select>
249
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
- {currentWorkspace?.category === "course" && (
 
 
 
 
 
 
252
  <>
253
- <Separator />
254
- <div className="space-y-1.5">
255
- <div className="flex items-center gap-2">
256
- <GraduationCap className="h-4 w-4" />
257
- <Label className="text-xs">Course</Label>
 
 
 
 
 
258
  </div>
259
- <div className="text-xs text-muted-foreground">
260
- {currentWorkspace.courseName ||
261
- currentWorkspace.courseInfo?.name ||
262
- "Course"}
 
 
 
 
263
  </div>
264
- {currentWorkspace.courseInfo?.instructor?.name && (
265
- <div className="text-xs text-muted-foreground">
266
- Instructor: {currentWorkspace.courseInfo.instructor.name}
267
- </div>
268
- )}
269
  </div>
270
  </>
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 key={m.id} className="min-w-0">
284
- <div className="text-xs font-medium truncate">
285
- {m.name}
286
- {m.isAI ? " (AI)" : ""}
287
- </div>
288
- <div className="text-[11px] text-muted-foreground truncate">
289
- {m.email}
290
- </div>
291
- </div>
292
- ))}
293
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  </div>
295
  </>
296
  )}
297
- </Card>
298
- </div>
299
-
300
- {/* ===== Scroll area ===== */}
301
- <div className="flex-1 min-h-0 overflow-hidden">
302
- <div className="panelScroll h-full min-h-0 p-4 space-y-4">
303
- {/* Saved Items */}
304
- <Card className="p-3">
305
- <div className="flex items-center gap-2 mb-2">
306
- <Bookmark className="h-4 w-4" />
307
- <div className="text-sm font-medium">Saved</div>
308
- <div className="ml-auto text-xs text-muted-foreground">
309
- {workspaceSavedItems.length}
 
 
 
 
 
310
  </div>
311
- </div>
312
 
313
- {workspaceSavedItems.length === 0 ? (
314
- <div className="text-xs text-muted-foreground">
315
- No saved items in this workspace yet.
316
- </div>
317
- ) : (
318
- <div className="space-y-2">
319
- {workspaceSavedItems.map((it) => {
320
- const highlight = recentlySavedId === it.id;
321
- return (
322
- <div
323
- key={it.id}
324
- className={[
325
- "rounded-md border border-border p-2",
326
- highlight ? "ring-2 ring-ring/30" : "",
327
- ].join(" ")}
328
- >
329
- <div className="flex items-start justify-between gap-2">
330
- <div className="min-w-0">
331
- <div className="text-xs font-medium truncate">
332
- {it.title}
333
- </div>
334
- <div className="text-[11px] text-muted-foreground">
335
- {formatWhen(it.timestamp)}
336
- </div>
337
- <div className="text-[11px] text-muted-foreground">
338
- Type: {it.type}
339
- {it.format ? ` · ${it.format}` : ""}
340
- </div>
341
- </div>
342
-
343
- <Button
344
- variant="ghost"
345
- size="icon"
346
- onClick={() => onUnsave(it.id)}
347
- title="Remove"
348
- >
349
- <Trash2 className="h-4 w-4" />
350
- </Button>
351
- </div>
352
- </div>
353
- );
354
- })}
355
- </div>
356
- )}
357
  </Card>
 
 
358
 
359
- {/* Saved Chats */}
360
- <Card className="p-3">
361
- <div className="flex items-center gap-2 mb-2">
362
- <MessageSquare className="h-4 w-4" />
363
- <div className="text-sm font-medium">Saved Chats</div>
364
- <div className="ml-auto text-xs text-muted-foreground">
365
- {savedChats.length}
366
- </div>
367
- </div>
 
 
 
368
 
 
 
 
 
 
369
  {savedChats.length === 0 ? (
370
- <div className="text-xs text-muted-foreground">
371
- No saved chats yet.
 
 
372
  </div>
373
  ) : (
374
  <div className="space-y-2">
375
- {savedChats.map((c) => {
376
- const renaming = renameId === c.id;
377
-
378
- return (
379
- <div
380
- key={c.id}
381
- className="rounded-md border border-border p-2"
382
- >
383
- <div className="flex items-start justify-between gap-2">
384
- <div className="min-w-0 flex-1">
385
- {renaming ? (
386
- <div className="space-y-2">
387
- <Input
388
- value={renameDraft}
389
- onChange={(e) => setRenameDraft(e.target.value)}
390
- placeholder="Chat title"
391
- />
392
- <div className="flex items-center gap-2">
393
- <Button
394
- size="sm"
395
- onClick={commitRename}
396
- disabled={!renameDraft.trim()}
397
- >
398
- <Check className="h-4 w-4 mr-1" />
399
- Save
400
- </Button>
401
- <Button
402
- size="sm"
403
- variant="secondary"
404
- onClick={cancelRename}
405
- >
406
- <X className="h-4 w-4 mr-1" />
407
- Cancel
408
- </Button>
409
- </div>
410
- </div>
411
- ) : (
412
- <>
413
- <div className="text-xs font-medium truncate">
414
- {c.title}
415
- </div>
416
- <div className="text-[11px] text-muted-foreground">
417
- {formatWhen(c.timestamp)}
418
- </div>
419
- <div className="text-[11px] text-muted-foreground">
420
- Mode: {c.chatMode} · Messages: {c.messages.length}
421
- </div>
422
- </>
423
- )}
424
- </div>
425
-
426
- {!renaming && (
427
- <div className="flex items-center gap-1">
428
- {onRenameSavedChat && (
429
- <Button
430
- variant="ghost"
431
- size="icon"
432
- onClick={() => beginRename(c)}
433
- title="Rename"
434
- >
435
- <Pencil className="h-4 w-4" />
436
- </Button>
437
- )}
438
- <Button
439
- variant="ghost"
440
- size="icon"
441
- onClick={() => onDeleteSavedChat(c.id)}
442
- title="Delete"
443
- >
444
- <Trash2 className="h-4 w-4" />
445
- </Button>
446
- </div>
447
- )}
448
- </div>
449
-
450
- {!renaming && (
451
- <div className="mt-2">
452
- <Button size="sm" className="w-full" onClick={() => onLoadChat(c)}>
453
- Open
454
- </Button>
455
- </div>
456
- )}
457
- </div>
458
- );
459
- })}
460
  </div>
461
  )}
462
- </Card>
463
-
464
- {/* Debug (optional but useful) */}
465
- <Card className="p-3">
466
- <div className="text-xs text-muted-foreground">
467
- Workspace:{" "}
468
- <span className="text-foreground">
469
- {currentWorkspace?.name || currentWorkspaceId}
470
- </span>
471
- </div>
472
- </Card>
473
  </div>
474
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  </div>
476
  );
477
  }
 
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
  }