Files
jiang13-bbs/frontend/components/home/WorkspaceNav.tsx

292 lines
11 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 Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Bookmark, CodeXml, Home, LayoutGrid, Menu, ShieldCheck, Trophy } 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 { leaderboardPath, snippetsPath, 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 pathname = usePathname();
const urlStyle = useUrlStyle();
const activeBoard = Number(params.get("board")) || 0;
const showAdmin = !!user && isStaff(user.role);
const snipHref = snippetsPath(urlStyle);
const lbHref = leaderboardPath(urlStyle);
// 与 Header 高亮规则一致:覆盖标准路径与伪静态(/snippet-*.html、/rank.html 等)
const snippetsActive =
!!pathname && (pathname.startsWith("/snippets") || pathname.startsWith("/snippet-"));
const leaderboardActive = !!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`));
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>
{/* 不进底栏的全局入口:仅移动端展示(首页 ☰ 导航抽屉是其在移动端的唯一路径),
桌面端由顶部导航承担,左栏不重复 */}
<Link
href={snipHref}
className="j13-bcard lg:hidden"
aria-current={snippetsActive ? "page" : undefined}
>
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<CodeXml size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">代码片段</span>
</Link>
<Link
href={lbHref}
className="j13-bcard lg:hidden"
aria-current={leaderboardActive ? "page" : undefined}
>
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<Trophy 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>
);
}