Files
Jyotisha/frontend/src/components/app-sidebar.tsx
T

412 lines
17 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 { AppLink } from "@/components/app-link";
import { persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url";
import { prefetchSecondaryPage } from "@/lib/secondary-page-data";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuLink,
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;
};
/**
* Everything only `/` can supply: the session-management layer with its
* optimistic updates and rollback, and the account menu with the settings
* dialogs behind it. Omit `controls` and the same sidebar renders read-only —
* rows are links, the footer is a link, nothing writes.
*
* `/chart`, `/ephemeris` and `/reports` used to carry a second sidebar
* component of their own, deleted in this round. Two components meant two sets
* of markup: its rows had no `.session-main`, so they lost the 44px minimum,
* the padding and the 2px current-item marker, while `.session-row` still
* reserved 44px for a menu button that was never rendered. Read-only was the
* requirement; a different shape was never part of it.
* See TASK-sidebar-unify-20260916 D1.
*/
export type AppSidebarControls = {
accountMenuOpen: boolean;
accountTriggerRef: Ref<HTMLButtonElement>;
newChatDisabled: boolean;
creatingSession: boolean;
sessionControls: SidebarSessionControls;
onAccountMenuOpenChange: (open: boolean) => void;
onNewChat: () => void;
onSelectSession: (sessionId: string) => void;
onOpenProfile: () => void;
onOpenChartLibrary: () => void;
onOpenGeneral: () => void;
onOpenBilling: () => void;
onOpenLogout: () => void;
};
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 | null;
/** Read-only mode: both reads have settled, however they settled. */
settled?: boolean;
/** Read-only mode: no session, so the footer offers 去登录 instead of an account. */
signedOut?: boolean;
controls?: AppSidebarControls;
};
const NAV_PAGES = [
{ href: "/chart", label: "星盘", icon: Orbit },
{ href: "/ephemeris", label: "星历", icon: CalendarDays },
{ href: "/reports", label: "我的报告", icon: FileText },
] as const;
export function AppSidebar({
sessions,
activeSessionId,
openingSessionId = null,
openErrorSessionId = null,
openErrorMessage = "",
account,
settled = true,
signedOut = false,
controls,
}: AppSidebarProps) {
const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar();
/* Null outside an app-router context (and in unit tests that mount this
component directly), even though the type says string. */
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);
/* Destructured rather than read as `controls.x` at each use site: the React
Compiler rule treats every member of an object that carries a ref as a ref
read during render, and `accountTriggerRef` lives in here. */
const {
accountMenuOpen = false,
accountTriggerRef,
newChatDisabled = false,
creatingSession = false,
sessionControls,
onAccountMenuOpenChange,
onNewChat,
onSelectSession,
onOpenProfile,
onOpenChartLibrary,
onOpenGeneral,
onOpenBilling,
onOpenLogout,
}: Partial<AppSidebarControls> = controls ?? {};
const hasMoreSessions = sessionControls?.hasMore ?? false;
const onLoadMoreSessions = sessionControls?.onLoadMore;
const favoriteSessions = sessions.filter((session) => session.pinned);
const historySessions = sessions.filter((session) => !session.pinned);
const historyGroups = groupSessionsByRecency(historySessions);
useEffect(() => {
if (!hasMoreSessions || !onLoadMoreSessions) return;
const node = loadMoreRef.current;
if (!node) return;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) onLoadMoreSessions();
});
observer.observe(node);
return () => observer.disconnect();
}, [historySessions.length, hasMoreSessions, onLoadMoreSessions]);
useEffect(() => {
if (previousMenuPlacement.current !== menuPlacement && accountMenuOpen) {
onAccountMenuOpenChange?.(false);
}
previousMenuPlacement.current = menuPlacement;
}, [accountMenuOpen, menuPlacement, onAccountMenuOpenChange]);
function closeDrawer() {
if (isMobile) setOpenMobile(false);
}
function handleNewChat() {
onNewChat?.();
closeDrawer();
}
/* Real links. Leaving `/` used to be a full document load: the whole React
tree, the session list and the account went with it, so coming back re-ran
the entire bootstrap from zero. `/login` keeps the hard exit —
it crosses an auth boundary — and `persistLoginSessionReturn()` still
stashes the `?c=` first, so the way back to this session survives. */
function leaveChat() {
persistLoginSessionReturn();
closeDrawer();
}
function handleExpandHistory() {
setOpen(true);
window.requestAnimationFrame(() => {
(firstSessionRef.current ?? historyHeadingRef.current)?.focus();
});
}
function renderSession(session: SidebarSession, index: number) {
const shared = {
session,
active: session.id === activeSessionId,
opening: session.id === openingSessionId,
error: session.id === openErrorSessionId && openErrorMessage ? openErrorMessage : undefined,
};
return (
<SidebarMenuItem key={session.id}>
{sessionControls && onSelectSession ? (
<SidebarSessionRow
{...shared}
ref={index === 0 ? firstSessionRef : undefined}
disabled={sessionControls.disabled}
menuOpen={sessionControls.menuSessionId === session.id}
onMenuOpenChange={(open) => sessionControls.onMenuSessionChange(open ? session.id : null)}
onSelect={() => {
onSelectSession(session.id);
closeDrawer();
}}
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)}
/>
) : (
<SidebarSessionRow {...shared} href={sessionHref("", session.id)} onNavigate={closeDrawer} />
)}
</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>
{controls ? (
<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>
) : (
<SidebarMenuLink className="new-chat" tooltip="新对话" href="/" onClick={closeDrawer}>
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>新建对话</span> : null}
</SidebarMenuLink>
)}
</SidebarMenuItem>
{NAV_PAGES.map(({ href, label, icon: Icon }) => (
<SidebarMenuItem key={href}>
<SidebarMenuLink
className="report-nav-button"
tooltip={label}
isActive={pathname === href || pathname.startsWith(`${href}/`)}
href={href}
onPointerEnter={() => prefetchSecondaryPage(href)}
onPointerDown={() => prefetchSecondaryPage(href)}
onClick={leaveChat}
>
<Icon size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>{label}</span> : null}
</SidebarMenuLink>
</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 ? (
/* Static copy while a read-only list is in flight — never a skeleton. */
<p className="sidebar-empty">
{signedOut ? "登录后可以看到你的对话" : settled ? "暂无对话,点上方「新建对话」开始" : "对话列表读取中"}
</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>
))}
{hasMoreSessions ? <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">
{controls && account ? (
<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>
) : signedOut ? (
/* `/login` crosses an auth boundary, so it stays a document load. */
<a className="profile-trigger" href="/login" onClick={leaveChat}>
<span className="profile-initial" aria-hidden="true">·</span>
{showExpandedContent ? <span><b>去登录</b></span> : null}
</a>
) : (
/* Read-only footer: the same 56px identity block, minus the chevron
and the menu. The account menu and its dialogs live on `/`, so this
goes there rather than growing a second copy of them. */
<AppLink
className="profile-trigger"
href="/"
aria-label={account ? `${account.name},打开账户` : "打开账户"}
onClick={closeDrawer}
>
{account?.avatar
? <UserAvatar avatar={account.avatar} size={32} className="profile-avatar" />
: <span className="profile-initial" aria-hidden="true">{account?.initial ?? "·"}</span>}
{showExpandedContent && account ? <span><b>{account.name}</b></span> : null}
</AppLink>
)}
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}