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

454 lines
19 KiB
TypeScript

"use client";
import { FeedbackDialog } from "@/components/feedback-dialog";
import { Menu } from "@base-ui/react/menu";
import {
CalendarDays,
ChevronRight,
FileText,
LogOut,
Orbit,
Settings,
WalletCards,
MessageSquareText,
MessageSquareWarning,
SquarePen,
Star,
UserRound,
Users,
} from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import type { ReactNode, Ref } from "react";
import { AppLink } from "@/components/app-link";
import { accountDialogHref, type AccountDialogIntent } from "@/lib/account-dialog-intent";
import { newChatHref, sessionHref } from "@/lib/chat-session-url";
import { prefetchSecondaryPage } from "@/lib/secondary-page-data";
import { prefetchBillingPanel } from "@/lib/billing-panel-loader";
import { prefetchPaymentPackages } from "@/lib/payment-packages-cache";
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";
import { markNewChatClick } from "@/lib/new-chat-timing";
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 settings dialogs behind the
* account menu. Omit `controls` and the same sidebar renders read-only — rows
* are links, nothing writes — and the same account menu's dialog entries
* become links to `/?account=<dialog>` (TASK-self-edit-avatar-menu-20260928 S2).
*
* `/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;
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. */
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 },
{ href: "/people", label: "星盘档案", icon: Users },
] as const;
export function AppSidebar({
sessions,
activeSessionId,
openingSessionId = null,
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: controlledMenuOpen = false,
accountTriggerRef,
newChatDisabled = false,
creatingSession = false,
sessionControls,
onAccountMenuOpenChange: controlledMenuOpenChange,
onNewChat,
onSelectSession,
onOpenProfile,
onOpenGeneral,
onOpenBilling,
onOpenLogout,
}: Partial<AppSidebarControls> = controls ?? {};
/* Off `/` nothing controls the account menu, so it keeps its own open state (S2). */
const [ownMenuOpen, setOwnMenuOpen] = useState(false);
/* 反馈与投诉 is the sidebar's own dialog, so it opens the same way on every page. */
const [feedbackOpen, setFeedbackOpen] = useState(false);
const accountMenuOpen = controls ? controlledMenuOpen : ownMenuOpen;
const onAccountMenuOpenChange = controls ? controlledMenuOpenChange : setOwnMenuOpen;
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();
}
/** Off `/`: the same drawer close, plus the click mark for new-chat timing (BUG-1104). */
function handleNewChatLink() {
markNewChatClick();
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. Nothing is stashed on the way out: a bare
`/` lands on the blank starter home, and the way back to a conversation
is its `?c=` link (BUG-1052). */
function leaveChat() {
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,
};
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)}
onDelete={() => sessionControls.onDelete(session.id)}
/>
) : (
<SidebarSessionRow {...shared} href={sessionHref("", session.id)} onNavigate={closeDrawer} />
)}
</SidebarMenuItem>
);
}
/* One entry of the account menu: on `/` the dialog opens in place; on
another page the entry is a link to `/?account=<dialog>` (S2). */
function accountEntry(
dialog: AccountDialogIntent,
onOpen: (() => void) | undefined,
className: string,
content: ReactNode,
pointer: { onPointerEnter?: () => void; onPointerDown?: () => void } = {},
) {
return onOpen ? (
<Menu.Item className={className} onClick={onOpen} {...pointer}>{content}</Menu.Item>
) : (
<Menu.LinkItem
className={className}
closeOnClick
render={<AppLink href={accountDialogHref(dialog)} onClick={leaveChat} />}
{...pointer}
>
{content}
</Menu.LinkItem>
);
}
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={newChatHref()} onClick={handleNewChatLink}>
<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="最近对话">
{/* 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}>
最近
</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>
{feedbackOpen && account ? (
<FeedbackDialog accountEmail={account.email} sessionId={activeSessionId} onClose={() => setFeedbackOpen(false)} />
) : null}
<SidebarFooter className="sidebar-footer">
{account ? (
/* One account menu on every page. On `/` Home controls it and its
entries open the dialogs in place; elsewhere it keeps its own open
state and the dialog entries go to `/?account=<dialog>`, where Home
opens that dialog (S2). Theme applies where the reader is. */
<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>
{accountEntry("profile", onOpenProfile, "account-menu-item", (
<><UserRound aria-hidden="true" /><span>个人资料</span><ChevronRight aria-hidden="true" /></>
))}
{accountEntry("general", onOpenGeneral, "account-menu-item", (
<><Settings aria-hidden="true" /><span>通用设置</span><ChevronRight aria-hidden="true" /></>
))}
{accountEntry("billing", onOpenBilling, "account-menu-item", (
<><WalletCards aria-hidden="true" /><span>账户与点数</span><small>{account.credits} 点</small></>
), {
onPointerEnter: () => { prefetchBillingPanel(); prefetchPaymentPackages(); },
onPointerDown: () => { prefetchBillingPanel(); prefetchPaymentPackages(); },
})}
<Menu.Item className="account-menu-item" onClick={() => { onAccountMenuOpenChange?.(false); setFeedbackOpen(true); }}>
<MessageSquareWarning aria-hidden="true" /><span>反馈与投诉</span>
</Menu.Item>
<Menu.Separator className="account-menu-separator" />
<ThemePreferenceMenu />
<Menu.Separator className="account-menu-separator" />
{accountEntry("logout", onOpenLogout, "account-menu-item account-menu-danger", (
<><LogOut aria-hidden="true" /><span>退出登录</span></>
))}
{/* The documents the user agreed to, one tap away (2026-09-30). */}
<div className="account-menu-legal">
<Menu.LinkItem href="/terms" target="_blank" rel="noopener">用户协议</Menu.LinkItem>
<span aria-hidden="true">·</span>
<Menu.LinkItem href="/privacy" target="_blank" rel="noopener">隐私政策</Menu.LinkItem>
</div>
</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>
) : (
/* The account is still being read: the same 56px block, inert, with
no link and no spinner. It becomes the menu as soon as it lands. */
<div className="profile-trigger" aria-hidden="true">
<span className="profile-initial">·</span>
</div>
)}
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}