Spaces:
Sleeping
Sleeping
Update web/src/components/sidebar/LeftSidebar.tsx
Browse files
web/src/components/sidebar/LeftSidebar.tsx
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 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 |
import { Badge } from "../ui/badge";
|
|
@@ -33,9 +32,9 @@ import type { CourseDirectoryItem } from "../../lib/courseDirectory";
|
|
| 33 |
|
| 34 |
import clareAvatar from "../../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
|
| 35 |
|
| 36 |
-
// ✅ Theme-compatible divider (light/dark) +
|
| 37 |
const Divider = () => (
|
| 38 |
-
<div className="w-full border-t border-border/70 dark:border-border
|
| 39 |
);
|
| 40 |
|
| 41 |
type Props = {
|
|
@@ -77,10 +76,7 @@ type Props = {
|
|
| 77 |
availableCourses: CourseDirectoryItem[];
|
| 78 |
|
| 79 |
// optional if you already wired backend
|
| 80 |
-
onRenameGroupName?: (
|
| 81 |
-
workspaceId: string,
|
| 82 |
-
newName: string
|
| 83 |
-
) => Promise<void> | void;
|
| 84 |
onRenameGroupNo?: (workspaceId: string, newNo: number) => Promise<void> | void;
|
| 85 |
};
|
| 86 |
|
|
@@ -146,13 +142,11 @@ export function LeftSidebar(props: Props) {
|
|
| 146 |
const isMyWorkspace = useMemo(() => {
|
| 147 |
const ws: any = currentWorkspace as any;
|
| 148 |
|
| 149 |
-
// 常见 personal 标记(你后端 schema 之后可以对齐其中一个)
|
| 150 |
if (ws?.isPersonal === true) return true;
|
| 151 |
if (String(ws?.spaceType || "").toLowerCase().includes("my")) return true;
|
| 152 |
if (String(ws?.type || "").toLowerCase().includes("my")) return true;
|
| 153 |
if (String(ws?.kind || "").toLowerCase().includes("my")) return true;
|
| 154 |
|
| 155 |
-
// 兜底:workspace id/name 里含 my/personal
|
| 156 |
const id = String(ws?.id || "").toLowerCase();
|
| 157 |
const name = String(ws?.name || "").toLowerCase();
|
| 158 |
if (id.includes("my") || id.includes("personal")) return true;
|
|
@@ -162,28 +156,12 @@ export function LeftSidebar(props: Props) {
|
|
| 162 |
}, [currentWorkspace]);
|
| 163 |
|
| 164 |
const isTeamSpace = useMemo(() => {
|
| 165 |
-
// 如果 workspace 明确是 My Space,则强制 false
|
| 166 |
if (isMyWorkspace) return false;
|
| 167 |
-
|
| 168 |
const st = String(spaceType || "").toLowerCase();
|
| 169 |
return st === "group" || st === "team";
|
| 170 |
}, [spaceType, isMyWorkspace]);
|
| 171 |
|
| 172 |
// --------- CourseInfo resolution ---------
|
| 173 |
-
const demoGroupMap: Record<string, { name: string; no: number }> = {
|
| 174 |
-
"Introduction to AI": { name: "Foundations", no: 1 },
|
| 175 |
-
"Machine Learning": { name: "Model Builders", no: 3 },
|
| 176 |
-
"Data Visualization": { name: "Storytellers", no: 2 },
|
| 177 |
-
};
|
| 178 |
-
|
| 179 |
-
const demoGroup = useMemo(() => {
|
| 180 |
-
return demoGroupMap[courseName] ?? {
|
| 181 |
-
name: wsGroupName || "My Group",
|
| 182 |
-
no: toIntOrFallback(wsGroupNo, 1),
|
| 183 |
-
};
|
| 184 |
-
}, [courseName, wsGroupName, wsGroupNo]);
|
| 185 |
-
|
| 186 |
-
|
| 187 |
const courseInfo = useMemo((): CourseDirectoryItem | null => {
|
| 188 |
const list = Array.isArray(availableCourses) ? availableCourses : [];
|
| 189 |
|
|
@@ -193,7 +171,7 @@ const demoGroup = useMemo(() => {
|
|
| 193 |
const hit =
|
| 194 |
list.find((c: any) => norm(c.id) === sel) ||
|
| 195 |
list.find((c: any) => norm(c.name) === sel);
|
| 196 |
-
if (hit) return hit;
|
| 197 |
}
|
| 198 |
|
| 199 |
const wsCourse = (currentWorkspace as any)?.courseInfo as
|
|
@@ -201,16 +179,10 @@ const demoGroup = useMemo(() => {
|
|
| 201 |
| undefined;
|
| 202 |
|
| 203 |
const wsId = norm(wsCourse?.id);
|
| 204 |
-
if (wsId)
|
| 205 |
-
return (list.find((c: any) => norm(c.id) === wsId) ??
|
| 206 |
-
(wsCourse as any)) as any;
|
| 207 |
-
}
|
| 208 |
|
| 209 |
const wsName = norm(wsCourse?.name);
|
| 210 |
-
if (wsName)
|
| 211 |
-
return (list.find((c: any) => norm(c.name) === wsName) ??
|
| 212 |
-
(wsCourse as any)) as any;
|
| 213 |
-
}
|
| 214 |
|
| 215 |
return null;
|
| 216 |
}, [availableCourses, currentWorkspace, selectedCourse]);
|
|
@@ -222,14 +194,7 @@ const demoGroup = useMemo(() => {
|
|
| 222 |
// --------- Group fields ---------
|
| 223 |
const wsGroupNo = useMemo(() => {
|
| 224 |
const ws: any = currentWorkspace as any;
|
| 225 |
-
return
|
| 226 |
-
ws?.groupNo ??
|
| 227 |
-
ws?.groupNumber ??
|
| 228 |
-
ws?.groupIndex ??
|
| 229 |
-
ws?.group_id ??
|
| 230 |
-
ws?.groupId ??
|
| 231 |
-
1
|
| 232 |
-
);
|
| 233 |
}, [currentWorkspace]);
|
| 234 |
|
| 235 |
const wsGroupName = useMemo(() => {
|
|
@@ -237,15 +202,28 @@ const demoGroup = useMemo(() => {
|
|
| 237 |
return pickAny(ws, ["groupName", "name", "title"]) || "My Group";
|
| 238 |
}, [currentWorkspace]);
|
| 239 |
|
| 240 |
-
//
|
| 241 |
-
const
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
const memberCount = (groupMembers || []).length;
|
| 247 |
|
| 248 |
-
// localStorage keys (
|
| 249 |
const groupNameStorageKey = useMemo(
|
| 250 |
() => `clare_group_name__${currentWorkspaceId}`,
|
| 251 |
[currentWorkspaceId]
|
|
@@ -263,9 +241,7 @@ const demoGroup = useMemo(() => {
|
|
| 263 |
// group no (Team space editable)
|
| 264 |
const [groupNo, setGroupNo] = useState<number>(toIntOrFallback(wsGroupNo, 1));
|
| 265 |
const [editingGroupNo, setEditingGroupNo] = useState(false);
|
| 266 |
-
const [draftGroupNo, setDraftGroupNo] = useState<string>(
|
| 267 |
-
String(toIntOrFallback(wsGroupNo, 1))
|
| 268 |
-
);
|
| 269 |
|
| 270 |
// Invite dialog state
|
| 271 |
const [inviteOpen, setInviteOpen] = useState(false);
|
|
@@ -281,21 +257,17 @@ const demoGroup = useMemo(() => {
|
|
| 281 |
setInviteOpen(false);
|
| 282 |
};
|
| 283 |
|
| 284 |
-
// hydrate persisted editable values (
|
| 285 |
useEffect(() => {
|
| 286 |
const storedName =
|
| 287 |
-
typeof window !== "undefined"
|
| 288 |
-
? window.localStorage.getItem(groupNameStorageKey)
|
| 289 |
-
: null;
|
| 290 |
const name = storedName && storedName.trim() ? storedName : wsGroupName;
|
| 291 |
setGroupName(name);
|
| 292 |
setDraftGroupName(name);
|
| 293 |
setEditingGroupName(false);
|
| 294 |
|
| 295 |
const storedNo =
|
| 296 |
-
typeof window !== "undefined"
|
| 297 |
-
? window.localStorage.getItem(groupNoStorageKey)
|
| 298 |
-
: null;
|
| 299 |
const no =
|
| 300 |
storedNo && storedNo.trim()
|
| 301 |
? toIntOrFallback(storedNo, toIntOrFallback(wsGroupNo, 1))
|
|
@@ -349,7 +321,7 @@ const demoGroup = useMemo(() => {
|
|
| 349 |
setEditingGroupNo(false);
|
| 350 |
};
|
| 351 |
|
| 352 |
-
// --------- Contacts
|
| 353 |
const instructorName = (courseInfo as any)?.instructor?.name ?? "N/A";
|
| 354 |
const instructorEmail = String((courseInfo as any)?.instructor?.email ?? "").trim();
|
| 355 |
|
|
@@ -359,50 +331,37 @@ const demoGroup = useMemo(() => {
|
|
| 359 |
const displayName = useMemo(() => getUserName(user), [user]);
|
| 360 |
|
| 361 |
return (
|
| 362 |
-
// ✅ Theme-safe background/text (fixes dark mode invisibility)
|
| 363 |
<div className="h-full w-full flex flex-col min-h-0 bg-background text-foreground">
|
| 364 |
{/* ================= TOP (non-scroll) ================= */}
|
| 365 |
<div className="flex-shrink-0">
|
| 366 |
{/* Welcome */}
|
| 367 |
<div className="px-4 pt-6 pb-6">
|
| 368 |
-
<div className="text-[34px] leading-tight font-semibold">
|
| 369 |
-
Welcome, {displayName}!
|
| 370 |
-
</div>
|
| 371 |
</div>
|
| 372 |
|
| 373 |
-
{/*
|
| 374 |
-
<div className="
|
| 375 |
<Divider />
|
| 376 |
</div>
|
| 377 |
|
| 378 |
-
|
| 379 |
{/* Course + Group */}
|
| 380 |
-
<div className="px-4 pt-
|
| 381 |
<div className="text-[30px] leading-tight font-semibold">{courseName}</div>
|
| 382 |
|
| 383 |
-
|
| 384 |
{/* ===== My Space ===== */}
|
| 385 |
{!isTeamSpace ? (
|
| 386 |
<div className="space-y-2">
|
| 387 |
-
<div className="text-[18px] font-semibold
|
| 388 |
-
|
| 389 |
-
</div>
|
| 390 |
-
|
| 391 |
-
<div className="text-[18px] font-semibold text-foreground">
|
| 392 |
-
Group {demoGroup.no}
|
| 393 |
-
</div>
|
| 394 |
</div>
|
| 395 |
) : (
|
| 396 |
-
|
| 397 |
-
/* ===== Team/Group: current style unchanged ===== */
|
| 398 |
<div className="rounded-2xl border bg-background overflow-hidden">
|
| 399 |
<div className="px-4 pt-4 pb-3 space-y-3">
|
| 400 |
{/* Line 1: group name editable */}
|
| 401 |
{!editingGroupName ? (
|
| 402 |
<div className="flex items-center gap-2">
|
| 403 |
-
<div className="text-[18px] font-semibold
|
| 404 |
-
{groupName || "My Group"}
|
| 405 |
-
</div>
|
| 406 |
<button
|
| 407 |
type="button"
|
| 408 |
className="inline-flex items-center text-muted-foreground hover:text-foreground"
|
|
@@ -424,20 +383,10 @@ const demoGroup = useMemo(() => {
|
|
| 424 |
className="h-8 w-[220px]"
|
| 425 |
autoFocus
|
| 426 |
/>
|
| 427 |
-
<Button
|
| 428 |
-
size="icon"
|
| 429 |
-
variant="ghost"
|
| 430 |
-
className="h-8 w-8"
|
| 431 |
-
onClick={saveGroupName}
|
| 432 |
-
>
|
| 433 |
<Check className="w-4 h-4" />
|
| 434 |
</Button>
|
| 435 |
-
<Button
|
| 436 |
-
size="icon"
|
| 437 |
-
variant="ghost"
|
| 438 |
-
className="h-8 w-8"
|
| 439 |
-
onClick={cancelGroupName}
|
| 440 |
-
>
|
| 441 |
<X className="w-4 h-4" />
|
| 442 |
</Button>
|
| 443 |
</div>
|
|
@@ -449,7 +398,7 @@ const demoGroup = useMemo(() => {
|
|
| 449 |
<Users className="w-4 h-4 text-muted-foreground" />
|
| 450 |
|
| 451 |
{!editingGroupNo ? (
|
| 452 |
-
<div className="text-[16px]
|
| 453 |
Group{" "}
|
| 454 |
<span className="inline-flex items-center gap-1">
|
| 455 |
{groupNo}
|
|
@@ -466,9 +415,7 @@ const demoGroup = useMemo(() => {
|
|
| 466 |
</div>
|
| 467 |
) : (
|
| 468 |
<div className="flex items-center gap-2">
|
| 469 |
-
<div className="text-[16px]
|
| 470 |
-
Group
|
| 471 |
-
</div>
|
| 472 |
<Input
|
| 473 |
value={draftGroupNo}
|
| 474 |
onChange={(e) => setDraftGroupNo(e.target.value)}
|
|
@@ -479,23 +426,11 @@ const demoGroup = useMemo(() => {
|
|
| 479 |
className="h-8 w-[80px]"
|
| 480 |
autoFocus
|
| 481 |
/>
|
| 482 |
-
<div className="text-[16px]
|
| 483 |
-
|
| 484 |
-
</div>
|
| 485 |
-
<Button
|
| 486 |
-
size="icon"
|
| 487 |
-
variant="ghost"
|
| 488 |
-
className="h-8 w-8"
|
| 489 |
-
onClick={saveGroupNo}
|
| 490 |
-
>
|
| 491 |
<Check className="w-4 h-4" />
|
| 492 |
</Button>
|
| 493 |
-
<Button
|
| 494 |
-
size="icon"
|
| 495 |
-
variant="ghost"
|
| 496 |
-
className="h-8 w-8"
|
| 497 |
-
onClick={cancelGroupNo}
|
| 498 |
-
>
|
| 499 |
<X className="w-4 h-4" />
|
| 500 |
</Button>
|
| 501 |
</div>
|
|
@@ -539,11 +474,7 @@ const demoGroup = useMemo(() => {
|
|
| 539 |
}`}
|
| 540 |
>
|
| 541 |
{isAI ? (
|
| 542 |
-
<img
|
| 543 |
-
src={clareAvatar}
|
| 544 |
-
alt="Clare"
|
| 545 |
-
className="w-full h-full object-cover"
|
| 546 |
-
/>
|
| 547 |
) : (
|
| 548 |
<span className="text-sm">{initials}</span>
|
| 549 |
)}
|
|
@@ -551,24 +482,17 @@ const demoGroup = useMemo(() => {
|
|
| 551 |
|
| 552 |
<div className="flex-1 min-w-0">
|
| 553 |
<div className="flex items-center gap-2">
|
| 554 |
-
<p className="text-sm truncate
|
| 555 |
-
{name}
|
| 556 |
-
</p>
|
| 557 |
{isAI && (
|
| 558 |
<Badge variant="secondary" className="text-xs">
|
| 559 |
AI
|
| 560 |
</Badge>
|
| 561 |
)}
|
| 562 |
</div>
|
| 563 |
-
<p className="text-xs text-muted-foreground truncate">
|
| 564 |
-
{email}
|
| 565 |
-
</p>
|
| 566 |
</div>
|
| 567 |
|
| 568 |
-
<div
|
| 569 |
-
className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"
|
| 570 |
-
title="Online"
|
| 571 |
-
/>
|
| 572 |
</div>
|
| 573 |
);
|
| 574 |
})}
|
|
@@ -577,10 +501,11 @@ const demoGroup = useMemo(() => {
|
|
| 577 |
)}
|
| 578 |
</div>
|
| 579 |
|
| 580 |
-
{/*
|
| 581 |
-
<div className="
|
| 582 |
<Divider />
|
| 583 |
</div>
|
|
|
|
| 584 |
|
| 585 |
{/* ================= MIDDLE (only scroll) ================= */}
|
| 586 |
<div className="flex-1 min-h-0 overflow-hidden">
|
|
@@ -597,12 +522,11 @@ const demoGroup = useMemo(() => {
|
|
| 597 |
|
| 598 |
{/* ================= BOTTOM (fixed, non-scroll) ================= */}
|
| 599 |
<div className="flex-shrink-0">
|
| 600 |
-
{/*
|
| 601 |
-
<div className="
|
| 602 |
<Divider />
|
| 603 |
</div>
|
| 604 |
|
| 605 |
-
|
| 606 |
<div className="px-4 py-4 space-y-2 text-[16px]">
|
| 607 |
<div className="text-muted-foreground">
|
| 608 |
Instructor:
|
|
@@ -646,17 +570,12 @@ const demoGroup = useMemo(() => {
|
|
| 646 |
</div>
|
| 647 |
</div>
|
| 648 |
|
| 649 |
-
{/* Invite Dialog
|
| 650 |
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
|
| 651 |
-
<DialogContent
|
| 652 |
-
className="w-[600px] max-w-[600px] sm:max-w-[600px]"
|
| 653 |
-
style={{ maxWidth: 600 }}
|
| 654 |
-
>
|
| 655 |
<DialogHeader>
|
| 656 |
<DialogTitle>Invite member</DialogTitle>
|
| 657 |
-
<DialogDescription>
|
| 658 |
-
Send a quick email invite with the team details.
|
| 659 |
-
</DialogDescription>
|
| 660 |
</DialogHeader>
|
| 661 |
<div className="space-y-3">
|
| 662 |
<Input
|
|
|
|
| 1 |
// web/src/components/sidebar/LeftSidebar.tsx
|
| 2 |
import React, { useEffect, useMemo, useState } from "react";
|
|
|
|
| 3 |
import { Button } from "../ui/button";
|
| 4 |
import { Input } from "../ui/input";
|
| 5 |
import { Badge } from "../ui/badge";
|
|
|
|
| 32 |
|
| 33 |
import clareAvatar from "../../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
|
| 34 |
|
| 35 |
+
// ✅ Theme-compatible divider (light/dark) + visibility
|
| 36 |
const Divider = () => (
|
| 37 |
+
<div className="w-full border-t border-border/70 dark:border-border flex-shrink-0" />
|
| 38 |
);
|
| 39 |
|
| 40 |
type Props = {
|
|
|
|
| 76 |
availableCourses: CourseDirectoryItem[];
|
| 77 |
|
| 78 |
// optional if you already wired backend
|
| 79 |
+
onRenameGroupName?: (workspaceId: string, newName: string) => Promise<void> | void;
|
|
|
|
|
|
|
|
|
|
| 80 |
onRenameGroupNo?: (workspaceId: string, newNo: number) => Promise<void> | void;
|
| 81 |
};
|
| 82 |
|
|
|
|
| 142 |
const isMyWorkspace = useMemo(() => {
|
| 143 |
const ws: any = currentWorkspace as any;
|
| 144 |
|
|
|
|
| 145 |
if (ws?.isPersonal === true) return true;
|
| 146 |
if (String(ws?.spaceType || "").toLowerCase().includes("my")) return true;
|
| 147 |
if (String(ws?.type || "").toLowerCase().includes("my")) return true;
|
| 148 |
if (String(ws?.kind || "").toLowerCase().includes("my")) return true;
|
| 149 |
|
|
|
|
| 150 |
const id = String(ws?.id || "").toLowerCase();
|
| 151 |
const name = String(ws?.name || "").toLowerCase();
|
| 152 |
if (id.includes("my") || id.includes("personal")) return true;
|
|
|
|
| 156 |
}, [currentWorkspace]);
|
| 157 |
|
| 158 |
const isTeamSpace = useMemo(() => {
|
|
|
|
| 159 |
if (isMyWorkspace) return false;
|
|
|
|
| 160 |
const st = String(spaceType || "").toLowerCase();
|
| 161 |
return st === "group" || st === "team";
|
| 162 |
}, [spaceType, isMyWorkspace]);
|
| 163 |
|
| 164 |
// --------- CourseInfo resolution ---------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
const courseInfo = useMemo((): CourseDirectoryItem | null => {
|
| 166 |
const list = Array.isArray(availableCourses) ? availableCourses : [];
|
| 167 |
|
|
|
|
| 171 |
const hit =
|
| 172 |
list.find((c: any) => norm(c.id) === sel) ||
|
| 173 |
list.find((c: any) => norm(c.name) === sel);
|
| 174 |
+
if (hit) return hit as any;
|
| 175 |
}
|
| 176 |
|
| 177 |
const wsCourse = (currentWorkspace as any)?.courseInfo as
|
|
|
|
| 179 |
| undefined;
|
| 180 |
|
| 181 |
const wsId = norm(wsCourse?.id);
|
| 182 |
+
if (wsId) return (list.find((c: any) => norm(c.id) === wsId) ?? (wsCourse as any)) as any;
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
const wsName = norm(wsCourse?.name);
|
| 185 |
+
if (wsName) return (list.find((c: any) => norm(c.name) === wsName) ?? (wsCourse as any)) as any;
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
return null;
|
| 188 |
}, [availableCourses, currentWorkspace, selectedCourse]);
|
|
|
|
| 194 |
// --------- Group fields ---------
|
| 195 |
const wsGroupNo = useMemo(() => {
|
| 196 |
const ws: any = currentWorkspace as any;
|
| 197 |
+
return ws?.groupNo ?? ws?.groupNumber ?? ws?.groupIndex ?? ws?.group_id ?? ws?.groupId ?? 1;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
}, [currentWorkspace]);
|
| 199 |
|
| 200 |
const wsGroupName = useMemo(() => {
|
|
|
|
| 202 |
return pickAny(ws, ["groupName", "name", "title"]) || "My Group";
|
| 203 |
}, [currentWorkspace]);
|
| 204 |
|
| 205 |
+
// --------- Demo group mapping (My Space only) ---------
|
| 206 |
+
const demoGroupMap: Record<string, { name: string; no: number }> = useMemo(
|
| 207 |
+
() => ({
|
| 208 |
+
"Introduction to AI": { name: "My Space", no: 1 },
|
| 209 |
+
"Machine Learning": { name: "Study Sprint", no: 3 },
|
| 210 |
+
"Data Visualization": { name: "Design Lab", no: 2 },
|
| 211 |
+
}),
|
| 212 |
+
[]
|
| 213 |
+
);
|
| 214 |
+
|
| 215 |
+
const demoGroup = useMemo(() => {
|
| 216 |
+
const hit = demoGroupMap[courseName];
|
| 217 |
+
if (hit) return hit;
|
| 218 |
+
return {
|
| 219 |
+
name: wsGroupName || "My Group",
|
| 220 |
+
no: toIntOrFallback(wsGroupNo, 1),
|
| 221 |
+
};
|
| 222 |
+
}, [demoGroupMap, courseName, wsGroupName, wsGroupNo]);
|
| 223 |
|
| 224 |
const memberCount = (groupMembers || []).length;
|
| 225 |
|
| 226 |
+
// localStorage keys (Team space editing)
|
| 227 |
const groupNameStorageKey = useMemo(
|
| 228 |
() => `clare_group_name__${currentWorkspaceId}`,
|
| 229 |
[currentWorkspaceId]
|
|
|
|
| 241 |
// group no (Team space editable)
|
| 242 |
const [groupNo, setGroupNo] = useState<number>(toIntOrFallback(wsGroupNo, 1));
|
| 243 |
const [editingGroupNo, setEditingGroupNo] = useState(false);
|
| 244 |
+
const [draftGroupNo, setDraftGroupNo] = useState<string>(String(toIntOrFallback(wsGroupNo, 1)));
|
|
|
|
|
|
|
| 245 |
|
| 246 |
// Invite dialog state
|
| 247 |
const [inviteOpen, setInviteOpen] = useState(false);
|
|
|
|
| 257 |
setInviteOpen(false);
|
| 258 |
};
|
| 259 |
|
| 260 |
+
// hydrate persisted editable values (Team space)
|
| 261 |
useEffect(() => {
|
| 262 |
const storedName =
|
| 263 |
+
typeof window !== "undefined" ? window.localStorage.getItem(groupNameStorageKey) : null;
|
|
|
|
|
|
|
| 264 |
const name = storedName && storedName.trim() ? storedName : wsGroupName;
|
| 265 |
setGroupName(name);
|
| 266 |
setDraftGroupName(name);
|
| 267 |
setEditingGroupName(false);
|
| 268 |
|
| 269 |
const storedNo =
|
| 270 |
+
typeof window !== "undefined" ? window.localStorage.getItem(groupNoStorageKey) : null;
|
|
|
|
|
|
|
| 271 |
const no =
|
| 272 |
storedNo && storedNo.trim()
|
| 273 |
? toIntOrFallback(storedNo, toIntOrFallback(wsGroupNo, 1))
|
|
|
|
| 321 |
setEditingGroupNo(false);
|
| 322 |
};
|
| 323 |
|
| 324 |
+
// --------- Contacts ---------
|
| 325 |
const instructorName = (courseInfo as any)?.instructor?.name ?? "N/A";
|
| 326 |
const instructorEmail = String((courseInfo as any)?.instructor?.email ?? "").trim();
|
| 327 |
|
|
|
|
| 331 |
const displayName = useMemo(() => getUserName(user), [user]);
|
| 332 |
|
| 333 |
return (
|
|
|
|
| 334 |
<div className="h-full w-full flex flex-col min-h-0 bg-background text-foreground">
|
| 335 |
{/* ================= TOP (non-scroll) ================= */}
|
| 336 |
<div className="flex-shrink-0">
|
| 337 |
{/* Welcome */}
|
| 338 |
<div className="px-4 pt-6 pb-6">
|
| 339 |
+
<div className="text-[34px] leading-tight font-semibold">Welcome, {displayName}!</div>
|
|
|
|
|
|
|
| 340 |
</div>
|
| 341 |
|
| 342 |
+
{/* Welcome ↔ Course divider + spacing */}
|
| 343 |
+
<div className="mt-4 mb-6">
|
| 344 |
<Divider />
|
| 345 |
</div>
|
| 346 |
|
|
|
|
| 347 |
{/* Course + Group */}
|
| 348 |
+
<div className="px-4 pt-8 pb-10 space-y-4">
|
| 349 |
<div className="text-[30px] leading-tight font-semibold">{courseName}</div>
|
| 350 |
|
|
|
|
| 351 |
{/* ===== My Space ===== */}
|
| 352 |
{!isTeamSpace ? (
|
| 353 |
<div className="space-y-2">
|
| 354 |
+
<div className="text-[18px] font-semibold truncate">{demoGroup.name}</div>
|
| 355 |
+
<div className="text-[18px] font-semibold">Group {demoGroup.no}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
</div>
|
| 357 |
) : (
|
| 358 |
+
/* ===== Team/Group ===== */
|
|
|
|
| 359 |
<div className="rounded-2xl border bg-background overflow-hidden">
|
| 360 |
<div className="px-4 pt-4 pb-3 space-y-3">
|
| 361 |
{/* Line 1: group name editable */}
|
| 362 |
{!editingGroupName ? (
|
| 363 |
<div className="flex items-center gap-2">
|
| 364 |
+
<div className="text-[18px] font-semibold truncate">{groupName || "My Group"}</div>
|
|
|
|
|
|
|
| 365 |
<button
|
| 366 |
type="button"
|
| 367 |
className="inline-flex items-center text-muted-foreground hover:text-foreground"
|
|
|
|
| 383 |
className="h-8 w-[220px]"
|
| 384 |
autoFocus
|
| 385 |
/>
|
| 386 |
+
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={saveGroupName}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
<Check className="w-4 h-4" />
|
| 388 |
</Button>
|
| 389 |
+
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={cancelGroupName}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
<X className="w-4 h-4" />
|
| 391 |
</Button>
|
| 392 |
</div>
|
|
|
|
| 398 |
<Users className="w-4 h-4 text-muted-foreground" />
|
| 399 |
|
| 400 |
{!editingGroupNo ? (
|
| 401 |
+
<div className="text-[16px] font-medium">
|
| 402 |
Group{" "}
|
| 403 |
<span className="inline-flex items-center gap-1">
|
| 404 |
{groupNo}
|
|
|
|
| 415 |
</div>
|
| 416 |
) : (
|
| 417 |
<div className="flex items-center gap-2">
|
| 418 |
+
<div className="text-[16px] font-medium">Group</div>
|
|
|
|
|
|
|
| 419 |
<Input
|
| 420 |
value={draftGroupNo}
|
| 421 |
onChange={(e) => setDraftGroupNo(e.target.value)}
|
|
|
|
| 426 |
className="h-8 w-[80px]"
|
| 427 |
autoFocus
|
| 428 |
/>
|
| 429 |
+
<div className="text-[16px] font-medium">({memberCount})</div>
|
| 430 |
+
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={saveGroupNo}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
<Check className="w-4 h-4" />
|
| 432 |
</Button>
|
| 433 |
+
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={cancelGroupNo}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
<X className="w-4 h-4" />
|
| 435 |
</Button>
|
| 436 |
</div>
|
|
|
|
| 474 |
}`}
|
| 475 |
>
|
| 476 |
{isAI ? (
|
| 477 |
+
<img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
) : (
|
| 479 |
<span className="text-sm">{initials}</span>
|
| 480 |
)}
|
|
|
|
| 482 |
|
| 483 |
<div className="flex-1 min-w-0">
|
| 484 |
<div className="flex items-center gap-2">
|
| 485 |
+
<p className="text-sm truncate">{name}</p>
|
|
|
|
|
|
|
| 486 |
{isAI && (
|
| 487 |
<Badge variant="secondary" className="text-xs">
|
| 488 |
AI
|
| 489 |
</Badge>
|
| 490 |
)}
|
| 491 |
</div>
|
| 492 |
+
<p className="text-xs text-muted-foreground truncate">{email}</p>
|
|
|
|
|
|
|
| 493 |
</div>
|
| 494 |
|
| 495 |
+
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" title="Online" />
|
|
|
|
|
|
|
|
|
|
| 496 |
</div>
|
| 497 |
);
|
| 498 |
})}
|
|
|
|
| 501 |
)}
|
| 502 |
</div>
|
| 503 |
|
| 504 |
+
{/* Course/Group ↔ Saved Chat divider + spacing */}
|
| 505 |
+
<div className="mt-2 mb-8">
|
| 506 |
<Divider />
|
| 507 |
</div>
|
| 508 |
+
</div>
|
| 509 |
|
| 510 |
{/* ================= MIDDLE (only scroll) ================= */}
|
| 511 |
<div className="flex-1 min-h-0 overflow-hidden">
|
|
|
|
| 522 |
|
| 523 |
{/* ================= BOTTOM (fixed, non-scroll) ================= */}
|
| 524 |
<div className="flex-shrink-0">
|
| 525 |
+
{/* Saved Chat ↔ Instructor divider + spacing */}
|
| 526 |
+
<div className="mt-6 mb-4">
|
| 527 |
<Divider />
|
| 528 |
</div>
|
| 529 |
|
|
|
|
| 530 |
<div className="px-4 py-4 space-y-2 text-[16px]">
|
| 531 |
<div className="text-muted-foreground">
|
| 532 |
Instructor:
|
|
|
|
| 570 |
</div>
|
| 571 |
</div>
|
| 572 |
|
| 573 |
+
{/* Invite Dialog */}
|
| 574 |
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
|
| 575 |
+
<DialogContent className="w-[600px] max-w-[600px] sm:max-w-[600px]" style={{ maxWidth: 600 }}>
|
|
|
|
|
|
|
|
|
|
| 576 |
<DialogHeader>
|
| 577 |
<DialogTitle>Invite member</DialogTitle>
|
| 578 |
+
<DialogDescription>Send a quick email invite with the team details.</DialogDescription>
|
|
|
|
|
|
|
| 579 |
</DialogHeader>
|
| 580 |
<div className="space-y-3">
|
| 581 |
<Input
|