SarahXia0405 commited on
Commit
92128df
·
verified ·
1 Parent(s): bebf4a3

Update web/src/components/LeftSidebar.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/LeftSidebar.tsx +213 -544
web/src/components/LeftSidebar.tsx CHANGED
@@ -1,573 +1,242 @@
1
  // web/src/components/LeftSidebar.tsx
2
- import React, { useEffect, useState } from "react";
3
- import { Label } from "./ui/label";
4
  import { Button } from "./ui/button";
5
- import { LogIn, Bookmark, Download, Copy } from "lucide-react";
6
- import { Separator } from "./ui/separator";
7
- import { GroupMembers } from "./GroupMembers";
8
- import { Card } from "./ui/card";
9
  import { Input } from "./ui/input";
10
- import type {
11
- LearningMode,
12
- Language,
13
- SpaceType,
14
- GroupMember,
15
- User as UserType,
16
- SavedItem,
17
- SavedChat,
18
- } from "../App";
19
- import { toast } from "sonner";
20
- import { Document, HeadingLevel, Packer, Paragraph, TextRun } from "docx";
21
- import { jsPDF } from "jspdf";
22
- import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog";
23
- import type { CourseInfo } from "../App";
24
-
25
- // ✅ extracted section
26
- import { SavedChatSection } from "./sidebar/SavedChatSection";
27
-
28
- interface LeftSidebarProps {
29
- learningMode: LearningMode;
30
- language: Language;
31
- onLearningModeChange: (mode: LearningMode) => void;
32
- onLanguageChange: (lang: Language) => void;
33
-
34
- spaceType: SpaceType;
35
- groupMembers: GroupMember[];
36
-
37
- user: UserType | null;
38
- onLogin: (user: UserType) => void;
39
- onLogout: () => void;
40
- isLoggedIn: boolean;
41
- onEditProfile: () => void;
42
-
43
- savedItems: SavedItem[];
44
- recentlySavedId: string | null;
45
- onUnsave: (id: string) => void;
46
- onSave: (content: string, type: "export" | "quiz" | "summary") => void;
47
 
 
 
48
  savedChats: SavedChat[];
49
- onLoadChat: (chat: SavedChat) => void;
50
- onDeleteSavedChat: (id: string) => void;
51
- onRenameSavedChat?: (id: string, newTitle: string) => void;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
- currentWorkspaceId: string;
54
- workspaces?: Array<{
55
- id: string;
56
- type: SpaceType;
57
- category?: "course" | "personal";
58
- courseName?: string;
59
- courseInfo?: CourseInfo;
60
- members?: GroupMember[];
61
- isEditable?: boolean;
62
- name?: string;
63
- }>;
64
 
65
- selectedCourse?: string;
66
- courses?: Array<{ id: string; name: string }>;
67
- availableCourses?: CourseInfo[];
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  }
69
 
70
  export function LeftSidebar({
71
- learningMode,
72
- language,
73
- onLearningModeChange,
74
- onLanguageChange,
75
- spaceType,
76
- groupMembers,
77
- user,
78
- onLogin,
79
- onLogout,
80
- isLoggedIn,
81
- onEditProfile,
82
- savedItems,
83
- recentlySavedId,
84
- onUnsave,
85
- onSave,
86
  savedChats,
87
- onLoadChat,
 
88
  onDeleteSavedChat,
89
  onRenameSavedChat,
90
- currentWorkspaceId,
91
- workspaces = [],
92
- selectedCourse,
93
- courses = [],
94
- availableCourses = [],
95
- }: LeftSidebarProps) {
96
- const [showLoginForm, setShowLoginForm] = useState(false);
97
- const [name, setName] = useState("");
98
- const [email, setEmail] = useState("");
99
-
100
- const [selectedItem, setSelectedItem] = useState<SavedItem | null>(null);
101
- const [isDialogOpen, setIsDialogOpen] = useState(false);
102
-
103
- const [isDownloading, setIsDownloading] = useState(false);
104
- const [copied, setCopied] = useState(false);
105
-
106
- const [editableTitle, setEditableTitle] = useState("Untitled");
107
- const [isEditingTitle, setIsEditingTitle] = useState(false);
108
-
109
- const handleLogin = () => {
110
- if (!name.trim() || !email.trim()) {
111
- toast.error("Please fill in all fields");
112
- return;
113
- }
114
- onLogin({ name: name.trim(), email: email.trim() });
115
- setShowLoginForm(false);
116
- setName("");
117
- setEmail("");
118
- toast.success(`Welcome, ${name}!`);
119
- };
120
-
121
- const handleLogout = () => {
122
- onLogout();
123
- setShowLoginForm(false);
124
- toast.success("Logged out successfully");
125
  };
126
 
127
- const downloadBlob = (blob: Blob, filename: string) => {
128
- const url = URL.createObjectURL(blob);
129
- const a = document.createElement("a");
130
- a.href = url;
131
- a.download = filename;
132
- document.body.appendChild(a);
133
- a.click();
134
- a.remove();
135
- URL.revokeObjectURL(url);
136
  };
137
 
138
- const formatDateStamp = (date: Date) => {
139
- const yyyy = date.getFullYear();
140
- const mm = String(date.getMonth() + 1).padStart(2, "0");
141
- const dd = String(date.getDate()).padStart(2, "0");
142
- return `${yyyy}-${mm}-${dd}`;
143
- };
144
 
145
- const getDefaultFilenameBase = (item: SavedItem) => {
146
- const kind = item.type === "export" ? "export" : item.type === "summary" ? "summary" : "quiz";
147
- return `clare-${kind}-${formatDateStamp(item.timestamp)}`;
148
- };
149
-
150
- const handleDownloadMd = async (item: SavedItem) => {
151
- try {
152
- setIsDownloading(true);
153
- toast.message("Preparing .md…");
154
- const blob = new Blob([item.content], { type: "text/markdown;charset=utf-8" });
155
- downloadBlob(blob, `${getDefaultFilenameBase(item)}.md`);
156
- toast.success("Downloaded .md");
157
- } catch (e) {
158
- // eslint-disable-next-line no-console
159
- console.error(e);
160
- toast.error("Failed to download .md");
161
- } finally {
162
- setIsDownloading(false);
163
- }
164
- };
165
-
166
- const handleDownloadDocx = async (item: SavedItem) => {
167
- try {
168
- setIsDownloading(true);
169
- toast.message("Preparing .docx…");
170
- const lines = item.content.split("\n");
171
- const paragraphs: Paragraph[] = lines.map((line) => {
172
- const trimmed = line.trim();
173
- if (!trimmed) return new Paragraph({ text: "" });
174
- if (trimmed.startsWith("### "))
175
- return new Paragraph({
176
- text: trimmed.replace(/^###\s+/, ""),
177
- heading: HeadingLevel.HEADING_3,
178
- });
179
- if (trimmed.startsWith("## "))
180
- return new Paragraph({
181
- text: trimmed.replace(/^##\s+/, ""),
182
- heading: HeadingLevel.HEADING_2,
183
- });
184
- if (trimmed.startsWith("# "))
185
- return new Paragraph({
186
- text: trimmed.replace(/^#\s+/, ""),
187
- heading: HeadingLevel.HEADING_1,
188
- });
189
- return new Paragraph({ children: [new TextRun({ text: line })] });
190
- });
191
-
192
- const doc = new Document({ sections: [{ properties: {}, children: paragraphs }] });
193
- const blob = await Packer.toBlob(doc);
194
- downloadBlob(blob, `${getDefaultFilenameBase(item)}.docx`);
195
- toast.success("Downloaded .docx");
196
- } catch (e) {
197
- // eslint-disable-next-line no-console
198
- console.error(e);
199
- toast.error("Failed to download .docx");
200
- } finally {
201
- setIsDownloading(false);
202
- }
203
- };
204
-
205
- const handleDownloadPdf = async (item: SavedItem) => {
206
- try {
207
- setIsDownloading(true);
208
- toast.message("Preparing .pdf…");
209
- const doc = new jsPDF({ unit: "pt", format: "a4" });
210
- const pageWidth = doc.internal.pageSize.getWidth();
211
- const pageHeight = doc.internal.pageSize.getHeight();
212
- const margin = 40;
213
- const contentWidth = pageWidth - margin * 2;
214
- const lineHeight = 16;
215
-
216
- const lines = doc.splitTextToSize(item.content, contentWidth);
217
- let y = margin;
218
- lines.forEach((line) => {
219
- if (y + lineHeight > pageHeight - margin) {
220
- doc.addPage();
221
- y = margin;
222
- }
223
- doc.text(line, margin, y);
224
- y += lineHeight;
225
- });
226
-
227
- doc.save(`${getDefaultFilenameBase(item)}.pdf`);
228
- toast.success("Downloaded .pdf");
229
- } catch (e) {
230
- // eslint-disable-next-line no-console
231
- console.error(e);
232
- toast.error("Failed to download .pdf");
233
- } finally {
234
- setIsDownloading(false);
235
- }
236
- };
237
-
238
- const handleCopy = async (content: string) => {
239
- await navigator.clipboard.writeText(content);
240
- setCopied(true);
241
- toast.success("Copied to clipboard!");
242
- setTimeout(() => setCopied(false), 2000);
243
- };
244
-
245
- const handleUnsaveItem = (id: string) => onUnsave(id);
246
-
247
- const isItemSaved = selectedItem
248
- ? savedItems.some((item) => {
249
- if (selectedItem.id && item.id === selectedItem.id) return true;
250
- return item.content === selectedItem.content && item.type === selectedItem.type;
251
- })
252
- : false;
253
-
254
- useEffect(() => {
255
- if (selectedItem && isDialogOpen) {
256
- const updatedItem = savedItems.find(
257
- (item) => item.content === selectedItem.content && item.type === selectedItem.type
258
- );
259
- if (updatedItem && updatedItem.id !== selectedItem.id) setSelectedItem(updatedItem);
260
- }
261
- // eslint-disable-next-line react-hooks/exhaustive-deps
262
- }, [savedItems, isDialogOpen]);
263
-
264
- const handleToggleSave = () => {
265
- if (!selectedItem) return;
266
-
267
- if (isItemSaved) {
268
- const itemToUnsave = savedItems.find(
269
- (item) =>
270
- (selectedItem.id && item.id === selectedItem.id) ||
271
- (item.content === selectedItem.content && item.type === selectedItem.type)
272
- );
273
- if (itemToUnsave) handleUnsaveItem(itemToUnsave.id);
274
- } else {
275
- onSave(selectedItem.content, selectedItem.type);
276
- }
277
  };
278
 
279
- const defaultCourses = [
280
- { id: "course1", name: "Introduction to AI" },
281
- { id: "course2", name: "Machine Learning" },
282
- { id: "course3", name: "Data Structures" },
283
- { id: "course4", name: "Web Development" },
284
- ];
285
- const coursesList = courses.length > 0 ? courses : defaultCourses;
286
-
287
- const currentWorkspace = workspaces?.find((w) => w.id === currentWorkspaceId);
288
-
289
- const getCourseDisplayInfo = () => {
290
- if (!currentWorkspace) return null;
291
-
292
- if (currentWorkspace.type === "group" && currentWorkspace.category === "course") {
293
- if (currentWorkspace.courseInfo) {
294
- return {
295
- type: "course" as const,
296
- name: currentWorkspace.courseInfo.name,
297
- instructor: currentWorkspace.courseInfo.instructor,
298
- teachingAssistant: currentWorkspace.courseInfo.teachingAssistant,
299
- };
300
- }
301
- return {
302
- type: "course" as const,
303
- name: currentWorkspace.courseName || "Unknown Course",
304
- instructor: { name: "Unknown", email: "" },
305
- teachingAssistant: { name: "Unknown", email: "" },
306
- };
307
- }
308
-
309
- if (currentWorkspace.type === "group" && currentWorkspace.category === "personal") {
310
- return {
311
- type: "personal" as const,
312
- name: editableTitle,
313
- members: currentWorkspace.members || [],
314
- };
315
- }
316
-
317
- if (currentWorkspace.type === "individual") {
318
- const saved = selectedCourse || localStorage.getItem("myspace_selected_course") || "course1";
319
- const courseInfo = availableCourses?.find((c) => c.id === saved);
320
- if (courseInfo) {
321
- return {
322
- type: "course" as const,
323
- name: courseInfo.name,
324
- instructor: courseInfo.instructor,
325
- teachingAssistant: courseInfo.teachingAssistant,
326
- };
327
- }
328
- const course = coursesList.find((c) => c.id === saved);
329
- return {
330
- type: "course" as const,
331
- name: course?.name || saved,
332
- instructor: { name: "Unknown", email: "" },
333
- teachingAssistant: { name: "Unknown", email: "" },
334
- };
335
- }
336
-
337
- return null;
338
- };
339
-
340
- const courseDisplayInfo = getCourseDisplayInfo();
341
-
342
  return (
343
- // fills parent; NO outer scroll
344
- <div className="h-full w-full min-h-0 flex flex-col overflow-hidden">
345
- {/* ========== FIXED TOP (no scroll) ========== */}
346
- {isLoggedIn && courseDisplayInfo && (
347
- <div className="p-4 border-b border-border flex-shrink-0">
348
- {courseDisplayInfo.type === "course" ? (
349
- <>
350
- <h3 className="text-base font-semibold mb-4">{courseDisplayInfo.name}</h3>
351
- <div className="space-y-2 text-sm">
352
- <div>
353
- <span className="text-muted-foreground">Instructor: </span>
354
- <a href={`mailto:${courseDisplayInfo.instructor.email}`} className="text-primary hover:underline">
355
- {courseDisplayInfo.instructor.name}
356
- </a>
357
- </div>
358
- <div>
359
- <span className="text-muted-foreground">TA: </span>
360
- <a
361
- href={`mailto:${courseDisplayInfo.teachingAssistant.email}`}
362
- className="text-primary hover:underline"
363
- >
364
- {courseDisplayInfo.teachingAssistant.name}
365
- </a>
366
- </div>
367
- </div>
368
- </>
369
- ) : (
370
- <>
371
- <div className="mb-4">
372
- {isEditingTitle ? (
373
- <Input
374
- value={editableTitle}
375
- onChange={(e) => setEditableTitle(e.target.value)}
376
- onBlur={() => setIsEditingTitle(false)}
377
- onKeyDown={(e) => {
378
- if (e.key === "Enter") setIsEditingTitle(false);
379
- }}
380
- autoFocus
381
- className="text-base font-semibold"
382
- />
383
- ) : (
384
- <h3
385
- className="text-base font-semibold cursor-pointer hover:text-primary"
386
- onClick={() => setIsEditingTitle(true)}
387
- >
388
- {editableTitle}
389
- </h3>
390
- )}
391
- </div>
392
-
393
- <div className="space-y-2 text-sm">
394
- <div className="text-muted-foreground mb-2">Members:</div>
395
- {courseDisplayInfo.members.map((member: any, idx: number) => (
396
- <div key={member.id}>
397
- <span className="text-muted-foreground">{idx === 0 ? "Creator: " : "Member: "}</span>
398
- <a href={`mailto:${member.email}`} className="text-primary hover:underline">
399
- {member.name}
400
- </a>
401
- </div>
402
- ))}
403
- </div>
404
- </>
405
- )}
406
- </div>
407
- )}
408
-
409
- {!isLoggedIn && (
410
- <div className="p-4 border-b border-border flex-shrink-0">
411
- <h3 className="text-base font-medium mb-4">Login</h3>
412
- <Card className="p-4">
413
- <div className="space-y-4">
414
- <div className="flex flex-col items-center py-4">
415
- <img
416
- 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"
417
- alt="Student studying"
418
- className="w-20 h-20 rounded-full object-cover mb-4"
419
- />
420
- <h3 className="mb-2">Welcome to Clare!</h3>
421
- <p className="text-sm text-muted-foreground text-center mb-4">
422
- Log in to start your personalized learning journey
423
- </p>
424
- </div>
425
-
426
- {!showLoginForm ? (
427
- <Button onClick={() => setShowLoginForm(true)} className="w-full gap-2">
428
- <LogIn className="h-4 w-4" />
429
- Student Login
430
- </Button>
431
- ) : (
432
- <div className="space-y-3">
433
- <div className="space-y-2">
434
- <Label htmlFor="name">Name</Label>
435
- <Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Enter your name" />
436
- </div>
437
- <div className="space-y-2">
438
- <Label htmlFor="email">Email / Student ID</Label>
439
- <Input
440
- id="email"
441
- type="email"
442
- value={email}
443
- onChange={(e) => setEmail(e.target.value)}
444
- placeholder="Enter your email or ID"
445
- />
446
- </div>
447
- <div className="flex gap-2">
448
- <Button onClick={handleLogin} className="flex-1">
449
- Enter
450
- </Button>
451
- <Button variant="outline" onClick={() => setShowLoginForm(false)}>
452
- Cancel
453
- </Button>
454
- </div>
455
- </div>
456
- )}
457
- </div>
458
- </Card>
459
- </div>
460
- )}
461
-
462
- {spaceType === "group" && (
463
- <div className="p-4 border-b border-border flex-shrink-0">
464
- <GroupMembers members={groupMembers} />
465
- </div>
466
- )}
467
-
468
- {/* ========== ONLY SCROLL AREA (Saved Chats) ========== */}
469
- <div
470
- className="flex-1 min-h-0 overflow-y-auto overscroll-contain"
471
- style={{ overscrollBehavior: "contain" }}
472
- >
473
- {/* IMPORTANT: keep SavedChatSection as a normal block; do NOT nest another overflow-y-auto inside it */}
474
- <SavedChatSection
475
- isLoggedIn={isLoggedIn}
476
- savedChats={savedChats}
477
- onLoadChat={onLoadChat}
478
- onDeleteSavedChat={onDeleteSavedChat}
479
- onRenameSavedChat={onRenameSavedChat}
480
- />
481
  </div>
482
 
483
- {/* Saved Item Dialog (unchanged) */}
484
- <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
485
- <DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
486
- <DialogHeader>
487
- <DialogTitle>{selectedItem?.title}</DialogTitle>
488
- <DialogDescription>
489
- {selectedItem?.type === "export" ? "Export" : selectedItem?.type === "quiz" ? "Quiz" : "Summary"} •{" "}
490
- {selectedItem?.timestamp.toLocaleDateString()}
491
- </DialogDescription>
492
- </DialogHeader>
493
-
494
- <div className="flex flex-col flex-1 min-h-0 space-y-4 mt-4">
495
- {selectedItem && (
496
- <>
497
- <div className="flex items-center justify-end gap-2 flex-shrink-0">
498
- <Button
499
- variant="outline"
500
- size="sm"
501
- disabled={isDownloading}
502
- onClick={() => handleDownloadMd(selectedItem)}
503
- title="Download as .md"
504
- className="h-7 px-2 text-xs gap-1.5"
505
- >
506
- <Download className="h-3 w-3" />
507
- .md
508
- </Button>
509
 
510
- <Button
511
- variant="outline"
512
- size="sm"
513
- disabled={isDownloading}
514
- onClick={() => handleDownloadDocx(selectedItem)}
515
- title="Download as .docx"
516
- className="h-7 px-2 text-xs gap-1.5"
517
- >
518
- <Download className="h-3 w-3" />
519
- .docx
520
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
 
522
- {selectedItem.format === "pdf" && (
523
- <Button
524
- variant="outline"
525
- size="sm"
526
- disabled={isDownloading}
527
- onClick={() => handleDownloadPdf(selectedItem)}
528
- title="Download as .pdf"
529
- className="h-7 px-2 text-xs gap-1.5"
530
- >
531
- <Download className="h-3 w-3" />
532
- .pdf
533
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
  )}
535
-
536
- <Button
537
- variant="outline"
538
- size="sm"
539
- onClick={() => handleCopy(selectedItem.content)}
540
- disabled={isDownloading}
541
- className="h-7 px-2 text-xs gap-1.5"
542
- title="Copy"
543
- >
544
- <Copy className={`h-3 w-3 ${copied ? "text-green-600" : ""}`} />
545
- </Button>
546
-
547
- <Button
548
- variant="outline"
549
- size="sm"
550
- onClick={handleToggleSave}
551
- disabled={isDownloading}
552
- className={`h-7 px-2 text-xs gap-1.5 ${
553
- isItemSaved ? "bg-red-50 dark:bg-red-950/20 border-red-300 dark:border-red-800" : ""
554
- }`}
555
- title={isItemSaved ? "Unsave" : "Save for later"}
556
- >
557
- <Bookmark className={`h-3 w-3 ${isItemSaved ? "fill-red-600 text-red-600" : ""}`} />
558
- </Button>
559
  </div>
560
-
561
- <Separator className="flex-shrink-0" />
562
-
563
- <div className="text-sm whitespace-pre-wrap text-foreground overflow-y-auto flex-1 min-h-0">
564
- {selectedItem.content}
565
- </div>
566
- </>
567
- )}
568
- </div>
569
- </DialogContent>
570
- </Dialog>
571
- </div>
572
  );
573
  }
 
1
  // web/src/components/LeftSidebar.tsx
2
+ import React, { useMemo, useState } from "react";
 
3
  import { Button } from "./ui/button";
 
 
 
 
4
  import { Input } from "./ui/input";
5
+ import { Separator } from "./ui/separator";
6
+ import { Bookmark, Trash2, Edit2, Check, X as XIcon } from "lucide-react";
7
+ import type { SavedChat } from "../App";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
+ type Props = {
10
+ // existing
11
  savedChats: SavedChat[];
12
+ currentSavedChatId?: string | null;
13
+
14
+ onLoadSavedChat?: (chatId: string) => void;
15
+ onDeleteSavedChat?: (chatId: string) => void;
16
+
17
+ // ✅ NEW
18
+ onRenameSavedChat?: (chatId: string, title: string) => void;
19
+
20
+ // optional: you may already have these
21
+ className?: string;
22
+ };
23
+
24
+ function formatSavedChatTitle(chat: SavedChat) {
25
+ // ✅ preferred user-defined title
26
+ const anyChat = chat as any;
27
+ const t = (anyChat?.title ?? anyChat?.name ?? "").toString().trim();
28
+ if (t) return t;
29
+
30
+ // fallback: keep your old naming style
31
+ const mode = (chat as any)?.chatMode ? String((chat as any).chatMode) : "Ask";
32
+ const ts =
33
+ (chat as any)?.savedAt ||
34
+ (chat as any)?.timestamp ||
35
+ (chat as any)?.createdAt ||
36
+ (chat as any)?.time ||
37
+ null;
38
+
39
+ // attempt to print a nice date if it exists
40
+ try {
41
+ if (ts) {
42
+ const d = typeof ts === "string" || typeof ts === "number" ? new Date(ts) : ts;
43
+ const dateStr = d.toLocaleDateString();
44
+ return `Chat - ${mode} - ${dateStr}`;
45
+ }
46
+ } catch {
47
+ // ignore
48
+ }
49
 
50
+ return `Chat - ${mode}`;
51
+ }
 
 
 
 
 
 
 
 
 
52
 
53
+ function formatSavedChatSub(chat: SavedChat) {
54
+ const ts =
55
+ (chat as any)?.savedAt ||
56
+ (chat as any)?.timestamp ||
57
+ (chat as any)?.createdAt ||
58
+ (chat as any)?.time ||
59
+ null;
60
+
61
+ if (!ts) return "";
62
+
63
+ try {
64
+ const d = typeof ts === "string" || typeof ts === "number" ? new Date(ts) : ts;
65
+ return d.toLocaleString();
66
+ } catch {
67
+ return String(ts);
68
+ }
69
  }
70
 
71
  export function LeftSidebar({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  savedChats,
73
+ currentSavedChatId,
74
+ onLoadSavedChat,
75
  onDeleteSavedChat,
76
  onRenameSavedChat,
77
+ className,
78
+ }: Props) {
79
+ // rename state
80
+ const [editingId, setEditingId] = useState<string | null>(null);
81
+ const [draftTitle, setDraftTitle] = useState<string>("");
82
+
83
+ const sortedChats = useMemo(() => {
84
+ // newest first
85
+ return [...savedChats].sort((a: any, b: any) => {
86
+ const ta = new Date(a.savedAt || a.timestamp || a.createdAt || 0).getTime();
87
+ const tb = new Date(b.savedAt || b.timestamp || b.createdAt || 0).getTime();
88
+ return tb - ta;
89
+ });
90
+ }, [savedChats]);
91
+
92
+ const startRename = (chat: SavedChat) => {
93
+ const id = (chat as any).id as string;
94
+ setEditingId(id);
95
+
96
+ const anyChat = chat as any;
97
+ const existing = (anyChat?.title ?? anyChat?.name ?? "").toString();
98
+ setDraftTitle(existing || formatSavedChatTitle(chat));
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  };
100
 
101
+ const cancelRename = () => {
102
+ setEditingId(null);
103
+ setDraftTitle("");
 
 
 
 
 
 
104
  };
105
 
106
+ const commitRename = (chatId: string) => {
107
+ const next = draftTitle.trim();
108
+ if (!next) return;
 
 
 
109
 
110
+ onRenameSavedChat?.(chatId, next);
111
+ setEditingId(null);
112
+ setDraftTitle("");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  };
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  return (
116
+ <aside className={className ?? "h-full w-full"}>
117
+ <div className="px-4 pt-4 pb-3 flex items-center gap-2">
118
+ <Bookmark className="h-4 w-4" />
119
+ <h3 className="font-semibold">Saved Chat</h3>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  </div>
121
 
122
+ <div className="px-4">
123
+ <Separator />
124
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
+ <div className="px-4 py-3 space-y-3">
127
+ {sortedChats.length === 0 ? (
128
+ <div className="text-sm text-muted-foreground">No saved chats yet.</div>
129
+ ) : (
130
+ sortedChats.map((chat) => {
131
+ const id = (chat as any).id as string;
132
+ const title = formatSavedChatTitle(chat);
133
+ const sub = formatSavedChatSub(chat);
134
+ const isActive = currentSavedChatId ? currentSavedChatId === id : false;
135
+ const isEditing = editingId === id;
136
+
137
+ return (
138
+ <div
139
+ key={id}
140
+ className={`rounded-xl border bg-card px-4 py-3 ${
141
+ isActive ? "border-primary/40" : "border-border"
142
+ }`}
143
+ >
144
+ <div className="flex items-start justify-between gap-3">
145
+ {/* left: title + time */}
146
+ <div className="min-w-0 flex-1">
147
+ {isEditing ? (
148
+ <div className="space-y-2">
149
+ <Input
150
+ value={draftTitle}
151
+ onChange={(e) => setDraftTitle(e.target.value)}
152
+ className="h-9"
153
+ autoFocus
154
+ onKeyDown={(e) => {
155
+ if (e.key === "Enter") {
156
+ e.preventDefault();
157
+ commitRename(id);
158
+ }
159
+ if (e.key === "Escape") {
160
+ e.preventDefault();
161
+ cancelRename();
162
+ }
163
+ }}
164
+ />
165
+ <div className="flex items-center gap-2">
166
+ <Button
167
+ size="sm"
168
+ className="h-8 px-2"
169
+ onClick={() => commitRename(id)}
170
+ disabled={!draftTitle.trim()}
171
+ title="Save"
172
+ >
173
+ <Check className="h-4 w-4" />
174
+ </Button>
175
+ <Button
176
+ size="sm"
177
+ variant="outline"
178
+ className="h-8 px-2"
179
+ onClick={cancelRename}
180
+ title="Cancel"
181
+ >
182
+ <XIcon className="h-4 w-4" />
183
+ </Button>
184
+ </div>
185
+ </div>
186
+ ) : (
187
+ <>
188
+ <button
189
+ type="button"
190
+ className="text-left w-full"
191
+ onClick={() => onLoadSavedChat?.(id)}
192
+ title="Open saved chat"
193
+ >
194
+ <div className="font-semibold truncate">{title}</div>
195
+ {sub ? <div className="text-xs text-muted-foreground mt-1">{sub}</div> : null}
196
+ </button>
197
+ </>
198
+ )}
199
+ </div>
200
 
201
+ {/* right: edit + delete */}
202
+ {!isEditing && (
203
+ <div className="flex items-center gap-2 flex-shrink-0">
204
+ {/* ✅ NEW: rename pen icon */}
205
+ <Button
206
+ variant="ghost"
207
+ size="icon"
208
+ className="h-8 w-8"
209
+ onClick={(e) => {
210
+ e.preventDefault();
211
+ e.stopPropagation();
212
+ startRename(chat);
213
+ }}
214
+ title="Rename"
215
+ >
216
+ <Edit2 className="h-4 w-4" />
217
+ </Button>
218
+
219
+ <Button
220
+ variant="ghost"
221
+ size="icon"
222
+ className="h-8 w-8 text-muted-foreground hover:text-destructive"
223
+ onClick={(e) => {
224
+ e.preventDefault();
225
+ e.stopPropagation();
226
+ onDeleteSavedChat?.(id);
227
+ }}
228
+ title="Delete"
229
+ >
230
+ <Trash2 className="h-4 w-4" />
231
+ </Button>
232
+ </div>
233
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  </div>
235
+ </div>
236
+ );
237
+ })
238
+ )}
239
+ </div>
240
+ </aside>
 
 
 
 
 
 
241
  );
242
  }