refactor(ui): 侧栏统一成一个组件,次级页共享外壳,跳转不再整页刷新
BUG-744 / BUG-745 / BUG-746,任务书 TASK-sidebar-unify-20260916。 T1 侧栏只剩一个组件。`AppSidebar` 收编次级页那份另写的侧栏:会话操作与 账户菜单收进可选的 `controls`,不传就渲染只读模式。只读行仍是同一个 `SidebarSessionRow`、同一套 `.session-row > .session-main` 标记,只是 `.session-main` 是 `<Link>`、不渲染菜单按钮,并用 `data-readonly="true"` 去掉那一列从不使用的 44px 空位;页脚是同一个 56px `.profile-trigger`, 渲染成去 `/` 的链接。只读模式只少菜单按钮、chevron、账户菜单三样。 `app-nav-rail.tsx`、`use-nav-rail.ts` 与 `.nav-rail-*` 两段 CSS 删除。 T2 四个次级路由移进 `app/(secondary)/` 路由组,`layout.tsx` 承载 `SidebarProvider + AppSidebar(只读) + SidebarInset`。`SecondaryShell` 拆剩 46px 顶栏并改名 `SecondaryHeader`,14 处调用同步。路由组不进 URL,四个 地址与四个渲染标记均未变。 T3 `sidebar-data-cache.ts`:模块级、按账户 id 键、60 秒的内存缓存,同步读 再后台刷新,不落 localStorage。`use-session-management.ts` 的新建 / 重命名 / 删除 / 归档 / 收藏成功后失效,401 清空。 T4 三个页面项改 `<SidebarMenuLink href=…>` 客户端跳转,`persistLoginSessionReturn()` 保留在 `onClick` 里,`/login` 仍是硬跳转。顺带删掉从未被调用的死 prop `onOpenReports`;它删掉后 `page.tsx` 的 `router` 再无消费者,`useConsultationRun` 里同样解构成 `_router` 的死参数一并删。 T5 折叠状态存 localStorage 的 `sidebar_state`(不用 cookie:`/`、`/chart`、 `/ephemeris` 都是 Static,服务端读 cookie 会让三条路由掉出静态渲染)。移动端 抽屉不记。整页加载首帧仍可能闪一下,属让步顺序第 1 条,写在 BUG-746 与真机清单。 `Home()` 的 useState 36 / useRef 37 均未增长,`page.tsx` 净删 1 行。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
302ff08504
commit
d9d347236f
@@ -1,7 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { ChartPageRoute } from "@/components/chart-page/chart-page-view";
|
||||
import "../site-styles";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "星盘 · Jyotisha",
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { EphemerisPage } from "@/components/ephemeris/ephemeris-page";
|
||||
import "../site-styles";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "星历 · Jyotisha",
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { useSidebarData } from "@/hooks/use-sidebar-data";
|
||||
import "../site-styles";
|
||||
|
||||
/**
|
||||
* One shell for `/chart`, `/ephemeris`, `/reports` and `/reports/[reportId]`.
|
||||
*
|
||||
* A route group changes no URL: the four routes keep their paths and their own
|
||||
* `metadata` and `dynamic` declarations. What it changes is who owns the shell.
|
||||
* Each page used to render `SecondaryShell`, so each carried a provider and a
|
||||
* sidebar of its own; stepping between two of them tore the nav down and built
|
||||
* it again, list request included. Hoisting it here means the sidebar is mounted
|
||||
* once per visit to this section, and `useSidebarData` runs once with it.
|
||||
*
|
||||
* The sidebar is read-only on purpose — no renaming, archiving, deleting or
|
||||
* account menu. Those are backed by `Home()`'s optimistic-update and rollback
|
||||
* layer, and none of these four pages offers session management
|
||||
* (TASK-cend-surfaces-claude-alignment-20260916 D9, carried forward by
|
||||
* TASK-sidebar-unify-20260916 D1).
|
||||
*/
|
||||
export default function SecondaryLayout({ children }: { children: ReactNode }) {
|
||||
const { sessions, account, settled, signedOut } = useSidebarData();
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<main className="chat-app">
|
||||
<AppSidebar
|
||||
sessions={sessions}
|
||||
activeSessionId={null}
|
||||
account={account}
|
||||
settled={settled}
|
||||
signedOut={signedOut}
|
||||
/>
|
||||
<SidebarInset className="chat-panel secondary-panel">
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { useEffect } from "react";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import "../../site-styles";
|
||||
import "../../../site-styles";
|
||||
|
||||
export default function ReportError({
|
||||
error,
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import "../../site-styles";
|
||||
import "../../../site-styles";
|
||||
|
||||
export default function ReportLoading() {
|
||||
return (
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import "../../site-styles";
|
||||
import "../../../site-styles";
|
||||
|
||||
export default function ReportNotFound() {
|
||||
return (
|
||||
-1
@@ -1,7 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { PersonalReportPage } from "@/components/personal-report/personal-report-page";
|
||||
import "../../site-styles";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { PersonalReportCenter } from "@/components/personal-report/personal-report-center";
|
||||
import "../site-styles";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -707,8 +707,8 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.brand-row strong { font-weight: 400; }
|
||||
.brand-mark, .auth-brand span { width: 32px; height: 32px; border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--ring-hairline); }
|
||||
.auth-story-brand img { width: 32px; height: 32px; border-radius: 50%; object-fit: contain; box-shadow: 0 0 0 1px var(--ring-hairline); }
|
||||
.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; font-size: var(--type-body-sm); line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; }
|
||||
.report-nav-button { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); margin: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-foreground); cursor: pointer; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; }
|
||||
.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; text-decoration: none; font-size: var(--type-body-sm); line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; }
|
||||
.report-nav-button { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); margin: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-decoration: none; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; }
|
||||
.report-nav-button:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
|
||||
.report-nav-button[data-active="true"] {
|
||||
position: relative;
|
||||
@@ -769,9 +769,13 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; border-radius: var(--radius-lg); color: var(--sidebar-foreground); transition: background-color 120ms ease-out, box-shadow 120ms ease-out, color 120ms ease-out; }
|
||||
.session-row:hover, .session-row:focus-within { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
|
||||
.session-row:has(.session-main[data-active="true"]) { background: var(--sidebar-accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 22%, transparent); color: var(--sidebar-accent-foreground); }
|
||||
.session-main { position: relative; width: 100%; min-height: 44px; display: grid; gap: 2px; padding: var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: inherit; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; }
|
||||
.session-main { position: relative; width: 100%; min-height: 44px; display: grid; gap: 2px; padding: var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: inherit; cursor: pointer; text-align: left; text-decoration: none; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; }
|
||||
.session-main[data-active="true"] { color: var(--sidebar-accent-foreground); background: transparent; }
|
||||
.session-main[data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: 0; width: 2px; background: var(--sidebar-ring); }
|
||||
/* Read-only rows (the secondary pages) render no menu trigger, so the 44px
|
||||
column the interactive row reserves for one would be dead space at the end
|
||||
of every title. Everything else about the row is the same markup. */
|
||||
.session-row[data-readonly="true"] { grid-template-columns: minmax(0, 1fr); }
|
||||
.session-title { min-width: 0; display: flex; align-items: center; gap: var(--space-1); overflow: hidden; line-height: 1.35; font-size: var(--type-caption); font-weight: 500; }
|
||||
.session-title > svg { width: 14px; height: 14px; flex: 0 0 auto; color: currentColor; }
|
||||
/* A rectification session whose Case is being opened: a static note, no spinner after the reveal. */
|
||||
@@ -813,7 +817,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.session-actions .session-action-danger, .session-actions .session-action-danger > svg { color: var(--color-danger); }
|
||||
.session-actions .session-action-danger[data-highlighted] { background: var(--color-danger-muted); }
|
||||
.sidebar-footer { position: relative; margin-top: 0; padding-top: 10px; border-top: 1px solid var(--sidebar-border); }
|
||||
.profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); }
|
||||
.profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-align: left; text-decoration: none; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); }
|
||||
.user-avatar { display: inline-grid; flex: 0 0 auto; overflow: hidden; place-items: center; border-radius: 50%; background: var(--color-action-soft); }
|
||||
.user-avatar > svg { width: 100%; height: 100%; display: block; }
|
||||
.profile-avatar { width: 32px; height: 32px; }
|
||||
@@ -2023,36 +2027,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* The rail is the chat sidebar minus every write action. It reuses the sidebar
|
||||
surface and row styles; only the identity block differs, because there is no
|
||||
account menu to open from here. */
|
||||
.nav-rail-row { width: 100%; }
|
||||
.nav-rail-identity {
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--sidebar-foreground);
|
||||
font-size: var(--type-body-sm);
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-rail-identity > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-rail-identity > small {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
button.nav-rail-identity { cursor: pointer; }
|
||||
button.nav-rail-identity:hover { background: var(--sidebar-accent); }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* Replaces the three `*-shell { padding-inline }` rules the standalone pages
|
||||
each carried. Same global 767px cut, one rule instead of three. */
|
||||
|
||||
+34
-36
@@ -3,7 +3,6 @@
|
||||
import "@/app/site-styles";
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent } from "react";
|
||||
@@ -239,7 +238,6 @@ const BillingPanel = dynamic(
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||
const [activeAccountDialog, setActiveAccountDialog] = useState<AccountDialog | null>(null);
|
||||
const [chartLibrary, setChartLibrary] = useState<ChartLibraryRecord[]>([]);
|
||||
@@ -739,7 +737,6 @@ export default function Home() {
|
||||
pendingConsultation,
|
||||
pendingSessionId,
|
||||
profile,
|
||||
router,
|
||||
sessions,
|
||||
setAccount,
|
||||
setActiveSessionId,
|
||||
@@ -1552,41 +1549,42 @@ export default function Home() {
|
||||
openErrorSessionId={rectificationErrorSessionId}
|
||||
openErrorMessage={rectificationErrorSessionId ? rectificationErrorMessage : ""}
|
||||
account={sidebarAccount}
|
||||
accountMenuOpen={accountMenuOpen}
|
||||
accountTriggerRef={accountTrigger}
|
||||
newChatDisabled={!hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending}
|
||||
creatingSession={creatingSession}
|
||||
sessionControls={{
|
||||
archivedCount: sessions.filter((session) => session.archivedAt).length, showingArchived: showArchivedSessions,
|
||||
hasMore: Boolean(sessionsCursor), onLoadMore: loadMoreSessions,
|
||||
menuSessionId: sessionMenuId,
|
||||
disabled: Boolean(pendingSessionId) || cancellationPending,
|
||||
onToggleArchivedView: () => { void toggleArchivedView(); setSessionMenuId(null); },
|
||||
onMenuSessionChange: setSessionMenuId,
|
||||
onTogglePinned: togglePinnedSession,
|
||||
onRename: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void renameSession(session);
|
||||
},
|
||||
onShare: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void shareSession(session);
|
||||
},
|
||||
onToggleArchived: toggleArchivedSession,
|
||||
onDelete: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) setPendingSessionDeletion(session);
|
||||
controls={{
|
||||
newChatDisabled: !hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending,
|
||||
creatingSession,
|
||||
accountMenuOpen,
|
||||
accountTriggerRef: accountTrigger,
|
||||
sessionControls: {
|
||||
archivedCount: sessions.filter((session) => session.archivedAt).length, showingArchived: showArchivedSessions,
|
||||
hasMore: Boolean(sessionsCursor), onLoadMore: loadMoreSessions,
|
||||
menuSessionId: sessionMenuId,
|
||||
disabled: Boolean(pendingSessionId) || cancellationPending,
|
||||
onToggleArchivedView: () => { void toggleArchivedView(); setSessionMenuId(null); },
|
||||
onMenuSessionChange: setSessionMenuId,
|
||||
onTogglePinned: togglePinnedSession,
|
||||
onRename: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void renameSession(session);
|
||||
},
|
||||
onShare: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void shareSession(session);
|
||||
},
|
||||
onToggleArchived: toggleArchivedSession,
|
||||
onDelete: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) setPendingSessionDeletion(session);
|
||||
},
|
||||
},
|
||||
onAccountMenuOpenChange: setAccountMenuOpen,
|
||||
onNewChat: () => void startNewChat(),
|
||||
onSelectSession: selectSession,
|
||||
onOpenProfile: () => openAccountDialog("profile"),
|
||||
onOpenChartLibrary: () => openAccountDialog("chart-library"),
|
||||
onOpenGeneral: () => openAccountDialog("general"),
|
||||
onOpenBilling: () => openAccountDialog("billing", { source: "account-menu" }),
|
||||
onOpenLogout: () => openAccountDialog("logout"),
|
||||
}}
|
||||
onAccountMenuOpenChange={setAccountMenuOpen}
|
||||
onNewChat={() => void startNewChat()}
|
||||
onOpenReports={() => router.push("/reports")}
|
||||
onSelectSession={selectSession}
|
||||
onOpenProfile={() => openAccountDialog("profile")}
|
||||
onOpenChartLibrary={() => openAccountDialog("chart-library")}
|
||||
onOpenGeneral={() => openAccountDialog("general")}
|
||||
onOpenBilling={() => openAccountDialog("billing", { source: "account-menu" })}
|
||||
onOpenLogout={() => openAccountDialog("logout")}
|
||||
/>
|
||||
{pendingSessionDeletion ? (
|
||||
<div className="account-modal-overlay session-delete-overlay" role="presentation" onMouseDown={() => setPendingSessionDeletion(null)}>
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { CalendarDays, FileText, Orbit, SquarePen, Star } from "lucide-react";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url";
|
||||
import { groupSessionsByRecency } from "@/lib/session-groups";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { useNavRail, type NavRailSession } from "@/hooks/use-nav-rail";
|
||||
|
||||
/**
|
||||
* The nav that /chart, /ephemeris and /reports carry.
|
||||
*
|
||||
* Those three pages used to be standalone full-screen routes whose only way back
|
||||
* was a 「返回对话」 link: the sidebar disappeared entirely the moment you opened
|
||||
* a chart. This rail keeps it on screen, at the cost of being read-only —
|
||||
* renaming, pinning, archiving and deleting stay on `/`, where the state that
|
||||
* backs them lives. See TASK-cend-surfaces-claude-alignment-20260916 D9.
|
||||
*/
|
||||
|
||||
const NAV_PAGES = [
|
||||
{ href: "/chart", label: "星盘", icon: Orbit },
|
||||
{ href: "/ephemeris", label: "星历", icon: CalendarDays },
|
||||
{ href: "/reports", label: "我的报告", icon: FileText },
|
||||
] as const;
|
||||
|
||||
export function AppNavRail() {
|
||||
const { sessions, account, settled, signedOut } = useNavRail();
|
||||
const { isMobile, setOpenMobile, state } = 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 showExpandedContent = !(state === "collapsed" && !isMobile);
|
||||
|
||||
const pinned = sessions.filter((session) => session.pinned);
|
||||
const history = sessions.filter((session) => !session.pinned);
|
||||
const groups = groupSessionsByRecency(history);
|
||||
|
||||
/* Real links, not buttons with a router call. `useRouter()` throws outside an
|
||||
app-router context (it took out twelve render tests), and an <a href> gives
|
||||
client-side navigation for free — clicking 新建对话 from a chart used to
|
||||
reload the whole document. `/login` keeps the hard exit: it crosses an auth
|
||||
boundary and wants the return target persisted first. */
|
||||
function leaveForLogin() {
|
||||
persistLoginSessionReturn();
|
||||
if (isMobile) setOpenMobile(false);
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
if (isMobile) setOpenMobile(false);
|
||||
}
|
||||
|
||||
function renderRow(session: NavRailSession) {
|
||||
return (
|
||||
<SidebarMenuItem key={session.id}>
|
||||
<Link className="session-row nav-rail-row" href={sessionHref("", session.id)} onClick={closeDrawer}>
|
||||
<span className="session-title">{session.title}</span>
|
||||
</Link>
|
||||
</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>
|
||||
<Link className="new-chat" data-sidebar="menu-button" href="/" title="新对话" onClick={closeDrawer}>
|
||||
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
|
||||
{showExpandedContent ? <span>新建对话</span> : null}
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
{NAV_PAGES.map(({ href, label, icon: Icon }) => (
|
||||
<SidebarMenuItem key={href}>
|
||||
<Link
|
||||
className="report-nav-button"
|
||||
data-sidebar="menu-button"
|
||||
data-active={pathname === href || pathname.startsWith(`${href}/`)}
|
||||
href={href}
|
||||
title={label}
|
||||
onClick={closeDrawer}
|
||||
>
|
||||
<Icon size={18} strokeWidth={1.75} aria-hidden="true" />
|
||||
{showExpandedContent ? <span>{label}</span> : null}
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
{showExpandedContent ? (
|
||||
<SidebarGroup className="session-nav" aria-label="最近对话">
|
||||
<p className="sidebar-section-label">最近</p>
|
||||
<SidebarGroupContent className="sidebar-nested">
|
||||
{/* Static copy while the list is in flight — never a skeleton. */}
|
||||
{sessions.length === 0 ? (
|
||||
<p className="sidebar-empty">
|
||||
{signedOut ? "登录后可以看到你的对话" : settled ? "暂无对话,点上方「新建对话」开始" : "对话列表读取中"}
|
||||
</p>
|
||||
) : (
|
||||
<SidebarMenu className="session-list">
|
||||
{pinned.length > 0 ? (
|
||||
<div>
|
||||
<p className="sidebar-group-label">
|
||||
<Star size={13} strokeWidth={1.75} aria-hidden="true" />
|
||||
收藏
|
||||
</p>
|
||||
{pinned.map(renderRow)}
|
||||
</div>
|
||||
) : null}
|
||||
{groups.map((group) => (
|
||||
<div key={group.key}>
|
||||
<p className="sidebar-group-label">{group.label}</p>
|
||||
{group.sessions.map(renderRow)}
|
||||
</div>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="sidebar-footer">
|
||||
{signedOut ? (
|
||||
<a className="nav-rail-identity" href="/login" onClick={leaveForLogin}>
|
||||
<span className="profile-initial" aria-hidden="true">·</span>
|
||||
{showExpandedContent ? <span><b>去登录</b></span> : null}
|
||||
</a>
|
||||
) : (
|
||||
/* Draws the same beam avatar the chat sidebar does — the rail used to
|
||||
show only the initial, so the same account wore two different faces
|
||||
depending on which page you were on. It is a real control now: the
|
||||
account menu itself lives on `/` with the settings dialogs behind
|
||||
it, so this goes there rather than growing a second copy. */
|
||||
<Link
|
||||
className="nav-rail-identity"
|
||||
href="/"
|
||||
aria-label={account ? `${account.name},余额 ${account.credits} 点,打开账户` : "打开账户"}
|
||||
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>
|
||||
<small>{account.credits} 点</small>
|
||||
</>
|
||||
) : null}
|
||||
</Link>
|
||||
)}
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -15,10 +15,11 @@ import {
|
||||
UserRound,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Ref } from "react";
|
||||
import { persistLoginSessionReturn } from "@/lib/chat-session-url";
|
||||
import { persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuLink,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
@@ -50,15 +52,21 @@ export type SidebarAccount = {
|
||||
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;
|
||||
/**
|
||||
* 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;
|
||||
@@ -66,7 +74,6 @@ export type AppSidebarProps = {
|
||||
sessionControls: SidebarSessionControls;
|
||||
onAccountMenuOpenChange: (open: boolean) => void;
|
||||
onNewChat: () => void;
|
||||
onOpenReports: () => void;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onOpenProfile: () => void;
|
||||
onOpenChartLibrary: () => void;
|
||||
@@ -75,6 +82,28 @@ export type AppSidebarProps = {
|
||||
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,
|
||||
@@ -82,23 +111,14 @@ export function AppSidebar({
|
||||
openErrorSessionId = null,
|
||||
openErrorMessage = "",
|
||||
account,
|
||||
accountMenuOpen,
|
||||
accountTriggerRef,
|
||||
newChatDisabled,
|
||||
creatingSession,
|
||||
sessionControls,
|
||||
onAccountMenuOpenChange,
|
||||
onNewChat,
|
||||
onOpenReports: _onOpenReports,
|
||||
onSelectSession,
|
||||
onOpenProfile,
|
||||
onOpenChartLibrary,
|
||||
onOpenGeneral,
|
||||
onOpenBilling,
|
||||
onOpenLogout,
|
||||
settled = true,
|
||||
signedOut = false,
|
||||
controls,
|
||||
}: AppSidebarProps) {
|
||||
const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
/* 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);
|
||||
@@ -106,51 +126,65 @@ export function AppSidebar({
|
||||
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 (!sessionControls.hasMore || !sessionControls.onLoadMore) return;
|
||||
if (!hasMoreSessions || !onLoadMoreSessions) return;
|
||||
const node = loadMoreRef.current;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) sessionControls.onLoadMore?.();
|
||||
if (entries.some((entry) => entry.isIntersecting)) onLoadMoreSessions();
|
||||
});
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [historySessions.length, sessionControls.hasMore, sessionControls.onLoadMore]);
|
||||
}, [historySessions.length, hasMoreSessions, onLoadMoreSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousMenuPlacement.current !== menuPlacement && accountMenuOpen) {
|
||||
onAccountMenuOpenChange(false);
|
||||
onAccountMenuOpenChange?.(false);
|
||||
}
|
||||
previousMenuPlacement.current = menuPlacement;
|
||||
}, [accountMenuOpen, menuPlacement, onAccountMenuOpenChange]);
|
||||
|
||||
function closeDrawer() {
|
||||
if (isMobile) setOpenMobile(false);
|
||||
}
|
||||
|
||||
function handleNewChat() {
|
||||
onNewChat();
|
||||
if (isMobile) setOpenMobile(false);
|
||||
onNewChat?.();
|
||||
closeDrawer();
|
||||
}
|
||||
|
||||
function leaveChat(path: "/chart" | "/ephemeris" | "/reports") {
|
||||
/* 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();
|
||||
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);
|
||||
closeDrawer();
|
||||
}
|
||||
|
||||
function handleExpandHistory() {
|
||||
@@ -161,27 +195,34 @@ export function AppSidebar({
|
||||
}
|
||||
|
||||
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}>
|
||||
<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)}
|
||||
/>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -195,69 +236,58 @@ export function AppSidebar({
|
||||
</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>
|
||||
{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}
|
||||
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 ? "归档记录" : "最近对话"}>
|
||||
<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 ? "归档记录" : "最近"}
|
||||
{sessionControls?.showingArchived ? "归档记录" : "最近"}
|
||||
</p>
|
||||
<SidebarGroupContent className="sidebar-nested">
|
||||
{favoriteSessions.length === 0 && historySessions.length === 0 ? (
|
||||
<p className="sidebar-empty">暂无对话,点上方「新建对话」开始</p>
|
||||
/* 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 ? (
|
||||
@@ -278,7 +308,7 @@ export function AppSidebar({
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{sessionControls.hasMore ? <div ref={loadMoreRef} aria-hidden className="session-list-sentinel" /> : null}
|
||||
{hasMoreSessions ? <div ref={loadMoreRef} aria-hidden className="session-list-sentinel" /> : null}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
@@ -300,54 +330,77 @@ export function AppSidebar({
|
||||
</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}
|
||||
{controls && account ? (
|
||||
<Menu.Root open={accountMenuOpen} onOpenChange={onAccountMenuOpenChange} modal={false}>
|
||||
<Menu.Trigger
|
||||
className="profile-trigger"
|
||||
ref={accountTriggerRef}
|
||||
type="button"
|
||||
>
|
||||
<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>
|
||||
{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. */
|
||||
<Link
|
||||
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}
|
||||
</Link>
|
||||
)}
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { SecondaryShell } from "@/components/secondary-shell";
|
||||
import { SecondaryHeader } from "@/components/secondary-header";
|
||||
import { useChartPage } from "@/hooks/use-chart-page";
|
||||
import {
|
||||
CHART_VIEW_TABS,
|
||||
@@ -63,7 +63,8 @@ export function ChartPageView({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SecondaryShell title="星盘" note={birthline}>
|
||||
<>
|
||||
<SecondaryHeader title="星盘" note={birthline} />
|
||||
{view == null ? (
|
||||
<section className="chart-page-message">
|
||||
<p>{CHART_VIEW_COPY.waitingChart}</p>
|
||||
@@ -122,6 +123,6 @@ export function ChartPageView({
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</SecondaryShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { zhCN } from "date-fns/locale";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { SecondaryShell } from "@/components/secondary-shell";
|
||||
import { SecondaryHeader } from "@/components/secondary-header";
|
||||
import { setComposerDraft } from "@/lib/composer-draft";
|
||||
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract";
|
||||
import {
|
||||
@@ -65,13 +65,14 @@ export function EphemerisPage() {
|
||||
|
||||
if (state.phase === "unauthorized") {
|
||||
return (
|
||||
<SecondaryShell title={EPHEMERIS_COPY.title}>
|
||||
<>
|
||||
<SecondaryHeader title={EPHEMERIS_COPY.title} />
|
||||
<section className="ephemeris-message">
|
||||
<h2>{EPHEMERIS_COPY.loginTitle}</h2>
|
||||
<p>{EPHEMERIS_COPY.loginBody}</p>
|
||||
<Button render={<Link href="/login" />} nativeButton={false}>{EPHEMERIS_COPY.loginAction}</Button>
|
||||
</section>
|
||||
</SecondaryShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,14 +127,15 @@ export function EphemerisView(props: {
|
||||
const selectedDay = props.date ? parseEphemerisDate(props.date) : undefined;
|
||||
|
||||
return (
|
||||
<SecondaryShell
|
||||
title={EPHEMERIS_COPY.title}
|
||||
actions={(
|
||||
<Button type="button" className="ephemeris-ask" onClick={props.onAsk}>
|
||||
{EPHEMERIS_COPY.ask}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<SecondaryHeader
|
||||
title={EPHEMERIS_COPY.title}
|
||||
actions={(
|
||||
<Button type="button" className="ephemeris-ask" onClick={props.onAsk}>
|
||||
{EPHEMERIS_COPY.ask}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<div className="ephemeris-body">
|
||||
<section className="ephemeris-hero">
|
||||
{/* `‹ 日期 ›`: the date is the subject, the two arrows are its handles.
|
||||
@@ -276,6 +278,6 @@ export function EphemerisView(props: {
|
||||
under the page now, and the 「带这天去提问」 action lives in the header. */}
|
||||
<p className="ephemeris-outro">{EPHEMERIS_COPY.footerNote}</p>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Clock3, FileText, RefreshCw } from "lucide-react";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { SecondaryShell } from "@/components/secondary-shell";
|
||||
import { SecondaryHeader } from "@/components/secondary-header";
|
||||
import { GeneratePersonalReportButton } from "./generate-personal-report-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
@@ -171,22 +171,24 @@ export function PersonalReportCenter() {
|
||||
|
||||
if (state.phase === "unauthorized") {
|
||||
return (
|
||||
<SecondaryShell title="我的报告">
|
||||
<>
|
||||
<SecondaryHeader title="我的报告" />
|
||||
<section className="report-center-message">
|
||||
<FileText aria-hidden="true" className="size-8 text-primary" />
|
||||
<h2>请先登录</h2>
|
||||
<p>登录后即可生成和查看你的个人报告。</p>
|
||||
<Button render={<Link href="/login" />} nativeButton={false}>去登录</Button>
|
||||
</section>
|
||||
</SecondaryShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryShell
|
||||
title="我的报告"
|
||||
actions={<GeneratePersonalReportButton onCreated={() => void load()} />}
|
||||
>
|
||||
<>
|
||||
<SecondaryHeader
|
||||
title="我的报告"
|
||||
actions={<GeneratePersonalReportButton onCreated={() => void load()} />}
|
||||
/>
|
||||
<div className="report-center-body">
|
||||
<section className="report-center-hero">
|
||||
<div>
|
||||
@@ -278,6 +280,6 @@ export function PersonalReportCenter() {
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Clock3, TriangleAlert } from "lucide-react";
|
||||
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
|
||||
import { SecondaryShell } from "@/components/secondary-shell";
|
||||
import { SecondaryHeader } from "@/components/secondary-header";
|
||||
import { ReportActions } from "./report-actions";
|
||||
import { PersonalReportMarkdownView } from "./personal-report-markdown-view";
|
||||
import { PersonalReportProgressPanel } from "./personal-report-progress-panel";
|
||||
@@ -316,7 +316,8 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
: null;
|
||||
const writing = progress?.stage === "writing";
|
||||
return (
|
||||
<SecondaryShell title="个人报告">
|
||||
<>
|
||||
<SecondaryHeader title="个人报告" />
|
||||
<div className="personal-report-state">
|
||||
{writing ? null : <InlineSpinner className="text-primary" size={32} />}
|
||||
<p role="status" className={writing ? "personal-report-progress-headline" : undefined}>
|
||||
@@ -331,13 +332,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "timed-out") {
|
||||
return (
|
||||
<SecondaryShell title="个人报告">
|
||||
<>
|
||||
<SecondaryHeader title="个人报告" />
|
||||
<div className="personal-report-state">
|
||||
<Clock3 aria-hidden="true" className="size-8 text-ink-secondary" />
|
||||
<h1>生成时间超出预期</h1>
|
||||
@@ -352,13 +354,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "unauthorized") {
|
||||
return (
|
||||
<SecondaryShell title="个人报告">
|
||||
<>
|
||||
<SecondaryHeader title="个人报告" />
|
||||
<div className="personal-report-state">
|
||||
<h1>请先登录</h1>
|
||||
<p>个人报告仅对登录用户开放。请登录后重试。</p>
|
||||
@@ -366,13 +369,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
去登录
|
||||
</Button>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "not-found") {
|
||||
return (
|
||||
<SecondaryShell title="个人报告">
|
||||
<>
|
||||
<SecondaryHeader title="个人报告" />
|
||||
<div className="personal-report-state">
|
||||
<h1>报告不存在</h1>
|
||||
<p>该报告不存在、已删除,或不属于当前账号。</p>
|
||||
@@ -380,13 +384,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
返回报告中心
|
||||
</Button>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "failed") {
|
||||
return (
|
||||
<SecondaryShell title="个人报告">
|
||||
<>
|
||||
<SecondaryHeader title="个人报告" />
|
||||
<div className="personal-report-state">
|
||||
<TriangleAlert aria-hidden="true" className="size-8 text-warning" />
|
||||
<h1>报告生成失败</h1>
|
||||
@@ -398,13 +403,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
返回报告中心重新生成
|
||||
</Button>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "invalid" || state.phase === "network-error") {
|
||||
return (
|
||||
<SecondaryShell title="个人报告">
|
||||
<>
|
||||
<SecondaryHeader title="个人报告" />
|
||||
<div className="personal-report-state">
|
||||
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
|
||||
<h1>报告暂时无法显示</h1>
|
||||
@@ -417,13 +423,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "legacy-unavailable") {
|
||||
return (
|
||||
<SecondaryShell title="个人报告">
|
||||
<>
|
||||
<SecondaryHeader title="个人报告" />
|
||||
<div className="personal-report-state">
|
||||
<TriangleAlert aria-hidden="true" className="size-8 text-warning" />
|
||||
<h1>旧版本报告</h1>
|
||||
@@ -432,13 +439,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
返回报告中心重新生成
|
||||
</Button>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase !== "markdown-ready") {
|
||||
return (
|
||||
<SecondaryShell title="个人报告">
|
||||
<>
|
||||
<SecondaryHeader title="个人报告" />
|
||||
<div className="personal-report-state">
|
||||
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
|
||||
<h1>报告暂时无法显示</h1>
|
||||
@@ -447,12 +455,13 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryShell title="个人报告">
|
||||
<>
|
||||
<SecondaryHeader title="个人报告" />
|
||||
<div className="personal-report-reader">
|
||||
<style media="print">{"@page { size: A4; margin: 13mm 12mm 14mm; }"}</style>
|
||||
<style media="print">{REPORT_SHELL_PRINT_CSS}</style>
|
||||
@@ -466,6 +475,6 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
<PersonalReportMarkdownView markdown={state.markdown} />
|
||||
</div>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
|
||||
/**
|
||||
* The 46px header every secondary page carries.
|
||||
*
|
||||
* It used to be `SecondaryShell`, which also mounted a `SidebarProvider` and a
|
||||
* sidebar of its own. Four routes rendering that shell meant four shells: every
|
||||
* arrival remounted the nav and re-issued `GET /api/sessions` and
|
||||
* `GET /api/account`, even when the reader had only stepped from `/chart` to
|
||||
* `/ephemeris`. The provider, the sidebar and the inset now live once, in
|
||||
* `app/(secondary)/layout.tsx`; what is left here is the header itself, which is
|
||||
* genuinely per page because only the page knows its name and its actions.
|
||||
*
|
||||
* The trigger still sits in this row — the provider is above it in the tree, so
|
||||
* `useSidebar()` resolves exactly as before.
|
||||
*/
|
||||
|
||||
export type SecondaryHeaderProps = {
|
||||
/** Shown in the header. Pass a falsy value for pages that carry their own. */
|
||||
readonly title: string;
|
||||
/** Right-aligned header controls: the one primary action, at most two. */
|
||||
readonly actions?: ReactNode;
|
||||
/** A quiet chip beside the title — birth line, report date. */
|
||||
readonly note?: ReactNode;
|
||||
};
|
||||
|
||||
export function SecondaryHeader({ title, actions, note }: SecondaryHeaderProps) {
|
||||
return (
|
||||
<header className="chat-header has-title">
|
||||
<SidebarTrigger placement="inset" />
|
||||
<div className="chat-header-title">
|
||||
<strong>{title}</strong>
|
||||
{note ? <span className="chat-header-chart">{note}</span> : null}
|
||||
</div>
|
||||
{actions ? <div className="chat-header-actions">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { AppNavRail } from "@/components/app-nav-rail";
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
|
||||
/**
|
||||
* The app shell for /chart, /ephemeris and /reports.
|
||||
*
|
||||
* Each of those pages used to own a full-screen layout of its own — a
|
||||
* `*-shell` root, a `*-topbar` holding one 「返回对话」 link, and a `*-hero` with
|
||||
* a page-sized h1. Three copies of the same skeleton, and none of them had the
|
||||
* sidebar, so opening a chart dropped the reader out of the app entirely.
|
||||
*
|
||||
* The page name now sits in the 46px header beside the sidebar trigger, which is
|
||||
* where the chat page keeps its session title.
|
||||
*/
|
||||
|
||||
export type SecondaryShellProps = {
|
||||
/** Shown in the header. Pass a falsy value for pages that carry their own. */
|
||||
readonly title: string;
|
||||
/** Right-aligned header controls: the one primary action, at most two. */
|
||||
readonly actions?: ReactNode;
|
||||
/** A quiet chip beside the title — birth line, report date. */
|
||||
readonly note?: ReactNode;
|
||||
readonly children: ReactNode;
|
||||
};
|
||||
|
||||
export function SecondaryShell({ title, actions, note, children }: SecondaryShellProps) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<main className="chat-app">
|
||||
<AppNavRail />
|
||||
<SidebarInset className="chat-panel secondary-panel">
|
||||
<header className="chat-header has-title">
|
||||
<SidebarTrigger placement="inset" />
|
||||
<div className="chat-header-title">
|
||||
<strong>{title}</strong>
|
||||
{note ? <span className="chat-header-chart">{note}</span> : null}
|
||||
</div>
|
||||
{actions ? <div className="chat-header-actions">{actions}</div> : null}
|
||||
</header>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
StarOff,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { forwardRef } from "react";
|
||||
import { RECTIFICATION_SIDEBAR_OPENING_NOTE } from "@/lib/rectification-surface-state";
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar";
|
||||
@@ -41,13 +42,17 @@ export type SidebarSessionControls = {
|
||||
readonly onDelete: (sessionId: string) => void;
|
||||
};
|
||||
|
||||
type SidebarSessionRowProps = {
|
||||
type SidebarSessionRowCommonProps = {
|
||||
readonly session: SidebarSession;
|
||||
readonly active: boolean;
|
||||
/** Its Case is being opened: a static note, no spinner. */
|
||||
readonly opening?: boolean;
|
||||
/** Open failed for this row: one line under the title, same notice scale as composer. */
|
||||
readonly error?: string;
|
||||
};
|
||||
|
||||
type SidebarSessionRowInteractiveProps = SidebarSessionRowCommonProps & {
|
||||
readonly href?: undefined;
|
||||
readonly disabled: boolean;
|
||||
readonly menuOpen: boolean;
|
||||
readonly onMenuOpenChange: (open: boolean) => void;
|
||||
@@ -59,21 +64,68 @@ type SidebarSessionRowProps = {
|
||||
readonly onDelete: () => void;
|
||||
};
|
||||
|
||||
export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRowProps>(function SidebarSessionRow({
|
||||
session,
|
||||
active,
|
||||
opening = false,
|
||||
error,
|
||||
disabled,
|
||||
menuOpen,
|
||||
onMenuOpenChange,
|
||||
onSelect,
|
||||
onTogglePinned,
|
||||
onRename,
|
||||
onShare,
|
||||
onToggleArchived,
|
||||
onDelete,
|
||||
}, ref) {
|
||||
/**
|
||||
* Read-only mode: the row is a plain link to `/?c=<id>` and nothing else.
|
||||
*
|
||||
* Same `.session-row > .session-main` markup as the interactive row, because a
|
||||
* second set of markup is exactly how the secondary pages ended up with rows
|
||||
* that had no 44px minimum, no padding and no current-item marker while still
|
||||
* reserving a 44px column for a menu button that was never rendered.
|
||||
*/
|
||||
type SidebarSessionRowLinkProps = SidebarSessionRowCommonProps & {
|
||||
readonly href: string;
|
||||
readonly onNavigate?: () => void;
|
||||
};
|
||||
|
||||
type SidebarSessionRowProps = SidebarSessionRowInteractiveProps | SidebarSessionRowLinkProps;
|
||||
|
||||
function SessionRowBody({ session, opening }: { session: SidebarSession; opening: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<span className="session-title">
|
||||
{session.pinned ? <Star aria-label="已收藏" /> : null}
|
||||
<span className="truncate">{session.title}</span>
|
||||
{opening ? <span className="session-opening-note">{RECTIFICATION_SIDEBAR_OPENING_NOTE}</span> : null}
|
||||
</span>
|
||||
{session.subtitle ? <small className="session-subtitle">{session.subtitle}</small> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRowProps>(function SidebarSessionRow(props, ref) {
|
||||
const { session, active, opening = false, error } = props;
|
||||
|
||||
if (props.href !== undefined) {
|
||||
return (
|
||||
<div className="session-row" data-readonly="true">
|
||||
<Link
|
||||
className="session-main"
|
||||
data-sidebar="menu-button"
|
||||
data-slot="sidebar-menu-button"
|
||||
data-active={active}
|
||||
aria-current={active ? "page" : undefined}
|
||||
href={props.href}
|
||||
onClick={props.onNavigate}
|
||||
>
|
||||
<SessionRowBody session={session} opening={opening} />
|
||||
</Link>
|
||||
{error ? <p className="session-open-error" role="alert">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
disabled,
|
||||
menuOpen,
|
||||
onMenuOpenChange,
|
||||
onSelect,
|
||||
onTogglePinned,
|
||||
onRename,
|
||||
onShare,
|
||||
onToggleArchived,
|
||||
onDelete,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Menu.Root
|
||||
open={sessionMutationMenuVisible(menuOpen, disabled)}
|
||||
@@ -98,12 +150,7 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="session-title">
|
||||
{session.pinned ? <Star aria-label="已收藏" /> : null}
|
||||
<span className="truncate">{session.title}</span>
|
||||
{opening ? <span className="session-opening-note">{RECTIFICATION_SIDEBAR_OPENING_NOTE}</span> : null}
|
||||
</span>
|
||||
{session.subtitle ? <small className="session-subtitle">{session.subtitle}</small> : null}
|
||||
<SessionRowBody session={session} opening={opening} />
|
||||
</SidebarMenuButton>
|
||||
<Menu.Trigger
|
||||
className="session-menu-trigger"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Tooltip } from "@base-ui/react/tooltip";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
@@ -17,7 +18,9 @@ import {
|
||||
import { useSidebarViewport } from "@/hooks/use-sidebar-viewport";
|
||||
import {
|
||||
defaultSidebarOpen,
|
||||
readStoredSidebarOpen,
|
||||
shouldHandleSidebarShortcut,
|
||||
writeStoredSidebarOpen,
|
||||
type SidebarViewport,
|
||||
} from "@/lib/sidebar-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -58,6 +61,11 @@ type SidebarMenuButtonProps = ComponentProps<"button"> & {
|
||||
readonly tooltip?: string;
|
||||
};
|
||||
|
||||
type SidebarMenuLinkProps = ComponentProps<typeof Link> & {
|
||||
readonly isActive?: boolean;
|
||||
readonly tooltip?: string;
|
||||
};
|
||||
|
||||
const SidebarContext = createContext<SidebarProviderContextValue | null>(null);
|
||||
|
||||
function useSidebarContext(): SidebarProviderContextValue {
|
||||
@@ -93,9 +101,13 @@ export function SidebarProvider({
|
||||
|
||||
const commitOpen = useCallback((nextOpen: boolean) => {
|
||||
userChangedDesktopState.current = true;
|
||||
/* Remembered across pages so collapsing on `/` survives the trip to
|
||||
`/chart`. Mobile is skipped inside the helper: the drawer is not a
|
||||
preference. */
|
||||
writeStoredSidebarOpen(viewport, nextOpen);
|
||||
if (!isControlled) setUncontrolledOpen(nextOpen);
|
||||
onOpenChange?.(nextOpen);
|
||||
}, [isControlled, onOpenChange]);
|
||||
}, [isControlled, onOpenChange, viewport]);
|
||||
|
||||
const setOpen = useCallback((nextOpen: boolean) => {
|
||||
commitOpen(nextOpen);
|
||||
@@ -110,7 +122,11 @@ export function SidebarProvider({
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && !isControlled && !userChangedDesktopState.current) {
|
||||
setUncontrolledOpen(defaultSidebarOpen(viewport));
|
||||
/* Same frame the viewport default was already applied in, so a stored
|
||||
preference costs no flash the breakpoint default did not already
|
||||
cost. localStorage cannot be read during the server render and `/` has
|
||||
to stay `○ Static`, so this is as early as the value can arrive. */
|
||||
setUncontrolledOpen(readStoredSidebarOpen(viewport) ?? defaultSidebarOpen(viewport));
|
||||
}
|
||||
}, [isControlled, ready, viewport]);
|
||||
|
||||
@@ -224,6 +240,22 @@ export function SidebarMenuButton({ className, isActive = false, tooltip, ...pro
|
||||
return <Tooltip.Root><Tooltip.Trigger render={button} /><Tooltip.Portal><Tooltip.Positioner side="right" sideOffset={8}><Tooltip.Popup>{tooltip}</Tooltip.Popup></Tooltip.Positioner></Tooltip.Portal></Tooltip.Root>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A nav row that is a destination rather than an action.
|
||||
*
|
||||
* Same element contract as `SidebarMenuButton` — `data-sidebar="menu-button"`,
|
||||
* `data-active`, the 44px minimum, the collapsed-rail tooltip — so the CSS and
|
||||
* the collapsed rail cannot tell the two apart. 星盘 / 星历 / 我的报告 are
|
||||
* links on every page now; the chat page used to reach them with
|
||||
* `window.location.assign`, which threw the whole React tree away.
|
||||
*/
|
||||
export function SidebarMenuLink({ className, isActive = false, tooltip, ...props }: SidebarMenuLinkProps) {
|
||||
const { isMobile, state } = useSidebar();
|
||||
const link = <Link data-sidebar="menu-button" data-slot="sidebar-menu-button" data-active={isActive} className={cn("min-h-11 w-full", className)} {...props} />;
|
||||
if (tooltip === undefined || state !== "collapsed" || isMobile) return link;
|
||||
return <Tooltip.Root><Tooltip.Trigger render={link} /><Tooltip.Portal><Tooltip.Positioner side="right" sideOffset={8}><Tooltip.Popup>{tooltip}</Tooltip.Popup></Tooltip.Positioner></Tooltip.Portal></Tooltip.Root>;
|
||||
}
|
||||
|
||||
export function SidebarFooter({ className, ...props }: ComponentProps<"div">) {
|
||||
return <div data-sidebar="footer" data-slot="sidebar-footer" className={cn("shrink-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ export type ConsultationRunParams = {
|
||||
pendingConsultation: MutableRefObject<PendingConsultation | null>;
|
||||
pendingSessionId: string | null;
|
||||
profile: Profile;
|
||||
router: { push: (href: string) => void };
|
||||
sessions: ChatSession[];
|
||||
setAccount: Dispatch<SetStateAction<Account | null>>;
|
||||
setActiveSessionId: Dispatch<SetStateAction<string>>;
|
||||
@@ -166,7 +165,6 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
pendingConsultation,
|
||||
pendingSessionId,
|
||||
profile,
|
||||
router: _router,
|
||||
sessions,
|
||||
setAccount,
|
||||
setActiveSessionId,
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { BeamAvatar } from "@/lib/beam-avatar";
|
||||
|
||||
/**
|
||||
* Read-only nav data for the secondary pages (/chart, /ephemeris, /reports).
|
||||
*
|
||||
* The chat page's sidebar is fed by `Home()`'s hooks, which also own renaming,
|
||||
* deleting, pinning and archiving — optimistic updates, confirmation dialogs and
|
||||
* rollback included. Lifting that whole layer so four routes can share it is far
|
||||
* more than these pages need: none of them offers session management. Two GETs
|
||||
* are enough, and this hook never writes.
|
||||
*/
|
||||
|
||||
export type NavRailSession = {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly pinned: boolean;
|
||||
readonly updatedAt: number;
|
||||
};
|
||||
|
||||
export type NavRailAccount = {
|
||||
readonly name: string;
|
||||
readonly initial: string;
|
||||
readonly credits: number;
|
||||
/** The same beam avatar the chat sidebar draws, so the two footers match. */
|
||||
readonly avatar: BeamAvatar | null;
|
||||
};
|
||||
|
||||
export type NavRailState = {
|
||||
readonly sessions: readonly NavRailSession[];
|
||||
readonly account: NavRailAccount | null;
|
||||
/** True once both requests have settled, however they settled. */
|
||||
readonly settled: boolean;
|
||||
/** The viewer is not signed in; the rail degrades to brand plus a login link. */
|
||||
readonly signedOut: boolean;
|
||||
};
|
||||
|
||||
type SessionRow = {
|
||||
id?: unknown;
|
||||
title?: unknown;
|
||||
pinned?: unknown;
|
||||
updated_at?: unknown;
|
||||
};
|
||||
|
||||
function toSession(row: SessionRow): NavRailSession | null {
|
||||
if (typeof row.id !== "string" || !row.id) return null;
|
||||
const updatedAt = typeof row.updated_at === "string" ? Date.parse(row.updated_at) : Number.NaN;
|
||||
return {
|
||||
id: row.id,
|
||||
title: typeof row.title === "string" && row.title.trim() ? row.title.trim() : "新对话",
|
||||
pinned: row.pinned === true,
|
||||
updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function useNavRail(): NavRailState {
|
||||
const [sessions, setSessions] = useState<readonly NavRailSession[]>([]);
|
||||
const [account, setAccount] = useState<NavRailAccount | null>(null);
|
||||
const [settled, setSettled] = useState(false);
|
||||
const [signedOut, setSignedOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
if (typeof fetch !== "function") {
|
||||
setSettled(true);
|
||||
return;
|
||||
}
|
||||
const [sessionResult, accountResult] = await Promise.allSettled([
|
||||
fetch("/api/sessions?limit=40", { signal: controller.signal }),
|
||||
fetch("/api/account", { signal: controller.signal }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
if (sessionResult.status === "fulfilled" && sessionResult.value.ok) {
|
||||
const body = await sessionResult.value.json().catch(() => null) as { sessions?: unknown } | null;
|
||||
const rows = Array.isArray(body?.sessions) ? body.sessions as SessionRow[] : [];
|
||||
if (!cancelled) setSessions(rows.map(toSession).filter((item): item is NavRailSession => item !== null));
|
||||
} else if (sessionResult.status === "fulfilled" && sessionResult.value.status === 401) {
|
||||
if (!cancelled) setSignedOut(true);
|
||||
}
|
||||
|
||||
if (accountResult.status === "fulfilled" && accountResult.value.ok) {
|
||||
const body = await accountResult.value.json().catch(() => null) as {
|
||||
credits?: unknown;
|
||||
user?: { email?: unknown };
|
||||
profile?: { name?: unknown };
|
||||
avatar?: BeamAvatar | null;
|
||||
} | null;
|
||||
const name = typeof body?.profile?.name === "string" ? body.profile.name.trim() : "";
|
||||
const email = typeof body?.user?.email === "string" ? body.user.email : "";
|
||||
if (!cancelled) {
|
||||
setAccount({
|
||||
name: name || email || "账户",
|
||||
initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你",
|
||||
credits: typeof body?.credits === "number" ? body.credits : 0,
|
||||
avatar: body?.avatar ?? null,
|
||||
});
|
||||
}
|
||||
} else if (accountResult.status === "fulfilled" && accountResult.value.status === 401) {
|
||||
if (!cancelled) setSignedOut(true);
|
||||
}
|
||||
|
||||
if (!cancelled) setSettled(true);
|
||||
}
|
||||
|
||||
void load().catch(() => {
|
||||
if (!cancelled) setSettled(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { sessions, account, settled, signedOut };
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/lib/chat-session-url";
|
||||
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
|
||||
import { sortSessions } from "@/lib/session-groups";
|
||||
import { invalidateSidebarCache } from "@/lib/sidebar-data-cache";
|
||||
import {
|
||||
clearBirthTimeConsultationConsent,
|
||||
type BirthTimeConsultationConsentState,
|
||||
@@ -216,6 +217,12 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
updateSession(session.id, () => nextSession);
|
||||
try {
|
||||
await persistSession(nextSession);
|
||||
/* The secondary pages read their list from a 60s module cache; without
|
||||
this, walking to /chart right after a rename still shows the old
|
||||
title. Invalidate rather than write through: `Home()` owns the richer
|
||||
ChatSession shape, and one refetch is cheaper than keeping two
|
||||
representations in step. */
|
||||
invalidateSidebarCache();
|
||||
} catch (caught) {
|
||||
setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败");
|
||||
}
|
||||
@@ -236,6 +243,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" });
|
||||
const payload = await response.json().catch(() => null) as { error?: string } | null;
|
||||
if (!response.ok) throw new Error(payload?.error || "删除聊天记录失败");
|
||||
invalidateSidebarCache();
|
||||
} catch (caught) {
|
||||
setSessions(previousSessions);
|
||||
setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败");
|
||||
@@ -247,7 +255,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
if (!session) return;
|
||||
const nextPinned = !session.pinned;
|
||||
updateSession(sessionId, (current) => ({ ...current, pinned: nextPinned }));
|
||||
void writeChatSession(sessionId, { pinned: nextPinned }, "update").catch((caught) => {
|
||||
void writeChatSession(sessionId, { pinned: nextPinned }, "update").then(invalidateSidebarCache).catch((caught) => {
|
||||
updateSession(sessionId, (current) => ({ ...current, pinned: session.pinned }));
|
||||
setComposerNotice(caught instanceof Error ? caught.message : "置顶同步失败");
|
||||
});
|
||||
@@ -266,7 +274,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
if (!uiPreview.current) writeSessionUrl(fallbackId || null, "replace");
|
||||
}
|
||||
setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。");
|
||||
void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").catch((caught) => {
|
||||
void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").then(invalidateSidebarCache).catch((caught) => {
|
||||
updateSession(sessionId, (current) => ({ ...current, archivedAt: session.archivedAt }));
|
||||
if (!restoring && previousActiveId === sessionId) {
|
||||
setActiveSessionId(previousActiveId);
|
||||
@@ -330,6 +338,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
? { continuedFromSessionId: options.continuedFromSessionId }
|
||||
: undefined,
|
||||
);
|
||||
invalidateSidebarCache();
|
||||
return nextSession;
|
||||
} catch (caught) {
|
||||
setSessions((current) => current.filter((session) => session.id !== nextSession.id));
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { SidebarAccount } from "@/components/app-sidebar";
|
||||
import type { SidebarSession } from "@/components/sidebar-session-row";
|
||||
import type { BeamAvatar } from "@/lib/beam-avatar";
|
||||
import {
|
||||
invalidateSidebarCache,
|
||||
readSidebarCache,
|
||||
sidebarCacheIsFresh,
|
||||
writeSidebarCache,
|
||||
} from "@/lib/sidebar-data-cache";
|
||||
|
||||
/**
|
||||
* Read-only nav data for the secondary pages (/chart, /ephemeris, /reports).
|
||||
*
|
||||
* The chat page's sidebar is fed by `Home()`'s hooks, which also own renaming,
|
||||
* deleting, pinning and archiving — optimistic updates, confirmation dialogs and
|
||||
* rollback included. Lifting that whole layer so four routes can share it is far
|
||||
* more than these pages need: none of them offers session management. Two GETs
|
||||
* are enough, and this hook never writes.
|
||||
*
|
||||
* It is called once, from the `(secondary)` layout, so moving between those
|
||||
* four routes does not re-run it at all. The module cache behind it covers the
|
||||
* other trip: leaving for `/` and coming back unmounts the layout, and without
|
||||
* it that would mean two more requests inside the same minute.
|
||||
*/
|
||||
|
||||
export type SidebarDataState = {
|
||||
readonly sessions: readonly SidebarSession[];
|
||||
readonly account: SidebarAccount | null;
|
||||
/** True once both requests have settled, however they settled. */
|
||||
readonly settled: boolean;
|
||||
/** The viewer is not signed in; the sidebar degrades to brand plus a login link. */
|
||||
readonly signedOut: boolean;
|
||||
};
|
||||
|
||||
type SessionRow = {
|
||||
id?: unknown;
|
||||
title?: unknown;
|
||||
pinned?: unknown;
|
||||
archived_at?: unknown;
|
||||
updated_at?: unknown;
|
||||
};
|
||||
|
||||
type AccountBody = {
|
||||
credits?: unknown;
|
||||
user?: { id?: unknown; email?: unknown };
|
||||
profile?: { name?: unknown };
|
||||
avatar?: BeamAvatar | null;
|
||||
};
|
||||
|
||||
const EMPTY: SidebarDataState = { sessions: [], account: null, settled: false, signedOut: false };
|
||||
|
||||
export function toSidebarSession(row: SessionRow): SidebarSession | null {
|
||||
if (typeof row.id !== "string" || !row.id) return null;
|
||||
const updatedAt = typeof row.updated_at === "string" ? Date.parse(row.updated_at) : Number.NaN;
|
||||
return {
|
||||
id: row.id,
|
||||
title: typeof row.title === "string" && row.title.trim() ? row.title.trim() : "新对话",
|
||||
pinned: row.pinned === true,
|
||||
archived: typeof row.archived_at === "string" && row.archived_at !== "",
|
||||
updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function toSidebarAccount(body: AccountBody | null): SidebarAccount {
|
||||
const name = typeof body?.profile?.name === "string" ? body.profile.name.trim() : "";
|
||||
const email = typeof body?.user?.email === "string" ? body.user.email : "";
|
||||
return {
|
||||
name: name || email || "账户",
|
||||
email,
|
||||
initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你",
|
||||
credits: typeof body?.credits === "number" ? body.credits : 0,
|
||||
avatar: body?.avatar ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Which account the payload belongs to; the cache key. */
|
||||
export function sidebarAccountKey(body: AccountBody | null): string {
|
||||
const id = typeof body?.user?.id === "string" ? body.user.id : "";
|
||||
const email = typeof body?.user?.email === "string" ? body.user.email : "";
|
||||
return id || email || "anonymous";
|
||||
}
|
||||
|
||||
export function useSidebarData(): SidebarDataState {
|
||||
const [state, setState] = useState<SidebarDataState>(() => {
|
||||
/* Read once, at mount: an entry from a previous visit renders in the first
|
||||
frame rather than after a round trip. Empty on the server, and empty on a
|
||||
real page load, which is what makes the first render match. */
|
||||
const cached = readSidebarCache();
|
||||
if (cached === null) return EMPTY;
|
||||
return { sessions: cached.sessions, account: cached.account, settled: true, signedOut: false };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const cached = readSidebarCache();
|
||||
if (sidebarCacheIsFresh(cached, Date.now())) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
if (typeof fetch !== "function") {
|
||||
setState((current) => ({ ...current, settled: true }));
|
||||
return;
|
||||
}
|
||||
const [sessionResult, accountResult] = await Promise.allSettled([
|
||||
fetch("/api/sessions?limit=40", { signal: controller.signal }),
|
||||
fetch("/api/account", { signal: controller.signal }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
let sessions: readonly SidebarSession[] | null = null;
|
||||
let account: SidebarAccount | null = null;
|
||||
let accountBody: AccountBody | null = null;
|
||||
let signedOut = false;
|
||||
|
||||
if (sessionResult.status === "fulfilled" && sessionResult.value.ok) {
|
||||
const body = await sessionResult.value.json().catch(() => null) as { sessions?: unknown } | null;
|
||||
const rows = Array.isArray(body?.sessions) ? body.sessions as SessionRow[] : [];
|
||||
sessions = rows.map(toSidebarSession).filter((item): item is SidebarSession => item !== null);
|
||||
} else if (sessionResult.status === "fulfilled" && sessionResult.value.status === 401) {
|
||||
signedOut = true;
|
||||
}
|
||||
|
||||
if (accountResult.status === "fulfilled" && accountResult.value.ok) {
|
||||
accountBody = await accountResult.value.json().catch(() => null) as AccountBody | null;
|
||||
account = toSidebarAccount(accountBody);
|
||||
} else if (accountResult.status === "fulfilled" && accountResult.value.status === 401) {
|
||||
signedOut = true;
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
if (signedOut) {
|
||||
/* A rejected session must not leave a list on screen, here or on the
|
||||
next page this tab opens. */
|
||||
invalidateSidebarCache();
|
||||
setState({ sessions: [], account: null, settled: true, signedOut: true });
|
||||
return;
|
||||
}
|
||||
if (sessions !== null || account !== null) {
|
||||
writeSidebarCache({
|
||||
accountId: sidebarAccountKey(accountBody),
|
||||
sessions: sessions ?? [],
|
||||
account,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
setState({ sessions: sessions ?? [], account, settled: true, signedOut: false });
|
||||
}
|
||||
|
||||
void load().catch(() => {
|
||||
if (!cancelled) setState((current) => ({ ...current, settled: true }));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { SidebarAccount } from "@/components/app-sidebar";
|
||||
import type { SidebarSession } from "@/components/sidebar-session-row";
|
||||
|
||||
/**
|
||||
* The session list and account the read-only sidebar shows, kept in memory for
|
||||
* one tab.
|
||||
*
|
||||
* `/chart`, `/ephemeris` and `/reports` used to re-issue `GET /api/sessions` and
|
||||
* `GET /api/account` on every arrival, because each page mounted its own shell.
|
||||
* The shared `(secondary)` layout removes the per-page remount; this removes the
|
||||
* repeat when the reader leaves for `/` and comes back. Nothing durable is
|
||||
* written: a stale session list surviving a browser restart is worse than one
|
||||
* fetch, and it would outlive a sign-out.
|
||||
*
|
||||
* Keyed by account id so a second account in the same tab never reads the
|
||||
* first one's rows. The pointer is what makes a synchronous read possible: the
|
||||
* account id only arrives with the payload, so the reader cannot name its own
|
||||
* key before the first fetch has happened.
|
||||
*/
|
||||
|
||||
export const SIDEBAR_CACHE_TTL_MS = 60_000;
|
||||
|
||||
export type SidebarCacheEntry = {
|
||||
readonly accountId: string;
|
||||
readonly sessions: readonly SidebarSession[];
|
||||
readonly account: SidebarAccount | null;
|
||||
readonly fetchedAt: number;
|
||||
};
|
||||
|
||||
const entries = new Map<string, SidebarCacheEntry>();
|
||||
let currentAccountId: string | null = null;
|
||||
|
||||
export function readSidebarCache(): SidebarCacheEntry | null {
|
||||
if (currentAccountId === null) return null;
|
||||
return entries.get(currentAccountId) ?? null;
|
||||
}
|
||||
|
||||
export function writeSidebarCache(entry: SidebarCacheEntry): void {
|
||||
entries.set(entry.accountId, entry);
|
||||
currentAccountId = entry.accountId;
|
||||
}
|
||||
|
||||
/** True when the entry may be shown without going back to the network. */
|
||||
export function sidebarCacheIsFresh(entry: SidebarCacheEntry | null, now: number): boolean {
|
||||
if (entry === null) return false;
|
||||
const age = now - entry.fetchedAt;
|
||||
return age >= 0 && age < SIDEBAR_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from `/` after every session write — create, rename, delete, archive,
|
||||
* pin — and on any 401. Renaming a session and walking to `/chart` has to show
|
||||
* the new title, and a signed-out tab must not keep a list on screen.
|
||||
*/
|
||||
export function invalidateSidebarCache(): void {
|
||||
entries.clear();
|
||||
currentAccountId = null;
|
||||
}
|
||||
@@ -37,3 +37,56 @@ export function shouldHandleSidebarShortcut(event: SidebarShortcutEvent): boolea
|
||||
&& !event.altKey
|
||||
&& !event.shiftKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the desktop/tablet collapse state is remembered across pages.
|
||||
*
|
||||
* localStorage, not the shadcn `sidebar_state` cookie: `/` is a `○ Static`
|
||||
* route and `/chart` and `/ephemeris` are static too, so reading a cookie on
|
||||
* the server would opt all three out of static rendering. A cookie that only
|
||||
* the client may read buys nothing a localStorage key does not, so this is the
|
||||
* cheaper half of the D5 choice. The mobile drawer is deliberately excluded:
|
||||
* reopening a phone drawer on every navigation is not a preference anyone set.
|
||||
*/
|
||||
export const SIDEBAR_STATE_STORAGE_KEY = "sidebar_state";
|
||||
|
||||
type SidebarStateStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
function sidebarStateStorage(): SidebarStateStorage | null {
|
||||
try {
|
||||
return globalThis.localStorage ?? null;
|
||||
} catch {
|
||||
/* Private mode and blocked site data both throw on access. */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `null` when nothing was stored, or when storage is unavailable. */
|
||||
export function readStoredSidebarOpen(
|
||||
viewport: SidebarViewport,
|
||||
storage: SidebarStateStorage | null = sidebarStateStorage(),
|
||||
): boolean | null {
|
||||
if (viewport === "mobile" || storage === null) return null;
|
||||
try {
|
||||
const raw = storage.getItem(SIDEBAR_STATE_STORAGE_KEY);
|
||||
if (raw === "true") return true;
|
||||
if (raw === "false") return false;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredSidebarOpen(
|
||||
viewport: SidebarViewport,
|
||||
open: boolean,
|
||||
storage: SidebarStateStorage | null = sidebarStateStorage(),
|
||||
): void {
|
||||
if (viewport === "mobile" || storage === null) return;
|
||||
try {
|
||||
storage.setItem(SIDEBAR_STATE_STORAGE_KEY, open ? "true" : "false");
|
||||
} catch {
|
||||
/* Quota and private mode: the sidebar still works, it just forgets. */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user