桌面侧栏可在 200 到 360 像素之间拖宽,拖到最左收起,宽度记在本机。手机从左边滑出。会话在同一组里拖动排序,顺序写入可空的 sidebar_order,收藏仍置顶。账户菜单、设置、反馈和退出确认改用组件库。顶栏点数只显示图标和数字。启程清单还在。菜单、拖宽、拖动排序和翻页在首屏之后再载入。
自测:tsc 退出码 0;npm run lint 0 error、121 warning;改到的前端测试 205 项全过;npm run build 后首页仍是 static;首屏 gzip 630350,相对 8f7c0f7d 的 621226 为 +1.47%。test:db 92 项里 79 过、13 败,失败名与 Windows 已知清单一致,排序列在通过的 ok 37。没有跑全量 npm test,没有跑 Python 快速门。没有登录态,没有在浏览器里拖侧栏。
Co-Authored-By: Grok (agent loop)
133 lines
4.2 KiB
TypeScript
133 lines
4.2 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";
|
|
|
|
/** Pixel width of the expanded desktop/tablet sidebar. The icon rail is not stored here. */
|
|
export const SIDEBAR_WIDTH_STORAGE_KEY = "sidebar_width";
|
|
export const SIDEBAR_WIDTH_MIN = 200;
|
|
export const SIDEBAR_WIDTH_MAX = 360;
|
|
export const SIDEBAR_WIDTH_DEFAULT = 288;
|
|
export const SIDEBAR_WIDTH_TABLET = 240;
|
|
export const SIDEBAR_WIDTH_COLLAPSED = 64;
|
|
|
|
export function clampSidebarWidth(width: number): number {
|
|
if (!Number.isFinite(width)) return SIDEBAR_WIDTH_DEFAULT;
|
|
return Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, Math.round(width)));
|
|
}
|
|
|
|
export function readStoredSidebarWidth(
|
|
storage: SidebarStateStorage | null = sidebarStateStorage(),
|
|
): number | null {
|
|
if (storage === null) return null;
|
|
try {
|
|
const raw = storage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);
|
|
if (raw === null || raw === "") return null;
|
|
const width = Number(raw);
|
|
if (!Number.isFinite(width)) return null;
|
|
return clampSidebarWidth(width);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function writeStoredSidebarWidth(
|
|
width: number,
|
|
storage: SidebarStateStorage | null = sidebarStateStorage(),
|
|
): void {
|
|
if (storage === null) return;
|
|
try {
|
|
storage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(clampSidebarWidth(width)));
|
|
} catch {
|
|
/* Quota and private mode: the drag still works for this visit. */
|
|
}
|
|
}
|
|
|
|
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. */
|
|
}
|
|
}
|
|
|