feat: 友链/书库/移动端导航/排行榜等多模块功能

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

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
2026-09-30 16:30:07 +08:00
parent e487bc2e92
commit 4f3f3a265c
105 changed files with 12371 additions and 852 deletions

View File

@@ -0,0 +1,206 @@
"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
)}
</>
);
}