SarahXia0405 commited on
Commit
846285c
·
verified ·
1 Parent(s): c4c4e16

Update web/src/components/LeftSidebar.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/LeftSidebar.tsx +91 -273
web/src/components/LeftSidebar.tsx CHANGED
@@ -1,61 +1,23 @@
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, Copy } from "lucide-react";
7
- import { toast } from "sonner";
8
 
9
  import type {
10
- LearningMode,
11
- Language,
12
- SpaceType,
13
- GroupMember,
14
- User as UserType,
15
- SavedItem,
16
  SavedChat,
17
  Workspace,
18
  CourseInfo,
19
  } from "../App";
20
 
21
  type Props = {
22
- // ===== App.tsx currently passes these =====
23
- learningMode: LearningMode;
24
- language: Language;
25
- onLearningModeChange: (mode: LearningMode) => void;
26
- onLanguageChange: (lang: Language) => void;
27
-
28
- spaceType: SpaceType;
29
- groupMembers: GroupMember[];
30
-
31
- user: UserType | null;
32
- onLogin: (u: UserType | null) => void;
33
- onLogout: () => void;
34
- isLoggedIn: boolean;
35
-
36
- onEditProfile: () => void;
37
-
38
- savedItems: SavedItem[];
39
- recentlySavedId: string | null;
40
- onUnsave: (id: string) => void;
41
- onSave: (
42
- content: string,
43
- type: "export" | "quiz" | "summary",
44
- saveAsChat?: boolean,
45
- format?: "pdf" | "text",
46
- workspaceId?: string
47
- ) => void;
48
-
49
  savedChats: SavedChat[];
50
  onLoadChat: (chat: SavedChat) => void;
51
  onDeleteSavedChat: (id: string) => void;
52
-
53
  onRenameSavedChat: (id: string, newTitle: string) => void;
54
 
55
  currentWorkspaceId: string;
56
  workspaces: Workspace[];
57
-
58
- selectedCourse: string;
59
  availableCourses: CourseInfo[];
60
  };
61
 
@@ -65,173 +27,34 @@ function formatSub(ts: any) {
65
  const d = typeof ts === "string" || typeof ts === "number" ? new Date(ts) : ts;
66
  return d.toLocaleString();
67
  } catch {
68
- return String(ts);
69
- }
70
- }
71
-
72
- function buildMailto(email: string, subject: string, body?: string) {
73
- const s = encodeURIComponent(subject);
74
- const b = body ? `&body=${encodeURIComponent(body)}` : "";
75
- // IMPORTANT: do not encode the whole string, only params
76
- return `mailto:${email}?subject=${s}${b}`;
77
- }
78
-
79
- async function copyToClipboard(text: string) {
80
- try {
81
- await navigator.clipboard.writeText(text);
82
- toast.success("Email copied");
83
- } catch {
84
- toast.error("Copy failed");
85
  }
86
  }
87
 
88
- function CourseInfoHeader({
89
- course,
90
- }: {
91
- course: CourseInfo;
92
- }) {
93
- const instructorMailto = buildMailto(
94
- course.instructor.email,
95
- `[Clare] Question about ${course.name}`,
96
- `Hi ${course.instructor.name},\n\nI have a question about ${course.name}:\n\n(Write your question here)\n\nThanks,\n`
97
- );
98
-
99
- const taMailto = buildMailto(
100
- course.teachingAssistant.email,
101
- `[Clare] Help request for ${course.name}`,
102
- `Hi ${course.teachingAssistant.name},\n\nI need help with ${course.name}:\n\n(Write your question here)\n\nThanks,\n`
103
- );
104
-
105
- // Use <a href="mailto:..."> (works best), and also force navigation via location as fallback.
106
- const openMail = (mailto: string) => {
107
- window.location.href = mailto;
108
- };
109
-
110
- const Row = ({
111
- label,
112
- name,
113
- email,
114
- mailto,
115
- }: {
116
- label: string;
117
- name: string;
118
- email: string;
119
- mailto: string;
120
- }) => {
121
- return (
122
- <div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
123
- <div className="min-w-0">
124
- <span>{label}: </span>
125
-
126
- {/* Primary: anchor mailto (should open default mail app) */}
127
- <a
128
- href={mailto}
129
- className="text-primary hover:underline"
130
- title={`Email ${name}`}
131
- onClick={(e) => {
132
- // Some browsers block navigation if event is prevented; we do NOT prevent default.
133
- // We add an explicit location jump as a fallback.
134
- // Note: no preventDefault.
135
- openMail(mailto);
136
- }}
137
- >
138
- {name}
139
- </a>
140
- </div>
141
-
142
- {/* Secondary: copy email (always works) */}
143
- <Button
144
- type="button"
145
- variant="ghost"
146
- size="icon"
147
- className="h-7 w-7 flex-shrink-0"
148
- title="Copy email"
149
- onClick={(e) => {
150
- e.stopPropagation();
151
- copyToClipboard(email);
152
- }}
153
- >
154
- <Copy className="h-3.5 w-3.5" />
155
- </Button>
156
- </div>
157
- );
158
- };
159
-
160
- return (
161
- <div className="px-4 pt-4 pb-3 flex-shrink-0">
162
- <div className="space-y-2">
163
- <div className="text-base font-semibold text-foreground truncate">{course.name}</div>
164
-
165
- <Row
166
- label="Instructor"
167
- name={course.instructor.name}
168
- email={course.instructor.email}
169
- mailto={instructorMailto}
170
- />
171
-
172
- <Row
173
- label="TA"
174
- name={course.teachingAssistant.name}
175
- email={course.teachingAssistant.email}
176
- mailto={taMailto}
177
- />
178
- </div>
179
- </div>
180
- );
181
- }
182
-
183
- export function LeftSidebar(props: Props) {
184
- const {
185
- savedChats,
186
- onLoadChat,
187
- onDeleteSavedChat,
188
- onRenameSavedChat,
189
- currentWorkspaceId,
190
- workspaces,
191
- selectedCourse,
192
- availableCourses,
193
- } = props;
194
-
195
- // =========================
196
- // Course info must ALWAYS show
197
- // =========================
198
- const currentCourseInfo: CourseInfo | null = useMemo(() => {
199
- const ws = workspaces?.find((w) => w.id === currentWorkspaceId);
200
-
201
- // group course workspace preferred
202
- if (ws?.type === "group" && ws?.category === "course") {
203
- const ci = (ws as any)?.courseInfo as CourseInfo | undefined;
204
- if (ci?.id && ci?.name) return ci;
205
-
206
- // fallback: match by courseName if courseInfo missing
207
- const name = (ws as any)?.courseName as string | undefined;
208
- if (name) {
209
- const byName = availableCourses?.find((c) => c.name === name);
210
- if (byName) return byName;
211
- }
212
- }
213
-
214
- // individual workspace: match selectedCourse id
215
- const byId = availableCourses?.find((c) => c.id === selectedCourse);
216
- return byId || (availableCourses?.[0] ?? null);
217
- }, [availableCourses, currentWorkspaceId, selectedCourse, workspaces]);
218
-
219
- // ===== rename state =====
220
  const [editingId, setEditingId] = useState<string | null>(null);
221
- const [draftTitle, setDraftTitle] = useState<string>("");
 
 
 
 
222
 
223
  const sortedChats = useMemo(() => {
224
- // newest first
225
- return [...savedChats].sort((a: any, b: any) => {
226
- const ta = new Date(a.timestamp || a.savedAt || a.createdAt || 0).getTime();
227
- const tb = new Date(b.timestamp || b.savedAt || b.createdAt || 0).getTime();
228
- return tb - ta;
229
- });
230
  }, [savedChats]);
231
 
232
  const startRename = (chat: SavedChat) => {
233
  setEditingId(chat.id);
234
- setDraftTitle((chat.title || "").trim() || "Untitled chat");
235
  };
236
 
237
  const cancelRename = () => {
@@ -239,131 +62,129 @@ export function LeftSidebar(props: Props) {
239
  setDraftTitle("");
240
  };
241
 
242
- const commitRename = (chatId: string) => {
243
  const next = draftTitle.trim();
244
  if (!next) return;
245
- onRenameSavedChat(chatId, next);
246
- setEditingId(null);
247
- setDraftTitle("");
248
  };
249
 
250
  return (
251
  <div className="h-full w-full flex flex-col min-h-0">
252
- {/* 0) Course Info (fixed, not scroll) */}
253
- {currentCourseInfo ? <CourseInfoHeader course={currentCourseInfo} /> : null}
254
 
255
- {/* Divider between Info and Saved Chat (fixed) */}
256
- <div className="px-4 flex-shrink-0">
257
- <Separator />
258
- </div>
 
 
259
 
260
- {/* 1) Saved Chat header (fixed, not scroll) */}
261
- <div className="flex-shrink-0 px-4 pt-4 pb-3">
262
- <div className="flex items-center gap-2">
263
- <Bookmark className="h-4 w-4" />
264
- <h3 className="font-semibold">Saved Chat</h3>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  </div>
 
 
 
 
 
 
 
 
 
266
  </div>
267
 
268
- {/* 2) Saved Chat list (ONLY this scrolls) */}
269
- <div className="flex-1 min-h-0 overflow-y-auto px-4 pb-3 space-y-3">
 
 
270
  {sortedChats.length === 0 ? (
271
  <div className="text-sm text-muted-foreground text-center py-10">
272
- <div className="mx-auto mb-3 h-10 w-10 rounded-full bg-muted flex items-center justify-center">
273
- <Bookmark className="h-5 w-5 text-muted-foreground" />
274
- </div>
275
- <div className="font-medium">No saved chats yet</div>
276
- <div className="text-xs text-muted-foreground mt-1">Save conversations to view them here</div>
277
  </div>
278
  ) : (
279
- sortedChats.map((chat) => {
280
  const isEditing = editingId === chat.id;
281
- const sub = formatSub((chat as any).timestamp || (chat as any).savedAt || (chat as any).createdAt);
282
 
283
  return (
284
- <div key={chat.id} className="rounded-xl border border-border bg-card px-4 py-3">
 
 
 
285
  <div className="flex items-start justify-between gap-3">
286
- {/* left */}
287
- <div className="min-w-0 flex-1">
 
288
  {isEditing ? (
289
  <div className="space-y-2">
290
  <Input
291
  value={draftTitle}
292
- onChange={(e) => setDraftTitle(e.target.value)}
293
- className="h-9"
294
  autoFocus
295
- onKeyDown={(e) => {
296
- if (e.key === "Enter") {
297
- e.preventDefault();
298
- commitRename(chat.id);
299
- }
300
- if (e.key === "Escape") {
301
- e.preventDefault();
302
- cancelRename();
303
- }
304
  }}
305
  />
306
- <div className="flex items-center gap-2">
307
- <Button
308
- size="sm"
309
- className="h-8 px-2"
310
- onClick={() => commitRename(chat.id)}
311
- disabled={!draftTitle.trim()}
312
- title="Save"
313
- >
314
  <Check className="h-4 w-4" />
315
  </Button>
316
- <Button
317
- size="sm"
318
- variant="outline"
319
- className="h-8 px-2"
320
- onClick={cancelRename}
321
- title="Cancel"
322
- >
323
  <XIcon className="h-4 w-4" />
324
  </Button>
325
  </div>
326
  </div>
327
  ) : (
328
  <button
329
- type="button"
330
  className="text-left w-full"
331
  onClick={() => onLoadChat(chat)}
332
- title="Open saved chat"
333
  >
334
- <div className="font-semibold truncate">{chat.title || "Untitled chat"}</div>
335
- {sub ? <div className="text-xs text-muted-foreground mt-1">{sub}</div> : null}
 
 
 
 
 
 
336
  </button>
337
  )}
338
  </div>
339
 
340
- {/* right: ✏️ then 🗑️ */}
341
  {!isEditing && (
342
- <div className="flex items-center gap-2 flex-shrink-0">
343
  <Button
344
  variant="ghost"
345
  size="icon"
346
- className="h-8 w-8"
347
- onClick={(e) => {
348
- e.preventDefault();
349
- e.stopPropagation();
350
- startRename(chat);
351
- }}
352
- title="Rename"
353
  >
354
  <Edit2 className="h-4 w-4" />
355
  </Button>
356
-
357
  <Button
358
  variant="ghost"
359
  size="icon"
360
- className="h-8 w-8 text-muted-foreground hover:text-destructive"
361
- onClick={(e) => {
362
- e.preventDefault();
363
- e.stopPropagation();
364
- onDeleteSavedChat(chat.id);
365
- }}
366
- title="Delete"
367
  >
368
  <Trash2 className="h-4 w-4" />
369
  </Button>
@@ -375,9 +196,6 @@ export function LeftSidebar(props: Props) {
375
  })
376
  )}
377
  </div>
378
-
379
- {/* footer spacer */}
380
- <div className="flex-shrink-0 px-4 pb-4" />
381
  </div>
382
  );
383
  }
 
 
1
  import React, { useMemo, useState } from "react";
2
  import { Button } from "./ui/button";
3
  import { Input } from "./ui/input";
4
  import { Separator } from "./ui/separator";
5
+ import { Bookmark, Trash2, Edit2, Check, X as XIcon } from "lucide-react";
 
6
 
7
  import type {
 
 
 
 
 
 
8
  SavedChat,
9
  Workspace,
10
  CourseInfo,
11
  } from "../App";
12
 
13
  type Props = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  savedChats: SavedChat[];
15
  onLoadChat: (chat: SavedChat) => void;
16
  onDeleteSavedChat: (id: string) => void;
 
17
  onRenameSavedChat: (id: string, newTitle: string) => void;
18
 
19
  currentWorkspaceId: string;
20
  workspaces: Workspace[];
 
 
21
  availableCourses: CourseInfo[];
22
  };
23
 
 
27
  const d = typeof ts === "string" || typeof ts === "number" ? new Date(ts) : ts;
28
  return d.toLocaleString();
29
  } catch {
30
+ return "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  }
32
  }
33
 
34
+ export function LeftSidebar({
35
+ savedChats,
36
+ onLoadChat,
37
+ onDeleteSavedChat,
38
+ onRenameSavedChat,
39
+ currentWorkspaceId,
40
+ workspaces,
41
+ }: Props) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  const [editingId, setEditingId] = useState<string | null>(null);
43
+ const [draftTitle, setDraftTitle] = useState("");
44
+
45
+ /** ===== 当前 workspace 对应的 course info ===== */
46
+ const currentWorkspace = workspaces.find(w => w.id === currentWorkspaceId);
47
+ const courseInfo = currentWorkspace?.courseInfo;
48
 
49
  const sortedChats = useMemo(() => {
50
+ return [...savedChats].sort((a, b) =>
51
+ new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
52
+ );
 
 
 
53
  }, [savedChats]);
54
 
55
  const startRename = (chat: SavedChat) => {
56
  setEditingId(chat.id);
57
+ setDraftTitle(chat.title || "");
58
  };
59
 
60
  const cancelRename = () => {
 
62
  setDraftTitle("");
63
  };
64
 
65
+ const commitRename = (id: string) => {
66
  const next = draftTitle.trim();
67
  if (!next) return;
68
+ onRenameSavedChat(id, next);
69
+ cancelRename();
 
70
  };
71
 
72
  return (
73
  <div className="h-full w-full flex flex-col min-h-0">
 
 
74
 
75
+ {/* ================= Course Info(不滚动) ================= */}
76
+ {courseInfo && (
77
+ <div className="px-4 pt-4 pb-3 flex-shrink-0 space-y-2">
78
+ <div className="font-semibold text-base">
79
+ {courseInfo.name}
80
+ </div>
81
 
82
+ <div className="text-sm text-muted-foreground">
83
+ Instructor:&nbsp;
84
+ <a
85
+ href={`mailto:${courseInfo.instructor.email}`}
86
+ className="text-primary hover:underline"
87
+ >
88
+ {courseInfo.instructor.name}
89
+ </a>
90
+ </div>
91
+
92
+ <div className="text-sm text-muted-foreground">
93
+ TA:&nbsp;
94
+ <a
95
+ href={`mailto:${courseInfo.teachingAssistant.email}`}
96
+ className="text-primary hover:underline"
97
+ >
98
+ {courseInfo.teachingAssistant.name}
99
+ </a>
100
+ </div>
101
  </div>
102
+ )}
103
+
104
+ {/* 🔴 关键分割线:Info / Saved Chat */}
105
+ <Separator className="flex-shrink-0" />
106
+
107
+ {/* ================= Saved Chat Header(不滚动) ================= */}
108
+ <div className="px-4 pt-4 pb-2 flex items-center gap-2 flex-shrink-0">
109
+ <Bookmark className="h-4 w-4" />
110
+ <h3 className="font-semibold">Saved Chat</h3>
111
  </div>
112
 
113
+ <Separator className="flex-shrink-0" />
114
+
115
+ {/* ================= Saved Chat List(唯一滚动区) ================= */}
116
+ <div className="flex-1 min-h-0 overflow-y-auto px-4 py-3 space-y-3">
117
  {sortedChats.length === 0 ? (
118
  <div className="text-sm text-muted-foreground text-center py-10">
119
+ No saved chats yet<br />
120
+ <span className="text-xs">Save conversations to view them here</span>
 
 
 
121
  </div>
122
  ) : (
123
+ sortedChats.map(chat => {
124
  const isEditing = editingId === chat.id;
125
+ const sub = formatSub(chat.timestamp);
126
 
127
  return (
128
+ <div
129
+ key={chat.id}
130
+ className="rounded-xl border bg-card px-4 py-3"
131
+ >
132
  <div className="flex items-start justify-between gap-3">
133
+
134
+ {/* 左侧 */}
135
+ <div className="flex-1 min-w-0">
136
  {isEditing ? (
137
  <div className="space-y-2">
138
  <Input
139
  value={draftTitle}
140
+ onChange={e => setDraftTitle(e.target.value)}
 
141
  autoFocus
142
+ onKeyDown={e => {
143
+ if (e.key === "Enter") commitRename(chat.id);
144
+ if (e.key === "Escape") cancelRename();
 
 
 
 
 
 
145
  }}
146
  />
147
+ <div className="flex gap-2">
148
+ <Button size="sm" onClick={() => commitRename(chat.id)}>
 
 
 
 
 
 
149
  <Check className="h-4 w-4" />
150
  </Button>
151
+ <Button size="sm" variant="outline" onClick={cancelRename}>
 
 
 
 
 
 
152
  <XIcon className="h-4 w-4" />
153
  </Button>
154
  </div>
155
  </div>
156
  ) : (
157
  <button
 
158
  className="text-left w-full"
159
  onClick={() => onLoadChat(chat)}
 
160
  >
161
+ <div className="font-medium truncate">
162
+ {chat.title || "Untitled chat"}
163
+ </div>
164
+ {sub && (
165
+ <div className="text-xs text-muted-foreground mt-1">
166
+ {sub}
167
+ </div>
168
+ )}
169
  </button>
170
  )}
171
  </div>
172
 
173
+ {/* 右侧按钮 */}
174
  {!isEditing && (
175
+ <div className="flex gap-2 flex-shrink-0">
176
  <Button
177
  variant="ghost"
178
  size="icon"
179
+ onClick={() => startRename(chat)}
 
 
 
 
 
 
180
  >
181
  <Edit2 className="h-4 w-4" />
182
  </Button>
 
183
  <Button
184
  variant="ghost"
185
  size="icon"
186
+ onClick={() => onDeleteSavedChat(chat.id)}
187
+ className="hover:text-destructive"
 
 
 
 
 
188
  >
189
  <Trash2 className="h-4 w-4" />
190
  </Button>
 
196
  })
197
  )}
198
  </div>
 
 
 
199
  </div>
200
  );
201
  }