- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配
194 lines
6.7 KiB
TypeScript
194 lines
6.7 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useSearchParams } from "next/navigation";
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { Bookmark, Home, 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";
|
||
|
||
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
||
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 }: { boards: BoardWithCount[] }) {
|
||
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 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]);
|
||
|
||
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">
|
||
<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>
|
||
</div>
|
||
);
|
||
}
|