Files
jiang13-bbs/frontend/components/home/WorkspaceNav.tsx
freefire 7a44c1f2cf feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
2026-09-27 04:01:42 +08:00

194 lines
6.7 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 { 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>
);
}