// web/src/components/sidebar/LeftSidebar.tsx import React, { useMemo, useState } from "react"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Badge } from "../ui/badge"; import { SavedChatSection } from "./SavedChatSection"; import { 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 = () => (
); 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; onRenameGroupNo?: (workspaceId: string, newNo: number) => Promise | 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; } /** * Hard-stop sanitizer to prevent any "pencil/edit" artifacts * from appearing in the Group Name area due to backend-provided strings. */ function sanitizeGroupLabel(v: any, fallback: string) { let s = String(v ?? "").trim(); if (!s) return fallback; // Remove known "edit" text patterns (case-insensitive) s = s.replace(/\b(edit|rename|pencil)\b/gi, "").trim(); // Remove common emoji pencil and variants s = s.replace(/✏️|✏/g, "").trim(); // Collapse excessive whitespace s = s.replace(/\s{2,}/g, " ").trim(); return s || fallback; } export function LeftSidebar(props: Props) { const { isLoggedIn, spaceType, groupMembers, savedChats, onLoadChat, onDeleteSavedChat, onRenameSavedChat, currentWorkspaceId, workspaces, selectedCourse, availableCourses, } = 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 wsGroupNoRaw = useMemo(() => { const ws: any = currentWorkspace as any; return ws?.groupNo ?? ws?.groupNumber ?? ws?.groupIndex ?? ws?.group_id ?? ws?.groupId ?? 1; }, [currentWorkspace]); const wsGroupNameRaw = useMemo(() => { const ws: any = currentWorkspace as any; // If backend sends name/title containing ✏️ etc, we sanitize later before display return pickAny(ws, ["groupName", "name", "title"]) || "My Group"; }, [currentWorkspace]); const memberCount = (groupMembers || []).length; // ✅ Final display values (sanitized) const groupName = useMemo( () => sanitizeGroupLabel(wsGroupNameRaw, "My Group"), [wsGroupNameRaw] ); const groupNo = useMemo( () => toIntOrFallback(wsGroupNoRaw, 1), [wsGroupNoRaw] ); // --------- Demo group mapping (My Space only) --------- const demoGroupMap: Record = useMemo( () => ({ "Introduction to AI": { name: "CS 101 Study Group", 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: sanitizeGroupLabel(wsGroupNameRaw, "My Group"), no: toIntOrFallback(wsGroupNoRaw, 1), }; }, [demoGroupMap, courseName, wsGroupNameRaw, wsGroupNoRaw]); // 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); }; // --------- 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 (
{/* ================= TOP (non-scroll) ================= */}
{/* Course + Group */}
{/* Course row */}
{courseName}
{/* ===== My Space (beautified) ===== */} {!isTeamSpace ? (
Group Name: {demoGroup.name}
Group Number: {demoGroup.no}
) : ( /* ===== Team/Group (no pencil in group name area) ===== */
{/* Line 1: group name (read-only) */}
{groupName}
{/* Line 2: Group {no} ({count}) + Invite */}
Group {groupNo} ({memberCount})
{(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 (
{isAI ? ( Clare ) : ( {initials} )}

{name}

{isAI && ( AI )}

{email}

); })}
)}
{/* ================= MIDDLE (only scroll) ================= */}
{/* ================= BOTTOM (fixed, non-scroll) ================= */}
Instructor:  {instructorEmail ? ( {instructorName} ) : ( {instructorName} )}
TA:  {taEmail ? ( {taName} ) : ( {taName} )}
{/* Invite Dialog */} Invite member Send a quick email invite with the team details.
setInviteEmail(e.target.value)} />

An invitation email with a join link will be sent to this address.

); }