SarahXia0405's picture
Update web/src/components/Header.tsx
b180cdf verified
// web/src/components/Header.tsx
import React, { useState } from "react";
import { Button } from "./ui/button";
import { Menu, Sun, Moon, Languages, ChevronDown, LogOut, Plus, X, Edit, Star } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "./ui/dropdown-menu";
import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
import type { Workspace, CourseInfo } from "../App";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { toast } from "sonner";
import { ProfileEditor } from "./ProfileEditor";
type UserType = {
name: string;
email: string;
};
type Language = "auto" | "en" | "zh";
interface HeaderProps {
user: UserType | null;
onMenuClick: () => void;
onUserClick: () => void;
isDarkMode: boolean;
onToggleDarkMode: () => void;
language: Language;
onLanguageChange: (lang: Language) => void;
workspaces: Workspace[];
currentWorkspace: Workspace | undefined;
onWorkspaceChange: (workspaceId: string) => void;
onCreateWorkspace?: (payload: {
name: string;
category: "course" | "personal";
courseId?: string;
invites: string[];
}) => void;
onLogout: () => void;
availableCourses?: CourseInfo[];
onUserUpdate?: (user: UserType) => void;
// ✅ NEW: controlled review-star display + click behavior
reviewStarOpacity?: number; // 0..1
reviewEnergyPct?: number; // 0..100
onStarClick?: () => void; // recommended: switch to Review
}
export function Header({
user,
onMenuClick,
onUserClick,
isDarkMode,
onToggleDarkMode,
language,
onLanguageChange,
workspaces,
currentWorkspace,
onWorkspaceChange,
onLogout,
onCreateWorkspace,
availableCourses = [],
onUserUpdate,
reviewStarOpacity,
reviewEnergyPct,
onStarClick,
}: HeaderProps) {
const [showProfileEditor, setShowProfileEditor] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [workspaceName, setWorkspaceName] = useState("");
const [category, setCategory] = useState<"course" | "personal">("course");
const [courseId, setCourseId] = useState("");
const [inviteEmail, setInviteEmail] = useState("");
const [invites, setInvites] = useState<string[]>([]);
const opacity = typeof reviewStarOpacity === "number" ? reviewStarOpacity : 0.15;
const energy = typeof reviewEnergyPct === "number" ? reviewEnergyPct : Math.round(opacity * 100);
const getStarStyle = () => {
if (energy <= 0) {
return { fill: "transparent", stroke: "white", strokeWidth: 1.5, strokeDasharray: "2 2" };
}
if (energy >= 60) return { fill: "#fbbf24", stroke: "#fbbf24", strokeWidth: 0 };
if (energy >= 25) return { fill: "#fcd34d", stroke: "#fcd34d", strokeWidth: 0 };
return { fill: "#fde68a", stroke: "#fde68a", strokeWidth: 0 };
};
const addInvite = () => {
const email = inviteEmail.trim();
if (!email) return;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
toast.error("Please enter a valid email");
return;
}
if (invites.includes(email)) return;
setInvites((prev) => [...prev, email]);
setInviteEmail("");
};
const removeInvite = (email: string) => {
setInvites((prev) => prev.filter((e) => e !== email));
};
const handleCreate = () => {
if (!workspaceName.trim()) {
toast.error("Please enter a workspace name");
return;
}
if (category === "course" && !courseId) {
toast.error("Please select a course");
return;
}
if (invites.length === 0) {
toast.error("Please add at least one member");
return;
}
onCreateWorkspace?.({
name: workspaceName.trim(),
category,
courseId: courseId || undefined,
invites,
});
setWorkspaceName("");
setCourseId("");
setCategory("course");
setInvites([]);
setInviteEmail("");
setCreateOpen(false);
};
return (
<header className="h-16 border-b border-border bg-card px-4 lg:px-6 flex items-center justify-between sticky top-0 z-[100]">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" className="lg:hidden" onClick={onMenuClick}>
<Menu className="h-5 w-5" />
</Button>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center">
<img src={clareAvatar} alt="Clare AI" className="w-full h-full object-cover" />
</div>
<div>
<h1
className="text-lg sm:text-xl tracking-tight"
style={{ fontFamily: "Inter, sans-serif", fontWeight: 600, letterSpacing: "-0.02em" }}
>
Clare{" "}
<span className="text-sm font-bold text-muted-foreground hidden sm:inline ml-2">
Your Personalized AI Tutor
</span>
</h1>
<p className="text-xs text-muted-foreground hidden sm:block">
Personalized guidance, review, and intelligent reinforcement
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Change language">
<Languages className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onLanguageChange("auto")}>
{language === "auto" && "✓ "}Auto
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onLanguageChange("en")}>
{language === "en" && "✓ "}English
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onLanguageChange("zh")}>
{language === "zh" && "✓ "}简体中文
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="icon" onClick={onToggleDarkMode} aria-label="Toggle dark mode">
{isDarkMode ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</Button>
{user && currentWorkspace ? (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2 pl-2 pr-3" aria-label="Switch workspace">
<img
src={currentWorkspace.avatar}
alt={currentWorkspace.name}
className="w-6 h-6 rounded-full object-cover"
/>
<span className="hidden sm:inline max-w-[120px] truncate">{currentWorkspace.name}</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[14rem]">
{workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => onWorkspaceChange(workspace.id)}
className={`gap-3 ${currentWorkspace.id === workspace.id ? "bg-accent" : ""}`}
>
<img
src={workspace.avatar}
alt={workspace.name}
className="w-6 h-6 rounded-full object-cover flex-shrink-0"
/>
<span className="truncate">{workspace.name}</span>
{currentWorkspace.id === workspace.id && <span className="ml-auto text-primary"></span>}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
<span>New Group Workspace</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Profile Avatar Button */}
<div className="relative inline-block">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full" aria-label="User profile">
<img
src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`}
alt={user.name}
className="w-8 h-8 rounded-full object-cover"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="px-2 py-1.5">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{user.name}</p>
<p className="text-xs text-muted-foreground truncate">
ID: {user.email.split("@")[0] || user.email}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Star
className="w-4 h-4"
style={{
...getStarStyle(),
opacity,
filter: energy >= 85 ? "drop-shadow(0 0 2px rgba(251, 191, 36, 0.8))" : "none",
}}
/>
<div className="flex flex-col items-end">
<span className="text-xs font-medium">{energy}%</span>
<div className="w-12 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-amber-400 to-yellow-500 transition-all duration-300"
style={{ width: `${energy}%` }}
/>
</div>
</div>
</div>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowProfileEditor(true)}>
<Edit className="h-4 w-4 mr-2" />
Edit Profile
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
<LogOut className="h-4 w-4 mr-2" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Star badge in top-right corner of avatar */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute cursor-pointer z-20 pointer-events-auto bg-transparent border-0 p-0"
style={{
top: "-8px",
right: "-16px",
opacity,
transition: "opacity 0.3s ease-in-out",
filter: energy >= 85
? "drop-shadow(0 0 4px rgba(251, 191, 36, 0.8)) drop-shadow(0 0 8px rgba(251, 191, 36, 0.4))"
: "none",
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onStarClick?.();
}}
aria-label="Review energy"
title="Review energy"
>
<Star
className="w-5 h-5"
style={{
...getStarStyle(),
opacity,
filter: energy >= 85 ? "drop-shadow(0 0 2px rgba(251, 191, 36, 1))" : "none",
}}
/>
</button>
</TooltipTrigger>
<TooltipContent
className="z-[200] border border-amber-300/30 shadow-md"
style={{
zIndex: 200,
backgroundColor: "rgba(251, 191, 36, 0.95)",
color: "rgb(28, 25, 23)",
}}
sideOffset={5}
>
<div className="space-y-1">
<p className="text-sm font-medium">Energy: {energy}%</p>
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-amber-400 to-yellow-500 transition-all duration-300"
style={{ width: `${energy}%` }}
/>
</div>
<p className="text-xs opacity-80">Enter Review and complete at least 1 action today to recharge.</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</>
) : null}
</div>
{/* Create Group Workspace Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent
className="w-[600px] max-w-[600px] sm:max-w-[600px] z-[1001] pointer-events-auto"
style={{ width: 600, maxWidth: 600, zIndex: 1001 }}
overlayClassName="!z-[99]"
overlayStyle={{
top: "64px",
left: 0,
right: 0,
bottom: 0,
zIndex: 99,
position: "fixed",
}}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Create Group Workspace</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="ws-name">Workspace Name</Label>
<Input
id="ws-name"
value={workspaceName}
onChange={(e) => setWorkspaceName(e.target.value)}
placeholder="e.g., CS 101 Study Group"
/>
</div>
<div className="space-y-2">
<Label>Category</Label>
<RadioGroup
value={category}
onValueChange={(val) => setCategory(val as "course" | "personal")}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem id="cat-course" value="course" />
<Label htmlFor="cat-course">Course</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem id="cat-personal" value="personal" />
<Label htmlFor="cat-personal">Personal Interest</Label>
</div>
</RadioGroup>
</div>
{category === "course" && (
<div className="space-y-2">
<Label htmlFor="course-select">Course Name</Label>
<Select value={courseId} onValueChange={setCourseId}>
<SelectTrigger id="course-select">
<SelectValue placeholder="Select a course" />
</SelectTrigger>
<SelectContent>
{availableCourses.map((course) => (
<SelectItem key={course.id} value={course.id}>
{course.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label>Invite Members (emails)</Label>
<div className="flex gap-2">
<Input
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Enter email and click Add"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addInvite();
}
}}
/>
<Button variant="secondary" onClick={addInvite}>
Add
</Button>
</div>
{invites.length > 0 && (
<div className="flex flex-wrap gap-2">
{invites.map((email) => (
<span key={email} className="inline-flex items-center px-2 py-1 rounded-full bg-muted text-sm">
{email}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 ml-1"
onClick={() => removeInvite(email)}
aria-label={`Remove ${email}`}
>
<X className="h-3 w-3" />
</Button>
</span>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreate}>Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Profile Editor Dialog */}
{user && showProfileEditor && (
<ProfileEditor
user={user}
onSave={(updatedUser) => {
if (onUserUpdate) onUserUpdate(updatedUser);
setShowProfileEditor(false);
}}
onClose={() => setShowProfileEditor(false)}
/>
)}
</header>
);
}