Spaces:
Sleeping
Sleeping
Update web/src/components/sidebar/LeftSidebar.tsx
Browse files
web/src/components/sidebar/LeftSidebar.tsx
CHANGED
|
@@ -1,8 +1,13 @@
|
|
| 1 |
// web/src/components/sidebar/LeftSidebar.tsx
|
| 2 |
import React, { useEffect, useMemo, useState } from "react";
|
| 3 |
import { Separator } from "../ui/separator";
|
|
|
|
|
|
|
| 4 |
|
| 5 |
import { SavedChatSection } from "./SavedChatSection";
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
import type {
|
| 8 |
SavedChat,
|
|
@@ -14,10 +19,18 @@ import type {
|
|
| 14 |
User,
|
| 15 |
SavedItem,
|
| 16 |
} from "../../App";
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
type Props = {
|
| 23 |
learningMode: LearningMode;
|
|
@@ -55,10 +68,28 @@ type Props = {
|
|
| 55 |
workspaces: Workspace[];
|
| 56 |
|
| 57 |
selectedCourse: string;
|
| 58 |
-
availableCourses:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
};
|
| 60 |
|
| 61 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
const anyU = u as any;
|
| 63 |
return (
|
| 64 |
anyU?.name ||
|
|
@@ -69,22 +100,7 @@ function getDisplayName(u: User | null) {
|
|
| 69 |
);
|
| 70 |
}
|
| 71 |
|
| 72 |
-
function
|
| 73 |
-
if (!selectedCourse) return "—";
|
| 74 |
-
const hit =
|
| 75 |
-
availableCourses?.find?.(
|
| 76 |
-
(c: any) =>
|
| 77 |
-
c?.id === selectedCourse ||
|
| 78 |
-
c?.courseId === selectedCourse ||
|
| 79 |
-
c?.slug === selectedCourse ||
|
| 80 |
-
c?.name === selectedCourse ||
|
| 81 |
-
c?.title === selectedCourse
|
| 82 |
-
) || null;
|
| 83 |
-
|
| 84 |
-
return hit?.name || hit?.title || String(selectedCourse);
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
function pickField(obj: any, keys: string[]) {
|
| 88 |
for (const k of keys) {
|
| 89 |
const v = obj?.[k];
|
| 90 |
if (v !== undefined && v !== null && String(v).trim() !== "") return v;
|
|
@@ -94,8 +110,9 @@ function pickField(obj: any, keys: string[]) {
|
|
| 94 |
|
| 95 |
export function LeftSidebar(props: Props) {
|
| 96 |
const {
|
| 97 |
-
isLoggedIn,
|
| 98 |
user,
|
|
|
|
|
|
|
| 99 |
groupMembers,
|
| 100 |
savedChats,
|
| 101 |
onLoadChat,
|
|
@@ -105,44 +122,108 @@ export function LeftSidebar(props: Props) {
|
|
| 105 |
workspaces,
|
| 106 |
selectedCourse,
|
| 107 |
availableCourses,
|
|
|
|
|
|
|
| 108 |
} = props;
|
| 109 |
|
| 110 |
const currentWorkspace = useMemo(
|
| 111 |
-
() => workspaces.find((w) =>
|
| 112 |
[workspaces, currentWorkspaceId]
|
| 113 |
);
|
| 114 |
|
| 115 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
const groupNameStorageKey = useMemo(
|
| 117 |
() => `clare_group_name__${currentWorkspaceId}`,
|
| 118 |
[currentWorkspaceId]
|
| 119 |
);
|
| 120 |
|
| 121 |
-
const defaultGroupName =
|
| 122 |
-
pickField(currentWorkspace, ["groupName", "group_title", "group_title_name"]) ||
|
| 123 |
-
pickField(currentWorkspace, ["name", "title"]) ||
|
| 124 |
-
"My Group";
|
| 125 |
-
|
| 126 |
const [groupName, setGroupName] = useState<string>(defaultGroupName);
|
| 127 |
-
const [
|
| 128 |
const [draftGroupName, setDraftGroupName] = useState<string>(defaultGroupName);
|
| 129 |
|
| 130 |
useEffect(() => {
|
| 131 |
-
|
| 132 |
-
|
| 133 |
const name = stored && stored.trim() ? stored : defaultGroupName;
|
| 134 |
-
|
| 135 |
setGroupName(name);
|
| 136 |
setDraftGroupName(name);
|
| 137 |
-
|
| 138 |
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 139 |
}, [groupNameStorageKey, defaultGroupName]);
|
| 140 |
|
| 141 |
-
const saveGroupName = () => {
|
| 142 |
const next = (draftGroupName || "").trim() || "My Group";
|
|
|
|
|
|
|
| 143 |
setGroupName(next);
|
| 144 |
setDraftGroupName(next);
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
try {
|
| 147 |
window.localStorage.setItem(groupNameStorageKey, next);
|
| 148 |
} catch {
|
|
@@ -152,129 +233,133 @@ export function LeftSidebar(props: Props) {
|
|
| 152 |
|
| 153 |
const cancelGroupName = () => {
|
| 154 |
setDraftGroupName(groupName);
|
| 155 |
-
|
| 156 |
};
|
| 157 |
|
| 158 |
-
//
|
| 159 |
-
const
|
| 160 |
-
|
| 161 |
|
| 162 |
-
const
|
| 163 |
-
|
| 164 |
|
| 165 |
-
const
|
| 166 |
-
pickField(currentWorkspace, ["ta", "TA", "teachingAssistant", "assistant", "taName"]) ?? "";
|
| 167 |
-
|
| 168 |
-
const courseName = useMemo(
|
| 169 |
-
() => getCourseDisplayName(selectedCourse, availableCourses),
|
| 170 |
-
[selectedCourse, availableCourses]
|
| 171 |
-
);
|
| 172 |
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
return (
|
| 176 |
-
<div className="h-full w-full flex flex-col min-h-0">
|
| 177 |
-
{/*
|
| 178 |
-
<div className="flex-shrink-0
|
| 179 |
{/* Welcome */}
|
| 180 |
-
<div className="
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
<Separator className="bg-[#ECECF1]" />
|
| 184 |
|
| 185 |
-
{/*
|
| 186 |
-
<div className="space-y-
|
| 187 |
-
<div className="text-[
|
| 188 |
-
<div className="text-[14px] font-medium leading-snug">{courseName}</div>
|
| 189 |
-
</div>
|
| 190 |
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
-
{/* Group Name (editable) */}
|
| 198 |
-
<div className="space-y-1">
|
| 199 |
-
<div className="flex items-center justify-between gap-2">
|
| 200 |
-
<div className="text-[12px] text-muted-foreground">Group Name</div>
|
| 201 |
-
|
| 202 |
-
{!isEditingGroupName ? (
|
| 203 |
-
<button
|
| 204 |
-
type="button"
|
| 205 |
-
className="inline-flex items-center gap-1 text-[12px] text-muted-foreground hover:text-foreground"
|
| 206 |
-
onClick={() => setIsEditingGroupName(true)}
|
| 207 |
-
aria-label="Edit group name"
|
| 208 |
-
>
|
| 209 |
-
<Pencil className="w-3.5 h-3.5" />
|
| 210 |
-
</button>
|
| 211 |
-
) : (
|
| 212 |
-
<div className="flex items-center gap-1">
|
| 213 |
-
<Button
|
| 214 |
-
type="button"
|
| 215 |
-
size="icon"
|
| 216 |
-
variant="ghost"
|
| 217 |
-
className="h-7 w-7"
|
| 218 |
-
onClick={saveGroupName}
|
| 219 |
-
aria-label="Save group name"
|
| 220 |
-
>
|
| 221 |
-
<Check className="w-4 h-4" />
|
| 222 |
-
</Button>
|
| 223 |
<Button
|
| 224 |
type="button"
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
onClick={cancelGroupName}
|
| 229 |
-
aria-label="Cancel edit group name"
|
| 230 |
>
|
| 231 |
-
<
|
|
|
|
| 232 |
</Button>
|
| 233 |
</div>
|
| 234 |
-
)}
|
| 235 |
-
</div>
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
onChange={(e) => setDraftGroupName(e.target.value)}
|
| 243 |
-
onKeyDown={(e) => {
|
| 244 |
-
if (e.key === "Enter") saveGroupName();
|
| 245 |
-
if (e.key === "Escape") cancelGroupName();
|
| 246 |
-
}}
|
| 247 |
-
className="h-8"
|
| 248 |
-
autoFocus
|
| 249 |
-
/>
|
| 250 |
)}
|
| 251 |
</div>
|
| 252 |
|
| 253 |
-
|
| 254 |
-
<div className="space-y-2">
|
| 255 |
-
<div className="text-[12px] text-muted-foreground">Group members</div>
|
| 256 |
-
<div className="space-y-1">
|
| 257 |
-
{(groupMembers || []).length === 0 ? (
|
| 258 |
-
<div className="text-[12px] text-muted-foreground">—</div>
|
| 259 |
-
) : (
|
| 260 |
-
groupMembers.map((m: any) => {
|
| 261 |
-
const name = m?.name || m?.fullName || m?.username || m?.email || "Member";
|
| 262 |
-
return (
|
| 263 |
-
<div key={m?.id || name} className="text-[13px] leading-snug">
|
| 264 |
-
{name}
|
| 265 |
-
</div>
|
| 266 |
-
);
|
| 267 |
-
})
|
| 268 |
-
)}
|
| 269 |
-
</div>
|
| 270 |
-
</div>
|
| 271 |
</div>
|
| 272 |
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
{/* ---------- MIDDLE (only scroll area) ---------- */}
|
| 276 |
<div className="flex-1 min-h-0 overflow-hidden">
|
| 277 |
-
<div className="h-full min-h-0 panelScroll px-
|
| 278 |
<SavedChatSection
|
| 279 |
isLoggedIn={isLoggedIn}
|
| 280 |
savedChats={savedChats}
|
|
@@ -285,27 +370,51 @@ export function LeftSidebar(props: Props) {
|
|
| 285 |
</div>
|
| 286 |
</div>
|
| 287 |
|
| 288 |
-
{/*
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
<div className="
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
</div>
|
| 307 |
-
|
| 308 |
-
|
| 309 |
</div>
|
| 310 |
);
|
| 311 |
}
|
|
|
|
| 1 |
// web/src/components/sidebar/LeftSidebar.tsx
|
| 2 |
import React, { useEffect, useMemo, useState } from "react";
|
| 3 |
import { Separator } from "../ui/separator";
|
| 4 |
+
import { Button } from "../ui/button";
|
| 5 |
+
import { Input } from "../ui/input";
|
| 6 |
|
| 7 |
import { SavedChatSection } from "./SavedChatSection";
|
| 8 |
+
import { GroupMembers } from "../GroupMembers";
|
| 9 |
+
|
| 10 |
+
import { Users, Mail, Pencil, Check, X } from "lucide-react";
|
| 11 |
|
| 12 |
import type {
|
| 13 |
SavedChat,
|
|
|
|
| 19 |
User,
|
| 20 |
SavedItem,
|
| 21 |
} from "../../App";
|
| 22 |
+
import type { CourseDirectoryItem } from "../../lib/courseDirectory";
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* Behavior:
|
| 26 |
+
* - spaceType === "group" (Team/Group space): show "Group Members (N)" + Invite button + member list (old design).
|
| 27 |
+
* - otherwise (My Space / personal): show simple "Group {groupNo}" + editable "Group {groupName}".
|
| 28 |
+
*
|
| 29 |
+
* Layout:
|
| 30 |
+
* - TOP: Welcome + Course + (Group block: either simple or members panel) => non-scroll
|
| 31 |
+
* - MIDDLE: Saved Chat => only scroll
|
| 32 |
+
* - BOTTOM: Instructor/TA (click -> Gmail compose) => fixed, non-scroll
|
| 33 |
+
*/
|
| 34 |
|
| 35 |
type Props = {
|
| 36 |
learningMode: LearningMode;
|
|
|
|
| 68 |
workspaces: Workspace[];
|
| 69 |
|
| 70 |
selectedCourse: string;
|
| 71 |
+
availableCourses: CourseDirectoryItem[];
|
| 72 |
+
|
| 73 |
+
// optional: if you already have invite flow somewhere, wire it here; otherwise UI stays but does nothing.
|
| 74 |
+
onInviteGroupMembers?: () => void;
|
| 75 |
+
|
| 76 |
+
// optional: if you have backend rename endpoint, wire it here (preferred).
|
| 77 |
+
// if not provided, we fallback to localStorage persistence.
|
| 78 |
+
onRenameGroupName?: (workspaceId: string, newName: string) => Promise<void> | void;
|
| 79 |
};
|
| 80 |
|
| 81 |
+
function gmailComposeLink(email: string, subject?: string, body?: string) {
|
| 82 |
+
const to = `to=${encodeURIComponent(email)}`;
|
| 83 |
+
const su = subject ? `&su=${encodeURIComponent(subject)}` : "";
|
| 84 |
+
const bd = body ? `&body=${encodeURIComponent(body)}` : "";
|
| 85 |
+
return `https://mail.google.com/mail/?view=cm&fs=1&${to}${su}${bd}`;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function norm(s: any) {
|
| 89 |
+
return String(s ?? "").trim().toLowerCase();
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function getUserName(u: User | null) {
|
| 93 |
const anyU = u as any;
|
| 94 |
return (
|
| 95 |
anyU?.name ||
|
|
|
|
| 100 |
);
|
| 101 |
}
|
| 102 |
|
| 103 |
+
function pickAny(obj: any, keys: string[]) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
for (const k of keys) {
|
| 105 |
const v = obj?.[k];
|
| 106 |
if (v !== undefined && v !== null && String(v).trim() !== "") return v;
|
|
|
|
| 110 |
|
| 111 |
export function LeftSidebar(props: Props) {
|
| 112 |
const {
|
|
|
|
| 113 |
user,
|
| 114 |
+
isLoggedIn,
|
| 115 |
+
spaceType,
|
| 116 |
groupMembers,
|
| 117 |
savedChats,
|
| 118 |
onLoadChat,
|
|
|
|
| 122 |
workspaces,
|
| 123 |
selectedCourse,
|
| 124 |
availableCourses,
|
| 125 |
+
onInviteGroupMembers,
|
| 126 |
+
onRenameGroupName,
|
| 127 |
} = props;
|
| 128 |
|
| 129 |
const currentWorkspace = useMemo(
|
| 130 |
+
() => workspaces.find((w) => w.id === currentWorkspaceId),
|
| 131 |
[workspaces, currentWorkspaceId]
|
| 132 |
);
|
| 133 |
|
| 134 |
+
// --------- CourseInfo resolution (same strategy as CourseInfoSection) ---------
|
| 135 |
+
const courseInfo = useMemo((): CourseDirectoryItem | null => {
|
| 136 |
+
const list = Array.isArray(availableCourses) ? availableCourses : [];
|
| 137 |
+
|
| 138 |
+
// 1) selectedCourse: may be id or name
|
| 139 |
+
const selRaw = (selectedCourse || "").trim();
|
| 140 |
+
const sel = norm(selRaw);
|
| 141 |
+
if (sel) {
|
| 142 |
+
const hit =
|
| 143 |
+
list.find((c: any) => norm(c.id) === sel) ||
|
| 144 |
+
list.find((c: any) => norm(c.name) === sel);
|
| 145 |
+
if (hit) return hit;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// 2) workspace.courseInfo: may have id/name, sometimes includes instructor/TA already
|
| 149 |
+
const wsCourse = (currentWorkspace as any)?.courseInfo as
|
| 150 |
+
| { id?: string; name?: string; instructor?: any; teachingAssistant?: any }
|
| 151 |
+
| undefined;
|
| 152 |
+
|
| 153 |
+
const wsId = norm(wsCourse?.id);
|
| 154 |
+
if (wsId) {
|
| 155 |
+
return (list.find((c: any) => norm(c.id) === wsId) ?? (wsCourse as any)) as any;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
const wsName = norm(wsCourse?.name);
|
| 159 |
+
if (wsName) {
|
| 160 |
+
return (list.find((c: any) => norm(c.name) === wsName) ?? (wsCourse as any)) as any;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
return null;
|
| 164 |
+
}, [availableCourses, currentWorkspace, selectedCourse]);
|
| 165 |
+
|
| 166 |
+
const courseName = useMemo(() => {
|
| 167 |
+
return (courseInfo as any)?.name ?? (selectedCourse || "Course");
|
| 168 |
+
}, [courseInfo, selectedCourse]);
|
| 169 |
+
|
| 170 |
+
// --------- Group number/name (My Space view) ---------
|
| 171 |
+
const groupNo = useMemo(() => {
|
| 172 |
+
const ws: any = currentWorkspace as any;
|
| 173 |
+
return (
|
| 174 |
+
ws?.groupNo ??
|
| 175 |
+
ws?.groupNumber ??
|
| 176 |
+
ws?.groupIndex ??
|
| 177 |
+
ws?.group_id ??
|
| 178 |
+
ws?.groupId ??
|
| 179 |
+
1
|
| 180 |
+
);
|
| 181 |
+
}, [currentWorkspace]);
|
| 182 |
+
|
| 183 |
+
const defaultGroupName = useMemo(() => {
|
| 184 |
+
const ws: any = currentWorkspace as any;
|
| 185 |
+
return (
|
| 186 |
+
pickAny(ws, ["groupName", "name", "title"]) ||
|
| 187 |
+
"My Group"
|
| 188 |
+
);
|
| 189 |
+
}, [currentWorkspace]);
|
| 190 |
+
|
| 191 |
const groupNameStorageKey = useMemo(
|
| 192 |
() => `clare_group_name__${currentWorkspaceId}`,
|
| 193 |
[currentWorkspaceId]
|
| 194 |
);
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
const [groupName, setGroupName] = useState<string>(defaultGroupName);
|
| 197 |
+
const [editingGroupName, setEditingGroupName] = useState(false);
|
| 198 |
const [draftGroupName, setDraftGroupName] = useState<string>(defaultGroupName);
|
| 199 |
|
| 200 |
useEffect(() => {
|
| 201 |
+
const stored =
|
| 202 |
+
typeof window !== "undefined" ? window.localStorage.getItem(groupNameStorageKey) : null;
|
| 203 |
const name = stored && stored.trim() ? stored : defaultGroupName;
|
|
|
|
| 204 |
setGroupName(name);
|
| 205 |
setDraftGroupName(name);
|
| 206 |
+
setEditingGroupName(false);
|
|
|
|
| 207 |
}, [groupNameStorageKey, defaultGroupName]);
|
| 208 |
|
| 209 |
+
const saveGroupName = async () => {
|
| 210 |
const next = (draftGroupName || "").trim() || "My Group";
|
| 211 |
+
|
| 212 |
+
// optimistic UI
|
| 213 |
setGroupName(next);
|
| 214 |
setDraftGroupName(next);
|
| 215 |
+
setEditingGroupName(false);
|
| 216 |
+
|
| 217 |
+
// preferred: call provided handler (backend)
|
| 218 |
+
if (onRenameGroupName) {
|
| 219 |
+
try {
|
| 220 |
+
await onRenameGroupName(currentWorkspaceId, next);
|
| 221 |
+
return;
|
| 222 |
+
} catch {
|
| 223 |
+
// fallback to localStorage if handler fails
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
try {
|
| 228 |
window.localStorage.setItem(groupNameStorageKey, next);
|
| 229 |
} catch {
|
|
|
|
| 233 |
|
| 234 |
const cancelGroupName = () => {
|
| 235 |
setDraftGroupName(groupName);
|
| 236 |
+
setEditingGroupName(false);
|
| 237 |
};
|
| 238 |
|
| 239 |
+
// --------- Contacts (fixed bottom) ---------
|
| 240 |
+
const instructorName = (courseInfo as any)?.instructor?.name ?? "N/A";
|
| 241 |
+
const instructorEmail = String((courseInfo as any)?.instructor?.email ?? "").trim();
|
| 242 |
|
| 243 |
+
const taName = (courseInfo as any)?.teachingAssistant?.name ?? "N/A";
|
| 244 |
+
const taEmail = String((courseInfo as any)?.teachingAssistant?.email ?? "").trim();
|
| 245 |
|
| 246 |
+
const displayName = useMemo(() => getUserName(user), [user]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
+
// Team/Group space detection: we treat "group" as the Team/Group page
|
| 249 |
+
const isTeamSpace = useMemo(() => {
|
| 250 |
+
const st = String(spaceType || "").toLowerCase();
|
| 251 |
+
return st === "group" || st === "team";
|
| 252 |
+
}, [spaceType]);
|
| 253 |
|
| 254 |
return (
|
| 255 |
+
<div className="h-full w-full flex flex-col min-h-0 bg-white">
|
| 256 |
+
{/* ================= TOP (non-scroll) ================= */}
|
| 257 |
+
<div className="flex-shrink-0">
|
| 258 |
{/* Welcome */}
|
| 259 |
+
<div className="px-4 pt-6 pb-6 space-y-3">
|
| 260 |
+
<div className="text-[34px] leading-tight font-semibold">
|
| 261 |
+
Welcome, {displayName}!
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
|
| 265 |
<Separator className="bg-[#ECECF1]" />
|
| 266 |
|
| 267 |
+
{/* Course + Group block */}
|
| 268 |
+
<div className="px-4 pt-10 pb-10 space-y-4">
|
| 269 |
+
<div className="text-[30px] leading-tight font-semibold">{courseName}</div>
|
|
|
|
|
|
|
| 270 |
|
| 271 |
+
{/* My Space: simple Group No + Group Name (editable) */}
|
| 272 |
+
{!isTeamSpace ? (
|
| 273 |
+
<div className="space-y-3">
|
| 274 |
+
<div className="text-[30px] leading-tight font-semibold">
|
| 275 |
+
Group {String(groupNo)}
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<div className="flex items-center justify-between gap-2">
|
| 279 |
+
{!editingGroupName ? (
|
| 280 |
+
<>
|
| 281 |
+
<div className="text-[22px] leading-tight font-medium">
|
| 282 |
+
Group {groupName}
|
| 283 |
+
</div>
|
| 284 |
+
<button
|
| 285 |
+
type="button"
|
| 286 |
+
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground"
|
| 287 |
+
onClick={() => setEditingGroupName(true)}
|
| 288 |
+
aria-label="Edit group name"
|
| 289 |
+
>
|
| 290 |
+
<Pencil className="w-4 h-4" />
|
| 291 |
+
</button>
|
| 292 |
+
</>
|
| 293 |
+
) : (
|
| 294 |
+
<div className="w-full flex items-center gap-2">
|
| 295 |
+
<Input
|
| 296 |
+
value={draftGroupName}
|
| 297 |
+
onChange={(e) => setDraftGroupName(e.target.value)}
|
| 298 |
+
onKeyDown={(e) => {
|
| 299 |
+
if (e.key === "Enter") saveGroupName();
|
| 300 |
+
if (e.key === "Escape") cancelGroupName();
|
| 301 |
+
}}
|
| 302 |
+
className="h-9"
|
| 303 |
+
autoFocus
|
| 304 |
+
/>
|
| 305 |
+
<Button
|
| 306 |
+
type="button"
|
| 307 |
+
size="icon"
|
| 308 |
+
variant="ghost"
|
| 309 |
+
className="h-9 w-9"
|
| 310 |
+
onClick={saveGroupName}
|
| 311 |
+
aria-label="Save group name"
|
| 312 |
+
>
|
| 313 |
+
<Check className="w-4 h-4" />
|
| 314 |
+
</Button>
|
| 315 |
+
<Button
|
| 316 |
+
type="button"
|
| 317 |
+
size="icon"
|
| 318 |
+
variant="ghost"
|
| 319 |
+
className="h-9 w-9"
|
| 320 |
+
onClick={cancelGroupName}
|
| 321 |
+
aria-label="Cancel edit group name"
|
| 322 |
+
>
|
| 323 |
+
<X className="w-4 h-4" />
|
| 324 |
+
</Button>
|
| 325 |
+
</div>
|
| 326 |
+
)}
|
| 327 |
+
</div>
|
| 328 |
+
</div>
|
| 329 |
+
) : (
|
| 330 |
+
// Team/Group Space: "Group Members (N)" + Invite + list
|
| 331 |
+
<div className="rounded-xl border bg-[#0B0B0C] text-white overflow-hidden">
|
| 332 |
+
<div className="px-4 py-3 flex items-center justify-between">
|
| 333 |
+
<div className="flex items-center gap-2 text-[14px] font-medium">
|
| 334 |
+
<Users className="w-4 h-4 opacity-90" />
|
| 335 |
+
<span>Group Members ({(groupMembers || []).length})</span>
|
| 336 |
+
</div>
|
| 337 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
<Button
|
| 339 |
type="button"
|
| 340 |
+
variant="secondary"
|
| 341 |
+
className="h-8 px-3 text-[13px] bg-white/10 hover:bg-white/15 text-white border border-white/15"
|
| 342 |
+
onClick={onInviteGroupMembers}
|
|
|
|
|
|
|
| 343 |
>
|
| 344 |
+
<Mail className="w-4 h-4 mr-2" />
|
| 345 |
+
Invite
|
| 346 |
</Button>
|
| 347 |
</div>
|
|
|
|
|
|
|
| 348 |
|
| 349 |
+
<div className="px-3 pb-3">
|
| 350 |
+
{/* Reuse existing GroupMembers component (old UI) */}
|
| 351 |
+
<GroupMembers members={groupMembers as any} />
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
)}
|
| 355 |
</div>
|
| 356 |
|
| 357 |
+
<Separator className="bg-[#ECECF1]" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
</div>
|
| 359 |
|
| 360 |
+
{/* ================= MIDDLE (only scroll) ================= */}
|
|
|
|
|
|
|
| 361 |
<div className="flex-1 min-h-0 overflow-hidden">
|
| 362 |
+
<div className="h-full min-h-0 panelScroll px-0 py-0">
|
| 363 |
<SavedChatSection
|
| 364 |
isLoggedIn={isLoggedIn}
|
| 365 |
savedChats={savedChats}
|
|
|
|
| 370 |
</div>
|
| 371 |
</div>
|
| 372 |
|
| 373 |
+
{/* ================= BOTTOM (fixed, non-scroll) ================= */}
|
| 374 |
+
<div className="flex-shrink-0">
|
| 375 |
+
<Separator className="bg-[#ECECF1]" />
|
| 376 |
+
<div className="px-4 py-4 space-y-2 text-[16px]">
|
| 377 |
+
<div className="text-muted-foreground">
|
| 378 |
+
Instructor:
|
| 379 |
+
{instructorEmail ? (
|
| 380 |
+
<a
|
| 381 |
+
href={gmailComposeLink(
|
| 382 |
+
instructorEmail,
|
| 383 |
+
`[Clare] Question about ${courseName}`,
|
| 384 |
+
`Hi ${instructorName},\n\nI have a question about ${courseName}:\n\n(Write your question here)\n\nThanks,\n`
|
| 385 |
+
)}
|
| 386 |
+
target="_blank"
|
| 387 |
+
rel="noopener noreferrer"
|
| 388 |
+
className="text-primary hover:underline"
|
| 389 |
+
>
|
| 390 |
+
{instructorName}
|
| 391 |
+
</a>
|
| 392 |
+
) : (
|
| 393 |
+
<span className="text-muted-foreground/60">{instructorName}</span>
|
| 394 |
+
)}
|
| 395 |
+
</div>
|
| 396 |
|
| 397 |
+
<div className="text-muted-foreground">
|
| 398 |
+
TA:
|
| 399 |
+
{taEmail ? (
|
| 400 |
+
<a
|
| 401 |
+
href={gmailComposeLink(
|
| 402 |
+
taEmail,
|
| 403 |
+
`[Clare] Help request for ${courseName}`,
|
| 404 |
+
`Hi ${taName},\n\nI need help with ${courseName}:\n\n(Write your question here)\n\nThanks,\n`
|
| 405 |
+
)}
|
| 406 |
+
target="_blank"
|
| 407 |
+
rel="noopener noreferrer"
|
| 408 |
+
className="text-primary hover:underline"
|
| 409 |
+
>
|
| 410 |
+
{taName}
|
| 411 |
+
</a>
|
| 412 |
+
) : (
|
| 413 |
+
<span className="text-muted-foreground/60">{taName}</span>
|
| 414 |
+
)}
|
| 415 |
</div>
|
| 416 |
+
</div>
|
| 417 |
+
</div>
|
| 418 |
</div>
|
| 419 |
);
|
| 420 |
}
|