侧栏此前在第一条会话之前压着三段:星盘名字 chip 组、收藏对话 <details>、
历史对话 <details>——两个折叠箭头,底下都没有值得折叠的东西。现在是一条
平铺列表:收藏作为首个标签组(带 13px 星标),其余沿用既有的日期分组。
星盘入口收敛到一处。这里与任务书 D6 的字面写法方向相反:D6 说留侧栏删
账户菜单,我做的是删侧栏留账户菜单。理由——D6 写于侧栏只有 2 个导航项时,
现在是 4 个;且 chip 点下去只是开弹窗(page.tsx 里三个回调都是
openAccountDialog("chart-library")),一组只会开弹窗的名字没有导航价值。
连带删 charts/onSelectChart/onAddChart props、SidebarChart 类型、
sidebarCharts 派生与一批 CSS。
助手头像删除,--assistant-content-inset 由 calc(32px + gap) 归零,
追问 chip 与运行时间轴改为与正文对齐。/jyotish-logo.png 文件保留。
顺带关闭 R2 留下的隐患:入口 pill 36px 低于 44px 触摸推荐值,补
::after inset -4px 热区扩展,并在 touch-target-contract 里接替
已删除的 .chart-nav-chip。
测试 3350 不变,fail 仍 31 且与基线逐条一致;/ 仍 Static;
CSS gzip 41,044→39,825(−3.0%),三轮累计 −3.1%。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
356 lines
14 KiB
TypeScript
356 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { Menu } from "@base-ui/react/menu";
|
|
import {
|
|
CalendarDays,
|
|
ChevronRight,
|
|
FileText,
|
|
LogOut,
|
|
Orbit,
|
|
Settings,
|
|
WalletCards,
|
|
MessageSquareText,
|
|
SquarePen,
|
|
Star,
|
|
UserRound,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { usePathname } from "next/navigation";
|
|
import { useEffect, useRef } from "react";
|
|
import type { Ref } from "react";
|
|
import { persistLoginSessionReturn } from "@/lib/chat-session-url";
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
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";
|
|
import { groupSessionsByRecency } from "@/lib/session-groups";
|
|
|
|
export type SidebarAccount = {
|
|
name: string;
|
|
email: string;
|
|
credits: number;
|
|
initial: string;
|
|
avatar: BeamAvatar | null;
|
|
};
|
|
|
|
export type AppSidebarProps = {
|
|
sessions: readonly SidebarSession[];
|
|
activeSessionId: string | null;
|
|
/** A rectification session whose Case is being opened and hydrated; its row says so, statically. */
|
|
openingSessionId?: string | null;
|
|
/** Open failed for this history session; shown under that row only. */
|
|
openErrorSessionId?: string | null;
|
|
openErrorMessage?: string;
|
|
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;
|
|
onOpenProfile: () => void;
|
|
onOpenChartLibrary: () => void;
|
|
onOpenGeneral: () => void;
|
|
onOpenBilling: () => void;
|
|
onOpenLogout: () => void;
|
|
};
|
|
|
|
export function AppSidebar({
|
|
sessions,
|
|
activeSessionId,
|
|
openingSessionId = null,
|
|
openErrorSessionId = null,
|
|
openErrorMessage = "",
|
|
account,
|
|
accountMenuOpen,
|
|
accountTriggerRef,
|
|
newChatDisabled,
|
|
creatingSession,
|
|
sessionControls,
|
|
onAccountMenuOpenChange,
|
|
onNewChat,
|
|
onOpenReports: _onOpenReports,
|
|
onSelectSession,
|
|
onOpenProfile,
|
|
onOpenChartLibrary,
|
|
onOpenGeneral,
|
|
onOpenBilling,
|
|
onOpenLogout,
|
|
}: AppSidebarProps) {
|
|
const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar();
|
|
const pathname = usePathname();
|
|
const firstSessionRef = useRef<HTMLButtonElement>(null);
|
|
const historyHeadingRef = useRef<HTMLParagraphElement>(null);
|
|
const loadMoreRef = useRef<HTMLDivElement>(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);
|
|
const historyGroups = groupSessionsByRecency(historySessions);
|
|
|
|
useEffect(() => {
|
|
if (!sessionControls.hasMore || !sessionControls.onLoadMore) return;
|
|
const node = loadMoreRef.current;
|
|
if (!node) return;
|
|
const observer = new IntersectionObserver((entries) => {
|
|
if (entries.some((entry) => entry.isIntersecting)) sessionControls.onLoadMore?.();
|
|
});
|
|
observer.observe(node);
|
|
return () => observer.disconnect();
|
|
}, [historySessions.length, sessionControls.hasMore, sessionControls.onLoadMore]);
|
|
|
|
useEffect(() => {
|
|
if (previousMenuPlacement.current !== menuPlacement && accountMenuOpen) {
|
|
onAccountMenuOpenChange(false);
|
|
}
|
|
previousMenuPlacement.current = menuPlacement;
|
|
}, [accountMenuOpen, menuPlacement, onAccountMenuOpenChange]);
|
|
|
|
function handleNewChat() {
|
|
onNewChat();
|
|
if (isMobile) setOpenMobile(false);
|
|
}
|
|
|
|
function leaveChat(path: "/chart" | "/ephemeris" | "/reports") {
|
|
persistLoginSessionReturn();
|
|
window.location.assign(path);
|
|
}
|
|
|
|
function handleOpenReports() {
|
|
leaveChat("/reports");
|
|
if (isMobile) setOpenMobile(false);
|
|
}
|
|
|
|
function handleOpenChart() {
|
|
leaveChat("/chart");
|
|
if (isMobile) setOpenMobile(false);
|
|
}
|
|
|
|
function handleOpenEphemeris() {
|
|
leaveChat("/ephemeris");
|
|
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}
|
|
opening={session.id === openingSessionId}
|
|
error={session.id === openErrorSessionId && openErrorMessage ? openErrorMessage : undefined}
|
|
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="星盘"
|
|
isActive={pathname === "/chart"}
|
|
onClick={handleOpenChart}
|
|
>
|
|
<Orbit size={18} strokeWidth={1.75} aria-hidden="true" />
|
|
{showExpandedContent ? <span>星盘</span> : null}
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton
|
|
className="report-nav-button"
|
|
type="button"
|
|
tooltip="星历"
|
|
isActive={pathname === "/ephemeris" || pathname.startsWith("/ephemeris/")}
|
|
onClick={handleOpenEphemeris}
|
|
>
|
|
<CalendarDays size={18} strokeWidth={1.75} aria-hidden="true" />
|
|
{showExpandedContent ? <span>星历</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="session-nav" aria-label={sessionControls.showingArchived ? "归档记录" : "最近对话"}>
|
|
{/* One flat list. It was three stacked sections — a 星盘列表 chip grid
|
|
and two <details> wrappers for 收藏对话 / 历史对话 — so the nav
|
|
carried two collapse affordances before the first session row.
|
|
Pinned sessions keep their priority as the first labelled group,
|
|
in the same shape as the recency groups under them. */}
|
|
<p className="sidebar-section-label" ref={historyHeadingRef} tabIndex={-1}>
|
|
{sessionControls.showingArchived ? "归档记录" : "最近"}
|
|
</p>
|
|
<SidebarGroupContent className="sidebar-nested">
|
|
{favoriteSessions.length === 0 && historySessions.length === 0 ? (
|
|
<p className="sidebar-empty">暂无对话,点上方「新建对话」开始</p>
|
|
) : (
|
|
<SidebarMenu className="session-list">
|
|
{favoriteSessions.length > 0 ? (
|
|
<div>
|
|
<p className="sidebar-group-label">
|
|
<Star size={13} strokeWidth={1.75} aria-hidden="true" />
|
|
收藏
|
|
</p>
|
|
{favoriteSessions.map((session, index) => renderSession(session, index))}
|
|
</div>
|
|
) : null}
|
|
{historyGroups.map((group) => (
|
|
<div key={group.key}>
|
|
<p className="sidebar-group-label">{group.label}</p>
|
|
{group.sessions.map((session) => renderSession(
|
|
session,
|
|
favoriteSessions.length + historySessions.findIndex((item) => item.id === session.id),
|
|
))}
|
|
</div>
|
|
))}
|
|
{sessionControls.hasMore ? <div ref={loadMoreRef} aria-hidden className="session-list-sentinel" /> : null}
|
|
</SidebarMenu>
|
|
)}
|
|
</SidebarGroupContent>
|
|
</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={onOpenChartLibrary}>
|
|
<Users aria-hidden="true" /><span>星盘资料</span><ChevronRight aria-hidden="true" />
|
|
</Menu.Item>
|
|
<Menu.Item className="account-menu-item" onClick={onOpenGeneral}>
|
|
<Settings aria-hidden="true" /><span>通用设置</span><ChevronRight aria-hidden="true" />
|
|
</Menu.Item>
|
|
<Menu.Item className="account-menu-item" onClick={onOpenBilling}>
|
|
<WalletCards 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>
|
|
);
|
|
}
|