Files
Jyotisha/frontend/src/components/app-sidebar.tsx
T
Jesse_Chen 187ef6ae24
Independent Staging Quality Gate / validate (push) Successful in 10m4s
Independent Staging Quality Gate / publish (push) Successful in 24m59s
feat(frontend): put a light/dark/system control in the account menu
Appearance is a radio group inside the avatar menu rather than a loose
button, so arrow keys reach it and the current choice is announced.
Picking one keeps the menu open, so the change is visible where it was
made.

"跟随系统" removes data-theme instead of writing a third value — the
media query has nothing to match otherwise. A synchronous script at the
top of <head> re-applies a pinned choice before the first paint; going
through next/script with any strategy would defer it and bring the flash
straight back, so the test asserts a plain script tag. Blocked storage
degrades to following the OS instead of throwing.

The stored value is browser state, so it is read through
useSyncExternalStore rather than synced into React state in an effect,
which also trips the cascading-render lint rule. localStorage only fires
`storage` in other tabs, so a same-tab write notifies its own listeners
and every open tab stays in step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
2026-08-29 12:12:06 +00:00

324 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Menu } from "@base-ui/react/menu";
import {
ChevronDown,
ChevronRight,
Clock3,
FileText,
Gift,
LogOut,
MessageSquareText,
SquarePen,
Star,
UserPlus,
UserRound,
Users,
} from "lucide-react";
import { useEffect, useRef } from "react";
import type { Ref } from "react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
import {
SidebarSessionRow,
type SidebarSession,
type SidebarSessionControls,
} from "@/components/sidebar-session-row";
import { ThemePreferenceMenu } from "@/components/theme-preference-menu";
import { UserAvatar } from "@/components/user-avatar";
import type { BeamAvatar } from "@/lib/beam-avatar";
export type SidebarAccount = {
name: string;
email: string;
credits: number;
initial: string;
avatar: BeamAvatar | null;
};
export type SidebarChart = {
readonly id: string;
readonly name: string;
readonly role: "self" | "other";
};
export type AppSidebarProps = {
sessions: readonly SidebarSession[];
charts: readonly SidebarChart[];
activeSessionId: string | null;
account: SidebarAccount;
accountMenuOpen: boolean;
accountTriggerRef: Ref<HTMLButtonElement>;
newChatDisabled: boolean;
creatingSession: boolean;
sessionControls: SidebarSessionControls;
onAccountMenuOpenChange: (open: boolean) => void;
onNewChat: () => void;
onOpenReports: () => void;
onSelectSession: (sessionId: string) => void;
onSelectChart: (chartId: string) => void;
onAddChart: () => void;
onOpenProfile: () => void;
onOpenRedeem: () => void;
onOpenLogout: () => void;
};
export function AppSidebar({
sessions,
charts,
activeSessionId,
account,
accountMenuOpen,
accountTriggerRef,
newChatDisabled,
creatingSession,
sessionControls,
onAccountMenuOpenChange,
onNewChat,
onOpenReports,
onSelectSession,
onSelectChart,
onAddChart,
onOpenProfile,
onOpenRedeem,
onOpenLogout,
}: AppSidebarProps) {
const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar();
const firstSessionRef = useRef<HTMLButtonElement>(null);
const historyHeadingRef = useRef<HTMLElement>(null);
const isCollapsedDesktop = state === "collapsed" && !isMobile;
const showExpandedContent = !isCollapsedDesktop;
const menuPlacement = `${viewport}:${state}`;
const previousMenuPlacement = useRef(menuPlacement);
const favoriteSessions = sessions.filter((session) => session.pinned);
const historySessions = sessions.filter((session) => !session.pinned);
useEffect(() => {
if (previousMenuPlacement.current !== menuPlacement && accountMenuOpen) {
onAccountMenuOpenChange(false);
}
previousMenuPlacement.current = menuPlacement;
}, [accountMenuOpen, menuPlacement, onAccountMenuOpenChange]);
function handleNewChat() {
onNewChat();
if (isMobile) setOpenMobile(false);
}
function handleOpenReports() {
onOpenReports();
if (isMobile) setOpenMobile(false);
}
function handleExpandHistory() {
setOpen(true);
window.requestAnimationFrame(() => {
(firstSessionRef.current ?? historyHeadingRef.current)?.focus();
});
}
function renderSession(session: SidebarSession, index: number) {
return (
<SidebarMenuItem key={session.id}>
<SidebarSessionRow
ref={index === 0 ? firstSessionRef : undefined}
session={session}
active={session.id === activeSessionId}
disabled={sessionControls.disabled}
menuOpen={sessionControls.menuSessionId === session.id}
onMenuOpenChange={(open) => sessionControls.onMenuSessionChange(open ? session.id : null)}
onSelect={() => {
onSelectSession(session.id);
if (isMobile) setOpenMobile(false);
}}
onTogglePinned={() => sessionControls.onTogglePinned(session.id)}
onRename={() => sessionControls.onRename(session.id)}
onShare={() => sessionControls.onShare(session.id)}
onToggleArchived={() => sessionControls.onToggleArchived(session.id)}
onDelete={() => sessionControls.onDelete(session.id)}
/>
</SidebarMenuItem>
);
}
return (
<Sidebar className="sidebar" aria-label="对话导航">
<SidebarHeader className="sidebar-header">
<div className="brand-row">
<span className="brand-mark" aria-hidden="true" />
{showExpandedContent ? <strong>Jyotisha</strong> : null}
</div>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
className="new-chat"
type="button"
tooltip="新对话"
disabled={newChatDisabled}
onClick={handleNewChat}
>
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>{creatingSession ? "正在创建" : "新建对话"}</span> : null}
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
className="report-nav-button"
type="button"
tooltip="我的报告"
onClick={handleOpenReports}
>
<FileText size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span></span> : null}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
{showExpandedContent ? (
<>
<SidebarGroup className="chart-nav" aria-label="星盘列表">
<div className="session-nav-header">
<SidebarGroupLabel className="sidebar-label">
<Users size={18} strokeWidth={1.75} aria-hidden="true" />
</SidebarGroupLabel>
<button
className="session-nav-toggle"
type="button"
aria-label="添加星盘"
onClick={onAddChart}
>
<UserPlus size={18} strokeWidth={1.75} aria-hidden="true" />
</button>
</div>
<SidebarGroupContent className="sidebar-nested">
{charts.length === 0 ? <p className="sidebar-empty"></p> : (
<div className="chart-nav-list">
{charts.map((chart) => (
<button
className="chart-nav-chip"
key={chart.id}
type="button"
onClick={() => onSelectChart(chart.id)}
>
{chart.name}
</button>
))}
</div>
)}
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup className="session-nav" aria-label="收藏对话">
<details className="sidebar-section" open>
<summary className="sidebar-section-summary">
<Star size={18} strokeWidth={1.75} aria-hidden="true" />
<span></span>
<ChevronDown className="sidebar-section-chevron" size={16} strokeWidth={1.75} aria-hidden="true" />
</summary>
<SidebarGroupContent className="sidebar-nested">
{favoriteSessions.length === 0 ? <p className="sidebar-empty"></p> : (
<SidebarMenu className="session-list">
{favoriteSessions.map((session, index) => renderSession(session, index))}
</SidebarMenu>
)}
</SidebarGroupContent>
</details>
</SidebarGroup>
<SidebarGroup className="session-nav" aria-label={sessionControls.showingArchived ? "归档记录" : "历史对话"}>
<details className="sidebar-section" open>
<summary className="sidebar-section-summary" ref={historyHeadingRef} tabIndex={-1}>
<Clock3 size={18} strokeWidth={1.75} aria-hidden="true" />
<span>{sessionControls.showingArchived ? "归档记录" : "历史对话"}</span>
<ChevronDown className="sidebar-section-chevron" size={16} strokeWidth={1.75} aria-hidden="true" />
</summary>
<SidebarGroupContent className="sidebar-nested">
{historySessions.length === 0 ? <p className="sidebar-empty"></p> : (
<SidebarMenu className="session-list">
{historySessions.map((session, index) => renderSession(session, favoriteSessions.length + index))}
</SidebarMenu>
)}
</SidebarGroupContent>
</details>
</SidebarGroup>
</>
) : (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
type="button"
tooltip="聊天记录"
aria-label="聊天记录"
onClick={handleExpandHistory}
>
<MessageSquareText size={18} strokeWidth={1.75} aria-hidden="true" />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
)}
</SidebarContent>
<SidebarFooter className="sidebar-footer">
<Menu.Root open={accountMenuOpen} onOpenChange={onAccountMenuOpenChange} modal={false}>
<Menu.Trigger
className="profile-trigger"
ref={accountTriggerRef}
type="button"
>
{account.avatar
? <UserAvatar avatar={account.avatar} size={32} className="profile-avatar" />
: <span className="profile-initial" aria-hidden="true">{account.initial}</span>}
{showExpandedContent ? <span><b>{account.name}</b></span> : null}
{showExpandedContent ? <ChevronRight className={accountMenuOpen ? "chevron is-open" : "chevron"} aria-hidden="true" /> : null}
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner
side={isMobile || state === "expanded" ? "top" : "right"}
align={isMobile || state === "expanded" ? "end" : "center"}
sideOffset={8}
collisionPadding={12}
>
<Menu.Popup className="account-menu-popup" aria-label="账户菜单">
<div className="account-menu-identity">
{account.avatar
? <UserAvatar avatar={account.avatar} size={40} className="account-menu-avatar" />
: <span className="account-menu-avatar" aria-hidden="true">{account.initial}</span>}
<span><b>{account.name}</b><small>{account.email}</small></span>
</div>
<Menu.Item className="account-menu-item" onClick={onOpenProfile}>
<UserRound aria-hidden="true" /><span></span><ChevronRight aria-hidden="true" />
</Menu.Item>
<Menu.Item className="account-menu-item" onClick={onOpenRedeem}>
<Gift aria-hidden="true" /><span></span><small>{account.credits} </small>
</Menu.Item>
<Menu.Separator className="account-menu-separator" />
<ThemePreferenceMenu />
<Menu.Separator className="account-menu-separator" />
<Menu.Item className="account-menu-item account-menu-danger" onClick={onOpenLogout}>
<LogOut aria-hidden="true" /><span>退</span>
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}