Files
jiang13-bbs/frontend/components/MobileRailDrawers.tsx
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

207 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Hash, ListTree, X } from "lucide-react";
type DrawerSide = "left" | "right";
export interface RailDrawerSide {
/** 触发按钮与抽屉标题 */
label: string;
/** 抽屉内容(服务端渲染节点) */
content: ReactNode;
/** 按钮图标(缺省:左 ListTree / 右 Hash) */
icon?: ReactNode;
}
/**
* 抽屉开合副作用:ESC 关闭 + 锁定背景滚动。
* onClose 经 ref 引用,避免回调身份变化导致反复装卸监听与滚动锁泄漏。
*/
export function useDrawerChrome(open: boolean, onClose: () => void) {
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeRef.current();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open]);
}
/**
* 单侧抽屉面板(lg 以下生效):遮罩 + 侧滑面板。
* - 关闭时保留 DOM 以获得滑出过渡,invisible 延迟切换保证动画完整;
* - 抽屉内点击链接(锚点 / 跳转)后自动收起。
*/
export function RailDrawerPanel({
side,
title,
open,
onClose,
children,
}: {
side: DrawerSide;
title: string;
open: boolean;
onClose: () => void;
children: ReactNode;
}) {
return (
<div
className="lg:hidden"
style={{ visibility: open ? "visible" : "hidden", transition: "visibility .2s" }}
aria-hidden={!open}
>
<div
className={`fixed inset-0 z-[1090] bg-black/40 transition-opacity duration-200 ${
open ? "opacity-100" : "pointer-events-none opacity-0"
}`}
onClick={onClose}
/>
<aside
role="dialog"
aria-modal="true"
aria-label={title}
className={`fixed bottom-0 z-[1100] flex w-[min(82vw,320px)] flex-col ${
side === "left" ? "left-0" : "right-0"
} transition-transform duration-200 ease-out ${
open ? "translate-x-0" : side === "left" ? "-translate-x-full" : "translate-x-full"
}`}
style={{
top: 0,
background: "var(--panel)",
boxShadow: "0 0 40px rgba(0,0,0,0.18)",
borderLeft: side === "right" ? "1px solid var(--line)" : undefined,
borderRight: side === "left" ? "1px solid var(--line)" : undefined,
}}
onClick={(e) => {
if ((e.target as HTMLElement).closest("a")) onClose();
}}
>
<div
className="flex shrink-0 items-center gap-2 px-4 py-3"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="text-[13px] font-bold" style={{ color: "var(--ink)" }}>
{title}
</span>
<button
type="button"
className="ml-auto rounded-lg p-1.5 transition-colors hover:bg-[var(--panel-2)]"
onClick={onClose}
aria-label="关闭"
>
<X size={16} style={{ color: "var(--ink-2)" }} aria-hidden />
</button>
</div>
<div className="j13-rail-scroll flex flex-1 flex-col gap-3 overflow-y-auto p-3">
{children}
</div>
</aside>
</div>
);
}
/**
* 通用移动端侧栏抽屉(lg 以下生效,桌面仍用常驻侧栏):
* - 吸顶工具栏贴在全局 Header 下方,左右各一个抽屉触发按钮,中间可放页面上文;
* - 左右两侧内容均可选,两侧皆空时整体不渲染;
* - 渐进增强:ESC 关闭、背景滚动锁定、点链接自动收起。
*/
export default function MobileRailDrawers({
left,
right,
center,
}: {
/** 左抽屉(对应桌面左栏) */
left?: RailDrawerSide;
/** 右抽屉(对应桌面右栏) */
right?: RailDrawerSide;
/** 工具栏中间内容(如当前文章标题),可省略 */
center?: ReactNode;
}) {
const [open, setOpen] = useState<DrawerSide | null>(null);
useDrawerChrome(open !== null, () => setOpen(null));
if (!left && !right) return null;
const btn =
"relative inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12.5px] font-medium shrink-0 transition-colors hover:bg-[var(--panel-2)] active:scale-[0.97] " +
// 触控热区上下外扩 8px(视觉不变),移动端更易点中
"before:absolute before:inset-x-0 before:-inset-y-2 before:content-['']";
const sides: { side: DrawerSide; cfg?: RailDrawerSide; fallbackIcon: ReactNode }[] = [
{ side: "left", cfg: left, fallbackIcon: <ListTree size={15} style={{ color: "var(--accent)" }} aria-hidden /> },
{ side: "right", cfg: right, fallbackIcon: <Hash size={15} style={{ color: "var(--accent)" }} aria-hidden /> },
];
return (
<>
{/* 吸顶工具栏:与书籍阅读页一致的三段布局——左钮贴左、中间文字居中、右钮贴右;
有中间文字而缺某侧按钮时以等宽占位保持文字不偏心 */}
<header
className="lg:hidden sticky z-30 mb-3 flex items-center gap-1 rounded-xl px-1 py-1"
style={{
top: "var(--header-h)",
background: "color-mix(in srgb, var(--panel) 88%, transparent)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
border: "1px solid var(--line)",
}}
>
{left ? (
<button
type="button"
className={btn}
onClick={() => setOpen("left")}
aria-haspopup="dialog"
>
{left.icon ?? sides[0].fallbackIcon}
{left.label}
</button>
) : center ? (
<span className="w-[52px] shrink-0" aria-hidden />
) : null}
{center ? <div className="min-w-0 flex-1 px-1 text-center">{center}</div> : null}
{right ? (
<button
type="button"
className={`${btn} ml-auto`}
onClick={() => setOpen("right")}
aria-haspopup="dialog"
>
{right.icon ?? sides[1].fallbackIcon}
{right.label}
</button>
) : center ? (
<span className="w-[52px] shrink-0" aria-hidden />
) : null}
</header>
{sides.map(({ side, cfg }) =>
cfg ? (
<RailDrawerPanel
key={side}
side={side}
title={cfg.label}
open={open === side}
onClose={() => setOpen(null)}
>
{cfg.content}
</RailDrawerPanel>
) : null
)}
</>
);
}