SarahXia0405's picture
Update web/src/components/sidebar/LeftSidebar.tsx
105f096 verified
// 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:&nbsp;
{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:&nbsp;
{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>
);
}