Spaces:
Sleeping
Sleeping
| // 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> | |
| ); | |
| } | |