187ef6ae24
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
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|