新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
254 lines
9.2 KiB
TypeScript
254 lines
9.2 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useSearchParams } from "next/navigation";
|
||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||
import { Bookmark, Home, LayoutGrid, Menu, ShieldCheck } from "lucide-react";
|
||
import type { BoardWithCount, User } from "@/lib/api";
|
||
import { isStaff } from "@/lib/roles";
|
||
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||
import { userPath } from "@/lib/urlStyle";
|
||
import { RailDrawerPanel, useDrawerChrome } from "@/components/MobileRailDrawers";
|
||
|
||
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
||
function boardHref(id: number) {
|
||
return `/?board=${id}`;
|
||
}
|
||
|
||
// 板块目录裸面板:只有 j13-module 本体,不含外层 nav / 宽度 / sticky 类,
|
||
// 供侧栏注册表按管理端配置在任意侧栏渲染。
|
||
export function WorkspaceNavPanel({
|
||
boards,
|
||
user,
|
||
}: {
|
||
boards: BoardWithCount[];
|
||
user?: User | null;
|
||
}) {
|
||
const params = useSearchParams();
|
||
const urlStyle = useUrlStyle();
|
||
const activeBoard = Number(params.get("board")) || 0;
|
||
const showAdmin = !!user && isStaff(user.role);
|
||
|
||
return (
|
||
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden" aria-label="板块目录">
|
||
<div className="j13-rail-scroll j13-board-list min-h-0 flex-1">
|
||
<p className="j13-rail-group">浏览</p>
|
||
<Link
|
||
href="/"
|
||
className="j13-bcard"
|
||
aria-current={activeBoard === 0 ? "page" : undefined}
|
||
>
|
||
<span
|
||
className="j13-bcard-ico"
|
||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||
aria-hidden="true"
|
||
>
|
||
<Home size={15} />
|
||
</span>
|
||
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
|
||
</Link>
|
||
|
||
{user ? (
|
||
<Link href={`${userPath(urlStyle, user.id)}?tab=favorites`} className="j13-bcard">
|
||
<span
|
||
className="j13-bcard-ico"
|
||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||
aria-hidden="true"
|
||
>
|
||
<Bookmark size={15} />
|
||
</span>
|
||
<span className="j13-bcard-name flex-1 truncate">我的收藏</span>
|
||
</Link>
|
||
) : null}
|
||
|
||
{boards.length > 0 ? (
|
||
<>
|
||
<p className="j13-rail-group">板块</p>
|
||
{boards.map((b) => (
|
||
<Link
|
||
key={b.id}
|
||
href={boardHref(b.id)}
|
||
className="j13-bcard"
|
||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||
>
|
||
<span className={`av-${boardColor(b)} j13-bcard-ico`} aria-hidden="true">
|
||
<BoardIcon icon={b.icon} name={b.name} size={15} />
|
||
</span>
|
||
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||
<span className="j13-bcard-name truncate">{b.name}</span>
|
||
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
|
||
</span>
|
||
</Link>
|
||
))}
|
||
</>
|
||
) : null}
|
||
</div>
|
||
|
||
{showAdmin ? (
|
||
<div className="shrink-0 mt-1.5 pt-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||
<Link href="/admin" className="j13-bcard">
|
||
<span
|
||
className="j13-bcard-ico"
|
||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||
aria-hidden="true"
|
||
>
|
||
<ShieldCheck size={15} />
|
||
</span>
|
||
<span className="j13-bcard-name flex-1 truncate">管理面板</span>
|
||
</Link>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// 桌面左栏:整块模块,与右栏 j13-module 对齐
|
||
export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?: User | null }) {
|
||
return (
|
||
// sticky 必须挂在 flex 子项上:父级 items-start 会把内层缩成内容高,失去粘滞行程
|
||
<nav
|
||
className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]"
|
||
aria-label="工作台导航"
|
||
>
|
||
<WorkspaceNavPanel boards={boards} user={user} />
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
// 移动端:横向滑动板块条(两端渐隐指示「可左右滑动」);发帖入口由底部公共菜单承载。
|
||
// 板块条两端嵌左右抽屉入口(☰ 导航 / ▦ 面板),与书籍阅读页的左右工具栏同构:
|
||
// 左抽屉 = 桌面左栏(板块目录 + 工作台),右抽屉 = 桌面右栏面板;内容为空则对应入口不渲染
|
||
export function WorkspaceNavMobile({
|
||
boards,
|
||
left,
|
||
right,
|
||
}: {
|
||
boards: BoardWithCount[];
|
||
/** 左抽屉内容(桌面左栏,服务端渲染节点) */
|
||
left?: ReactNode;
|
||
/** 右抽屉内容(桌面右栏,服务端渲染节点) */
|
||
right?: ReactNode;
|
||
}) {
|
||
const params = useSearchParams();
|
||
const activeBoard = Number(params.get("board")) || 0;
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||
const [drawer, setDrawer] = useState<"left" | "right" | null>(null);
|
||
useDrawerChrome(drawer !== null, () => setDrawer(null));
|
||
|
||
const updateScrollState = useCallback(() => {
|
||
const el = scrollRef.current;
|
||
if (!el) return;
|
||
const max = el.scrollWidth - el.clientWidth;
|
||
setCanScrollLeft(el.scrollLeft > 4);
|
||
setCanScrollRight(max > 4 && el.scrollLeft < max - 4);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
updateScrollState();
|
||
const el = scrollRef.current;
|
||
if (!el || typeof ResizeObserver === "undefined") return;
|
||
const ro = new ResizeObserver(updateScrollState);
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
}, [boards, updateScrollState]);
|
||
|
||
// 进页 / 切板块时把激活 chip 滚到可视区中央
|
||
useEffect(() => {
|
||
const el = scrollRef.current;
|
||
if (!el) return;
|
||
el.querySelector<HTMLElement>('[aria-current="page"]')?.scrollIntoView({
|
||
block: "nearest",
|
||
inline: "center",
|
||
});
|
||
}, [activeBoard, boards]);
|
||
|
||
const triggerBtn =
|
||
"relative inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border transition-colors hover:bg-[var(--panel-2)] active:scale-95 " +
|
||
// 视觉 28px、触控热区扩到 44px(HIG 最小触控目标),仅扩大命中范围不改变外观
|
||
"before:absolute before:-inset-2 before:rounded-full before:content-['']";
|
||
|
||
return (
|
||
<div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6 bg-[var(--main-bg)] border-b border-[var(--line)] py-2.5">
|
||
<div className="relative flex items-center gap-1.5">
|
||
{left ? (
|
||
<button
|
||
type="button"
|
||
className={triggerBtn}
|
||
style={{ borderColor: "var(--line)", color: "var(--ink-2)" }}
|
||
onClick={() => setDrawer("left")}
|
||
aria-haspopup="dialog"
|
||
aria-label="打开导航抽屉"
|
||
>
|
||
<Menu size={15} aria-hidden />
|
||
</button>
|
||
) : null}
|
||
|
||
<div className="relative min-w-0 flex-1">
|
||
<div
|
||
ref={scrollRef}
|
||
className="j13-chips"
|
||
onScroll={updateScrollState}
|
||
>
|
||
<Link
|
||
href="/"
|
||
className="j13-chip"
|
||
aria-current={activeBoard === 0 ? "page" : undefined}
|
||
>
|
||
<Home size={13} /> 全部
|
||
</Link>
|
||
{boards.map((b) => (
|
||
<Link
|
||
key={b.id}
|
||
href={boardHref(b.id)}
|
||
className="j13-chip"
|
||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||
>
|
||
<BoardIcon icon={b.icon} name={b.name} size={13} />
|
||
{b.name}
|
||
</Link>
|
||
))}
|
||
</div>
|
||
{/* 渐隐滑动指示:遮罩与背景同色,pointer-events-none 不挡触控 */}
|
||
<div
|
||
className={`j13-chip-fade j13-chip-fade-left ${canScrollLeft ? "is-on" : ""}`}
|
||
aria-hidden="true"
|
||
/>
|
||
<div
|
||
className={`j13-chip-fade j13-chip-fade-right ${canScrollRight ? "is-on" : ""}`}
|
||
aria-hidden="true"
|
||
/>
|
||
</div>
|
||
|
||
{right ? (
|
||
<button
|
||
type="button"
|
||
className={triggerBtn}
|
||
style={{ borderColor: "var(--line)", color: "var(--ink-2)" }}
|
||
onClick={() => setDrawer("right")}
|
||
aria-haspopup="dialog"
|
||
aria-label="打开面板抽屉"
|
||
>
|
||
<LayoutGrid size={15} aria-hidden />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* 左右抽屉:内容由服务端渲染后传入;lg 起隐藏(桌面用常驻侧栏) */}
|
||
{left ? (
|
||
<RailDrawerPanel side="left" title="导航" open={drawer === "left"} onClose={() => setDrawer(null)}>
|
||
{left}
|
||
</RailDrawerPanel>
|
||
) : null}
|
||
{right ? (
|
||
<RailDrawerPanel side="right" title="面板" open={drawer === "right"} onClose={() => setDrawer(null)}>
|
||
{right}
|
||
</RailDrawerPanel>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|