Spaces:
Running
Running
| // web/src/components/sidebar/LeftSidebar.tsx | |
| import React, { useEffect, useMemo, useState } from "react"; | |
| import { Button } from "../ui/button"; | |
| import { Input } from "../ui/input"; | |
| import { Badge } from "../ui/badge"; | |
| import { SavedChatSection } from "./SavedChatSection"; | |
| import { Pencil, Check, X, MailPlus, Users, GraduationCap } from "lucide-react"; | |
| import { toast } from "sonner"; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogFooter, | |
| DialogHeader, | |
| DialogTitle, | |
| } from "../ui/dialog"; | |
| import type { | |
| SavedChat, | |
| Workspace, | |
| LearningMode, | |
| Language, | |
| SpaceType, | |
| GroupMember, | |
| User, | |
| SavedItem, | |
| } from "../../App"; | |
| import type { CourseDirectoryItem } from "../../lib/courseDirectory"; | |
| import clareAvatar from "../../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png"; | |
| // ✅ Theme-compatible divider (light/dark) + visibility | |
| const Divider = () => ( | |
| <div className="w-full border-t border-border/70 dark:border-border flex-shrink-0" /> | |
| ); | |
| type Props = { | |
| learningMode: LearningMode; | |
| language: Language; | |
| onLearningModeChange: (m: LearningMode) => void; | |
| onLanguageChange: (l: Language) => void; | |
| spaceType: SpaceType; | |
| groupMembers: GroupMember[]; | |
| user: User | null; | |
| onLogin: (u: any) => void; | |
| onLogout: () => void; | |
| isLoggedIn: boolean; | |
| onEditProfile: () => void; | |
| savedItems: SavedItem[]; | |
| recentlySavedId: string | null; | |
| onUnsave: (id: string) => void; | |
| onSave: ( | |
| content: string, | |
| type: "export" | "quiz" | "summary", | |
| saveAsChat?: boolean, | |
| format?: "pdf" | "text", | |
| workspaceId?: string | |
| ) => void; | |
| savedChats: SavedChat[]; | |
| onLoadChat: (chat: SavedChat) => void; | |
| onDeleteSavedChat: (id: string) => void; | |
| onRenameSavedChat: (id: string, newTitle: string) => void; | |
| currentWorkspaceId: string; | |
| workspaces: Workspace[]; | |
| selectedCourse: string; | |
| availableCourses: CourseDirectoryItem[]; | |
| // optional if you already wired backend | |
| onRenameGroupName?: (workspaceId: string, newName: string) => Promise<void> | void; | |
| onRenameGroupNo?: (workspaceId: string, newNo: number) => Promise<void> | void; | |
| }; | |
| function gmailComposeLink(email: string, subject?: string, body?: string) { | |
| const to = `to=${encodeURIComponent(email)}`; | |
| const su = subject ? `&su=${encodeURIComponent(subject)}` : ""; | |
| const bd = body ? `&body=${encodeURIComponent(body)}` : ""; | |
| return `https://mail.google.com/mail/?view=cm&fs=1&${to}${su}${bd}`; | |
| } | |
| function norm(s: any) { | |
| return String(s ?? "").trim().toLowerCase(); | |
| } | |
| function pickAny(obj: any, keys: string[]) { | |
| for (const k of keys) { | |
| const v = obj?.[k]; | |
| if (v !== undefined && v !== null && String(v).trim() !== "") return v; | |
| } | |
| return undefined; | |
| } | |
| function toIntOrFallback(v: any, fb: number) { | |
| const n = Number(v); | |
| if (Number.isFinite(n) && n > 0) return Math.floor(n); | |
| return fb; | |
| } | |
| export function LeftSidebar(props: Props) { | |
| const { | |
| user, | |
| isLoggedIn, | |
| spaceType, | |
| groupMembers, | |
| savedChats, | |
| onLoadChat, | |
| onDeleteSavedChat, | |
| onRenameSavedChat, | |
| currentWorkspaceId, | |
| workspaces, | |
| selectedCourse, | |
| availableCourses, | |
| onRenameGroupName, | |
| onRenameGroupNo, | |
| } = props; | |
| const currentWorkspace = useMemo( | |
| () => workspaces.find((w) => w.id === currentWorkspaceId), | |
| [workspaces, currentWorkspaceId] | |
| ); | |
| const isMyWorkspace = useMemo(() => { | |
| const ws: any = currentWorkspace as any; | |
| if (ws?.isPersonal === true) return true; | |
| if (String(ws?.spaceType || "").toLowerCase().includes("my")) return true; | |
| if (String(ws?.type || "").toLowerCase().includes("my")) return true; | |
| if (String(ws?.kind || "").toLowerCase().includes("my")) return true; | |
| const id = String(ws?.id || "").toLowerCase(); | |
| const name = String(ws?.name || "").toLowerCase(); | |
| if (id.includes("my") || id.includes("personal")) return true; | |
| if (name.includes("my space") || name.includes("personal")) return true; | |
| return false; | |
| }, [currentWorkspace]); | |
| const isTeamSpace = useMemo(() => { | |
| if (isMyWorkspace) return false; | |
| const st = String(spaceType || "").toLowerCase(); | |
| return st === "group" || st === "team"; | |
| }, [spaceType, isMyWorkspace]); | |
| // --------- CourseInfo resolution --------- | |
| const courseInfo = useMemo((): CourseDirectoryItem | null => { | |
| const list = Array.isArray(availableCourses) ? availableCourses : []; | |
| const selRaw = (selectedCourse || "").trim(); | |
| const sel = norm(selRaw); | |
| if (sel) { | |
| const hit = | |
| list.find((c: any) => norm(c.id) === sel) || | |
| list.find((c: any) => norm(c.name) === sel); | |
| if (hit) return hit as any; | |
| } | |
| const wsCourse = (currentWorkspace as any)?.courseInfo as | |
| | { id?: string; name?: string; instructor?: any; teachingAssistant?: any } | |
| | undefined; | |
| const wsId = norm(wsCourse?.id); | |
| if (wsId) return (list.find((c: any) => norm(c.id) === wsId) ?? (wsCourse as any)) as any; | |
| const wsName = norm(wsCourse?.name); | |
| if (wsName) return (list.find((c: any) => norm(c.name) === wsName) ?? (wsCourse as any)) as any; | |
| return null; | |
| }, [availableCourses, currentWorkspace, selectedCourse]); | |
| const courseName = useMemo(() => { | |
| return (courseInfo as any)?.name ?? (selectedCourse || "Course"); | |
| }, [courseInfo, selectedCourse]); | |
| // --------- Group fields --------- | |
| const wsGroupNo = useMemo(() => { | |
| const ws: any = currentWorkspace as any; | |
| return ws?.groupNo ?? ws?.groupNumber ?? ws?.groupIndex ?? ws?.group_id ?? ws?.groupId ?? 1; | |
| }, [currentWorkspace]); | |
| const wsGroupName = useMemo(() => { | |
| const ws: any = currentWorkspace as any; | |
| return pickAny(ws, ["groupName", "name", "title"]) || "My Group"; | |
| }, [currentWorkspace]); | |
| // --------- Demo group mapping (My Space only) --------- | |
| const demoGroupMap: Record<string, { name: string; no: number }> = useMemo( | |
| () => ({ | |
| "Introduction to AI": { name: "My Space", no: 1 }, | |
| "Machine Learning": { name: "Study Sprint", no: 3 }, | |
| "Data Visualization": { name: "Design Lab", no: 2 }, | |
| }), | |
| [] | |
| ); | |
| const demoGroup = useMemo(() => { | |
| const hit = demoGroupMap[courseName]; | |
| if (hit) return hit; | |
| return { | |
| name: wsGroupName || "My Group", | |
| no: toIntOrFallback(wsGroupNo, 1), | |
| }; | |
| }, [demoGroupMap, courseName, wsGroupName, wsGroupNo]); | |
| const memberCount = (groupMembers || []).length; | |
| // localStorage keys (Team space editing) | |
| const groupNameStorageKey = useMemo( | |
| () => `clare_group_name__${currentWorkspaceId}`, | |
| [currentWorkspaceId] | |
| ); | |
| const groupNoStorageKey = useMemo( | |
| () => `clare_group_no__${currentWorkspaceId}`, | |
| [currentWorkspaceId] | |
| ); | |
| // group name (Team space editable) | |
| const [groupName, setGroupName] = useState<string>(wsGroupName); | |
| const [editingGroupName, setEditingGroupName] = useState(false); | |
| const [draftGroupName, setDraftGroupName] = useState<string>(wsGroupName); | |
| // group no (Team space editable) | |
| const [groupNo, setGroupNo] = useState<number>(toIntOrFallback(wsGroupNo, 1)); | |
| const [editingGroupNo, setEditingGroupNo] = useState(false); | |
| const [draftGroupNo, setDraftGroupNo] = useState<string>(String(toIntOrFallback(wsGroupNo, 1))); | |
| // Invite dialog state | |
| const [inviteOpen, setInviteOpen] = useState(false); | |
| const [inviteEmail, setInviteEmail] = useState(""); | |
| const handleSendInvite = () => { | |
| if (!inviteEmail.trim()) { | |
| toast.error("Please enter an email to invite"); | |
| return; | |
| } | |
| toast.success(`Invitation sent to ${inviteEmail}`); | |
| setInviteEmail(""); | |
| setInviteOpen(false); | |
| }; | |
| // hydrate persisted editable values (Team space) | |
| useEffect(() => { | |
| const storedName = | |
| typeof window !== "undefined" ? window.localStorage.getItem(groupNameStorageKey) : null; | |
| const name = storedName && storedName.trim() ? storedName : wsGroupName; | |
| setGroupName(name); | |
| setDraftGroupName(name); | |
| setEditingGroupName(false); | |
| const storedNo = | |
| typeof window !== "undefined" ? window.localStorage.getItem(groupNoStorageKey) : null; | |
| const no = | |
| storedNo && storedNo.trim() | |
| ? toIntOrFallback(storedNo, toIntOrFallback(wsGroupNo, 1)) | |
| : toIntOrFallback(wsGroupNo, 1); | |
| setGroupNo(no); | |
| setDraftGroupNo(String(no)); | |
| setEditingGroupNo(false); | |
| }, [groupNameStorageKey, groupNoStorageKey, wsGroupName, wsGroupNo]); | |
| const saveGroupName = async () => { | |
| const next = (draftGroupName || "").trim() || "My Group"; | |
| setGroupName(next); | |
| setDraftGroupName(next); | |
| setEditingGroupName(false); | |
| if (onRenameGroupName) { | |
| try { | |
| await onRenameGroupName(currentWorkspaceId, next); | |
| return; | |
| } catch {} | |
| } | |
| try { | |
| window.localStorage.setItem(groupNameStorageKey, next); | |
| } catch {} | |
| }; | |
| const cancelGroupName = () => { | |
| setDraftGroupName(groupName); | |
| setEditingGroupName(false); | |
| }; | |
| const saveGroupNo = async () => { | |
| const nextNo = toIntOrFallback(draftGroupNo, groupNo); | |
| setGroupNo(nextNo); | |
| setDraftGroupNo(String(nextNo)); | |
| setEditingGroupNo(false); | |
| if (onRenameGroupNo) { | |
| try { | |
| await onRenameGroupNo(currentWorkspaceId, nextNo); | |
| return; | |
| } catch {} | |
| } | |
| try { | |
| window.localStorage.setItem(groupNoStorageKey, String(nextNo)); | |
| } catch {} | |
| }; | |
| const cancelGroupNo = () => { | |
| setDraftGroupNo(String(groupNo)); | |
| setEditingGroupNo(false); | |
| }; | |
| // --------- Contacts --------- | |
| const instructorName = (courseInfo as any)?.instructor?.name ?? "N/A"; | |
| const instructorEmail = String((courseInfo as any)?.instructor?.email ?? "").trim(); | |
| const taName = (courseInfo as any)?.teachingAssistant?.name ?? "N/A"; | |
| const taEmail = String((courseInfo as any)?.teachingAssistant?.email ?? "").trim(); | |
| return ( | |
| <div className="h-full w-full flex flex-col min-h-0 bg-background text-foreground"> | |
| {/* ================= TOP (non-scroll) ================= */} | |
| <div className="flex-shrink-0"> | |
| {/* ✅ removed Welcome section entirely */} | |
| <div className="mt-2 mb-4"> | |
| <Divider /> | |
| </div> | |
| {/* Course + Group */} | |
| <div className="px-4 pt-5 pb-6 space-y-4"> | |
| {/* Course row with icon (single-color, no container/background) */} | |
| <div className="flex items-start gap-3"> | |
| <div className="w-6 flex items-start justify-center flex-shrink-0 pt-[2px]"> | |
| <GraduationCap className="w-5 h-5 text-muted-foreground" /> | |
| </div> | |
| <div className="text-[22px] leading-snug font-semibold truncate">{courseName}</div> | |
| </div> | |
| {/* ===== My Space (beautified) ===== */} | |
| {!isTeamSpace ? ( | |
| <div className="space-y-2"> | |
| <div className="flex items-center justify-between text-[15px]"> | |
| <span className="text-muted-foreground">Group Name:</span> | |
| <span className="font-semibold truncate max-w-[60%] text-right">{demoGroup.name}</span> | |
| </div> | |
| <div className="flex items-center justify-between text-[15px]"> | |
| <span className="text-muted-foreground">Group Number:</span> | |
| <span className="font-semibold">{demoGroup.no}</span> | |
| </div> | |
| </div> | |
| ) : ( | |
| /* ===== Team/Group (editable card) ===== */ | |
| <div className="rounded-2xl border bg-background overflow-hidden"> | |
| <div className="px-4 pt-4 pb-3 space-y-3"> | |
| {/* Line 1: group name editable */} | |
| {!editingGroupName ? ( | |
| <div className="flex items-center gap-2"> | |
| <div className="text-[18px] font-semibold truncate">{groupName || "My Group"}</div> | |
| <button | |
| type="button" | |
| className="inline-flex items-center text-muted-foreground hover:text-foreground" | |
| onClick={() => setEditingGroupName(true)} | |
| aria-label="Edit group name" | |
| > | |
| <Pencil className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| ) : ( | |
| <div className="flex items-center gap-2"> | |
| <Input | |
| value={draftGroupName} | |
| onChange={(e) => setDraftGroupName(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter") saveGroupName(); | |
| if (e.key === "Escape") cancelGroupName(); | |
| }} | |
| className="h-8 w-[220px]" | |
| autoFocus | |
| /> | |
| <Button size="icon" variant="ghost" className="h-8 w-8" onClick={saveGroupName}> | |
| <Check className="w-4 h-4" /> | |
| </Button> | |
| <Button size="icon" variant="ghost" className="h-8 w-8" onClick={cancelGroupName}> | |
| <X className="w-4 h-4" /> | |
| </Button> | |
| </div> | |
| )} | |
| {/* Line 2: Group {no}(✏️) ({count}) + Invite */} | |
| <div className="flex items-center justify-between gap-3"> | |
| <div className="flex items-center gap-2"> | |
| <Users className="w-4 h-4 text-muted-foreground" /> | |
| {!editingGroupNo ? ( | |
| <div className="text-[16px] font-medium"> | |
| Group{" "} | |
| <span className="inline-flex items-center gap-1"> | |
| {groupNo} | |
| <button | |
| type="button" | |
| className="inline-flex items-center text-muted-foreground hover:text-foreground" | |
| onClick={() => setEditingGroupNo(true)} | |
| aria-label="Edit group number" | |
| > | |
| <Pencil className="w-4 h-4" /> | |
| </button> | |
| </span>{" "} | |
| ({memberCount}) | |
| </div> | |
| ) : ( | |
| <div className="flex items-center gap-2"> | |
| <div className="text-[16px] font-medium">Group</div> | |
| <Input | |
| value={draftGroupNo} | |
| onChange={(e) => setDraftGroupNo(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter") saveGroupNo(); | |
| if (e.key === "Escape") cancelGroupNo(); | |
| }} | |
| className="h-8 w-[80px]" | |
| autoFocus | |
| /> | |
| <div className="text-[16px] font-medium">({memberCount})</div> | |
| <Button size="icon" variant="ghost" className="h-8 w-8" onClick={saveGroupNo}> | |
| <Check className="w-4 h-4" /> | |
| </Button> | |
| <Button size="icon" variant="ghost" className="h-8 w-8" onClick={cancelGroupNo}> | |
| <X className="w-4 h-4" /> | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| <Button | |
| type="button" | |
| variant="secondary" | |
| className="h-9 px-3 text-[13px]" | |
| onClick={() => setInviteOpen(true)} | |
| > | |
| <MailPlus className="w-4 h-4 mr-2" /> | |
| Invite | |
| </Button> | |
| </div> | |
| </div> | |
| <div className="px-3 pb-3 space-y-2"> | |
| {(groupMembers || []).map((member) => { | |
| const isAI = !!(member as any).isAI; | |
| const name = String((member as any).name || ""); | |
| const email = String((member as any).email || ""); | |
| const initials = name | |
| ? name | |
| .split(" ") | |
| .filter(Boolean) | |
| .map((n) => n[0]) | |
| .join("") | |
| .toUpperCase() | |
| : "?"; | |
| return ( | |
| <div | |
| key={(member as any).id} | |
| className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 transition-colors" | |
| > | |
| <div | |
| className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${ | |
| isAI ? "overflow-hidden bg-background" : "bg-muted" | |
| }`} | |
| > | |
| {isAI ? ( | |
| <img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" /> | |
| ) : ( | |
| <span className="text-sm">{initials}</span> | |
| )} | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2"> | |
| <p className="text-sm truncate">{name}</p> | |
| {isAI && ( | |
| <Badge variant="secondary" className="text-xs"> | |
| AI | |
| </Badge> | |
| )} | |
| </div> | |
| <p className="text-xs text-muted-foreground truncate">{email}</p> | |
| </div> | |
| <div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" title="Online" /> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| <div className="mt-1 mb-6"> | |
| <Divider /> | |
| </div> | |
| </div> | |
| {/* ================= MIDDLE (only scroll) ================= */} | |
| <div className="flex-1 min-h-0 overflow-hidden"> | |
| <div className="h-full min-h-0 panelScroll px-0 py-0"> | |
| <SavedChatSection | |
| isLoggedIn={isLoggedIn} | |
| savedChats={savedChats} | |
| onLoadChat={onLoadChat} | |
| onDeleteSavedChat={onDeleteSavedChat} | |
| onRenameSavedChat={onRenameSavedChat} | |
| /> | |
| </div> | |
| </div> | |
| {/* ================= BOTTOM (fixed, non-scroll) ================= */} | |
| <div className="flex-shrink-0"> | |
| <div className="mt-6 mb-4"> | |
| <Divider /> | |
| </div> | |
| <div className="px-4 py-4 space-y-2 text-[16px]"> | |
| <div className="text-muted-foreground"> | |
| Instructor: | |
| {instructorEmail ? ( | |
| <a | |
| href={gmailComposeLink( | |
| instructorEmail, | |
| `[Clare] Question about ${courseName}`, | |
| `Hi ${instructorName},\n\nI have a question about ${courseName}:\n\n(Write your question here)\n\nThanks,\n` | |
| )} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="text-primary hover:underline" | |
| > | |
| {instructorName} | |
| </a> | |
| ) : ( | |
| <span className="text-muted-foreground/60">{instructorName}</span> | |
| )} | |
| </div> | |
| <div className="text-muted-foreground"> | |
| TA: | |
| {taEmail ? ( | |
| <a | |
| href={gmailComposeLink( | |
| taEmail, | |
| `[Clare] Help request for ${courseName}`, | |
| `Hi ${taName},\n\nI need help with ${courseName}:\n\n(Write your question here)\n\nThanks,\n` | |
| )} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="text-primary hover:underline" | |
| > | |
| {taName} | |
| </a> | |
| ) : ( | |
| <span className="text-muted-foreground/60">{taName}</span> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Invite Dialog */} | |
| <Dialog open={inviteOpen} onOpenChange={setInviteOpen}> | |
| <DialogContent className="w-[600px] max-w-[600px] sm:max-w-[600px]" style={{ maxWidth: 600 }}> | |
| <DialogHeader> | |
| <DialogTitle>Invite member</DialogTitle> | |
| <DialogDescription>Send a quick email invite with the team details.</DialogDescription> | |
| </DialogHeader> | |
| <div className="space-y-3"> | |
| <Input | |
| type="email" | |
| placeholder="name@example.com" | |
| value={inviteEmail} | |
| onChange={(e) => setInviteEmail(e.target.value)} | |
| /> | |
| <p className="text-xs text-muted-foreground"> | |
| An invitation email with a join link will be sent to this address. | |
| </p> | |
| </div> | |
| <DialogFooter> | |
| <Button variant="outline" onClick={() => setInviteOpen(false)}> | |
| Cancel | |
| </Button> | |
| <Button onClick={handleSendInvite}>Send invite</Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| </div> | |
| ); | |
| } | |