新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
129 lines
4.5 KiB
TypeScript
129 lines
4.5 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||
import { usePathname } from "next/navigation";
|
||
import Header from "@/components/Header";
|
||
import Footer from "@/components/Footer";
|
||
import BackToTop from "@/components/BackToTop";
|
||
import MobileTabBar from "@/components/MobileTabBar";
|
||
import type { User } from "@/lib/api";
|
||
import { isWideShellPath } from "@/lib/siteUrl";
|
||
import { libraryPath, userPath } from "@/lib/urlStyle";
|
||
import { useSiteBrand } from "@/components/SiteBrandProvider";
|
||
|
||
function pathIsAdmin(pathname: string | null) {
|
||
if (!pathname) return false;
|
||
return pathname === "/admin" || pathname.startsWith("/admin/");
|
||
}
|
||
|
||
function pathIsChat(pathname: string | null) {
|
||
if (!pathname) return false;
|
||
return pathname === "/chat" || pathname.startsWith("/chat/");
|
||
}
|
||
|
||
function pathIsComposeStudio(pathname: string | null) {
|
||
if (!pathname) return false;
|
||
return pathname === "/compose" || /^\/post\/[^/]+\/edit\/?$/.test(pathname);
|
||
}
|
||
|
||
/**
|
||
* 公开站 chrome 闸门:/admin 不渲染 Header/Footer/1440 main。
|
||
* 首屏用 middleware 的 x-pathname 初值保证 hydration 一致;
|
||
* 挂载后同步跟 usePathname(渲染期判定,避免 useEffect 晚一拍闪框)。
|
||
*/
|
||
export default function SiteChrome({
|
||
initialIsAdmin,
|
||
user,
|
||
unread,
|
||
chatUnread,
|
||
theme,
|
||
children,
|
||
}: {
|
||
initialIsAdmin: boolean;
|
||
user: User | null;
|
||
unread: number;
|
||
chatUnread: number;
|
||
theme: "light" | "dark";
|
||
children: React.ReactNode;
|
||
}) {
|
||
const pathname = usePathname();
|
||
const [mounted, setMounted] = useState(false);
|
||
const isFirstPath = useRef(true);
|
||
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
}, []);
|
||
|
||
// Next.js App Router:新页顶若仍在视口内会跳过滚顶。sticky Header 把
|
||
// 「微滚后的页顶」判成已可见,软导航就会停在资料卡上沿。父级 layoutEffect
|
||
// 晚于 Next.js 子级滚动处理,且不影响 F5 / 带 hash 的定位。
|
||
useLayoutEffect(() => {
|
||
if (isFirstPath.current) {
|
||
isFirstPath.current = false;
|
||
return;
|
||
}
|
||
if (pathIsAdmin(pathname)) return;
|
||
if (window.location.hash) return;
|
||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||
}, [pathname]);
|
||
|
||
// 首屏跟 SSR;之后在渲染期直接读 pathname,软导航无 Header 闪动
|
||
const isAdmin = mounted ? pathIsAdmin(pathname) : initialIsAdmin;
|
||
|
||
useLayoutEffect(() => {
|
||
document.documentElement.dataset.j13Chrome = isAdmin ? "admin" : "site";
|
||
}, [isAdmin]);
|
||
|
||
const brand = useSiteBrand();
|
||
|
||
if (isAdmin) {
|
||
return <>{children}</>;
|
||
}
|
||
|
||
// 聊天 / 发帖工作台锁视口,窗口回顶无意义
|
||
const showBackToTop = !pathIsChat(pathname) && !pathIsComposeStudio(pathname);
|
||
// 底栏只在已启用 tab 的根路径显示(个人页子 tab 走 ?tab= 查询参数,不影响 pathname);
|
||
// 发帖无「首页」概念不产生可见路径,发帖工作台 / 帖子编辑页继续排除
|
||
const enabledTabs = new Set(brand.mobile_tabs);
|
||
const meHref = user ? userPath(brand.url_style, user.id) : "";
|
||
const tabBarRoots: string[] = [];
|
||
if (enabledTabs.has("home")) tabBarRoots.push("/");
|
||
if (enabledTabs.has("library")) tabBarRoots.push(libraryPath(brand.url_style));
|
||
if (enabledTabs.has("messages")) tabBarRoots.push("/chat");
|
||
if (enabledTabs.has("me") && meHref) tabBarRoots.push(meHref);
|
||
const showTabBar =
|
||
!!pathname && !pathIsComposeStudio(pathname) && tabBarRoots.includes(pathname);
|
||
// 书库页正文区放宽(Header/Footer 保持 1440,避免顶栏菜单位置随页面跳动)
|
||
const wideShell = isWideShellPath(pathname);
|
||
|
||
return (
|
||
<>
|
||
<Header
|
||
initialUser={user}
|
||
initialUnread={unread}
|
||
initialChatUnread={chatUnread}
|
||
initialTheme={theme}
|
||
siteName={brand.site_name}
|
||
wordmark={brand.site_wordmark}
|
||
logoLight={brand.logo_light_url}
|
||
logoDark={brand.logo_dark_url}
|
||
brandMark={brand.brand_mark}
|
||
brandLogoSize={brand.brand_logo_size}
|
||
brandLogoFit={brand.brand_logo_fit}
|
||
/>
|
||
<main
|
||
id="main"
|
||
tabIndex={-1}
|
||
className={`flex-1 w-full ${wideShell ? "max-w-[1680px]" : "max-w-[1440px]"} mx-auto px-4 sm:px-6 pt-4 sm:pt-[var(--main-pad-y)] pb-[var(--main-pad-y)] outline-none`}
|
||
>
|
||
{children}
|
||
</main>
|
||
<Footer siteName={brand.site_name} links={brand.footer_links} />
|
||
{showTabBar ? (
|
||
<MobileTabBar user={user} initialChatUnread={chatUnread} />
|
||
) : null}
|
||
{showBackToTop ? <BackToTop /> : null}
|
||
</>
|
||
);
|
||
}
|