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
93 lines
2.9 KiB
TypeScript
93 lines
2.9 KiB
TypeScript
export type SidebarViewport = "mobile" | "tablet" | "desktop";
|
|
|
|
type EditableTarget = {
|
|
readonly tagName?: unknown;
|
|
readonly isContentEditable?: unknown;
|
|
};
|
|
|
|
export type SidebarShortcutEvent = Pick<
|
|
KeyboardEvent,
|
|
"key" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey"
|
|
> & {
|
|
readonly target: EventTarget | EditableTarget | null;
|
|
};
|
|
|
|
function isEditableTarget(target: SidebarShortcutEvent["target"]): target is EditableTarget {
|
|
return target !== null && typeof target === "object";
|
|
}
|
|
|
|
export function sidebarViewportForWidth(width: number): SidebarViewport {
|
|
if (width < 768) return "mobile";
|
|
if (width < 1024) return "tablet";
|
|
return "desktop";
|
|
}
|
|
|
|
export function defaultSidebarOpen(viewport: SidebarViewport): boolean {
|
|
return viewport === "desktop";
|
|
}
|
|
|
|
export function shouldHandleSidebarShortcut(event: SidebarShortcutEvent): boolean {
|
|
const target = event.target;
|
|
const editable = isEditableTarget(target)
|
|
&& (target.isContentEditable === true || /^(INPUT|TEXTAREA|SELECT)$/.test(String(target.tagName ?? "")));
|
|
|
|
return !editable
|
|
&& event.key.toLowerCase() === "b"
|
|
&& (event.metaKey || event.ctrlKey)
|
|
&& !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. */
|
|
}
|
|
}
|
|
|