Files
jiang13-bbs/frontend/app/layout.tsx
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

205 lines
8.4 KiB
TypeScript
Raw Permalink 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.
import OperationalBanner from "@/components/OperationalBanner";
import type { Metadata, Viewport } from "next";
import { cookies, headers } from "next/headers";
import { Plus_Jakarta_Sans, Noto_Sans_SC, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import SiteBrandProvider from "@/components/SiteBrandProvider";
import SiteChrome from "@/components/SiteChrome";
import Toaster from "@/components/Toaster";
import TipHost from "@/components/TipHost";
import AccountGuard from "@/components/AccountGuard";
import ThemeSync from "@/components/ThemeSync";
import RealtimeProvider from "@/components/RealtimeProvider";
import VisitBeacon from "@/components/VisitBeacon";
import PostLinkProvider from "@/components/PostLinkProvider";
import CodeFoldProvider from "@/components/markdown/CodeFoldProvider";
import MotionProvider from "@/components/MotionProvider";
import CommentsPolicyProvider from "@/components/CommentsPolicyProvider";
import MessagesPolicyProvider from "@/components/MessagesPolicyProvider";
import type { User } from "@/lib/api";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies";
import { accentStyleCSS } from "@/lib/theme";
import { bgConfigFromSettings, bgStyleCSS } from "@/lib/bg";
import { levelVarsCSS } from "@/lib/levels";
import { siteDocumentTitle } from "@/lib/brand";
import { motionConfigFromSettings } from "@/lib/motion";
import { peekPublicMetaSnapshot, publishPublicMetaSnapshot } from "@/lib/publicMetaSnapshot";
import { metadataBaseURL } from "@/lib/siteUrl";
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
// 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动
const jakarta = Plus_Jakarta_Sans({
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
variable: "--font-jakarta",
display: "swap",
});
const notoSC = Noto_Sans_SC({
// 须覆盖站内实际用到的字重:缺档时浏览器会伪粗(synthetic bold),中文在 2K 屏发虚发糊
weight: ["400", "500", "600", "700", "800", "900"],
variable: "--font-noto-sc",
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
weight: ["400", "500", "700"],
variable: "--font-jetbrains",
display: "swap",
});
// 必须同步:根 layout 若在此 await(拉设置 / headers),Next 流式 metadata
// 会在等待期把标签临时写成 localhost:3000,开发态 RSC/HMR 一密就来回闪。
export function generateMetadata(): Metadata {
const settings = peekPublicMetaSnapshot();
const name = settings.site_name || "姜十三论坛";
const title = siteDocumentTitle(name, settings.site_slogan || "");
const desc = settings.site_description || "姜十三论坛 - 技术分享与讨论社区";
const keywords = settings.site_keywords?.length ? settings.site_keywords : undefined;
const metadataBase = metadataBaseURL();
return {
...(metadataBase ? { metadataBase } : {}),
title: {
default: title,
template: `%s · ${name}`,
},
description: desc,
keywords,
// 固定地址:换图后浏览器/书签侧的 icon 链接不变
...(settings.favicon_url ? { icons: { icon: "/api/brand/favicon" } } : {}),
openGraph: {
title,
description: desc,
type: "website",
},
};
}
// 移动端键盘弹出时压缩布局高度(而非仅视觉平移),聊天输入框随 100dvh
// 收缩保持可见,配合聊天页锁视口布局;再叠加聚焦时 scrollIntoView 兜底
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
interactiveWidget: "resizes-content",
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const headerStore = await headers();
// middleware 写入 x-pathname;供 SiteChrome SSR 初值(软导航靠客户端 pathname)
const pathname = headerStore.get("x-pathname") || "";
const isAdmin = pathname === "/admin" || pathname.startsWith("/admin/");
// 浅色优先的设计系统,仅在显式选择暗色时挂 .dark
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
// SSR 直出登录态(access 过期时 middleware 已先行轮转并注入请求头,见 middleware.ts),
// 保证 F5 初始 HTML 中的右上角就是正确的用户区,而非先闪"登录/注册"。
// 无 access cookie 即按游客渲染;客户端 Header 在 CSRF 可读时再静默校正一次。
// 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
// 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
const settings = await getPublicSettingsCached();
publishPublicMetaSnapshot(settings);
const accent = settings.accent;
const accentCSS = accentStyleCSS(accent || "");
const bgCfg = bgConfigFromSettings(settings);
const bgCSS = bgStyleCSS(bgCfg);
const motionCfg = motionConfigFromSettings(settings);
const motionHtmlClass = [
!motionCfg.ui_animations ? "j13-reduce-motion" : "",
motionCfg.ui_animations && !motionCfg.anim_chrome ? "j13-reduce-chrome" : "",
bgCfg.bg_site_url ? "j13-has-site-bg" : "",
bgCfg.bg_admin_url ? "j13-has-admin-bg" : "",
]
.filter(Boolean)
.join(" ");
let user: User | null = null;
let unread = 0;
let chatUnread = 0;
const hasAuthCookie = !!cookieStore.get(TOKEN_COOKIE)?.value;
if (hasAuthCookie) {
// 用户信息与未读数合并为一次请求;只转发最小化的 access cookie
const me = await getMeCached(authCookieHeader(cookieStore) || undefined);
user = me.user;
unread = me.unread_count;
chatUnread = me.chat_unread_count ?? 0;
}
return (
<html
lang="zh-CN"
data-j13-chrome={isAdmin ? "admin" : "site"}
data-lv-fx={settings.levels_fx === false ? "off" : "on"}
className={`${theme === "dark" ? "dark" : ""} ${motionHtmlClass} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`.trim()}
>
<body className="min-h-screen flex flex-col">
{accentCSS ? (
// React 托管的 SSR 主题节点;客户端换肤走 j13-accent-runtime,禁止对此外挂 remove
<style
id="j13-accent-vars"
data-accent-source="ssr"
dangerouslySetInnerHTML={{ __html: accentCSS }}
/>
) : null}
{bgCSS ? (
<style
id="j13-bg-vars"
data-bg-source="ssr"
dangerouslySetInnerHTML={{ __html: bgCSS }}
/>
) : null}
{/* 等级配色变量(--lv-N-fg/-bg,引用设计 token 随明暗主题切换);恒渲染兼作兜底 */}
<style
id="j13-level-vars"
data-level-source="ssr"
dangerouslySetInnerHTML={{ __html: levelVarsCSS(settings.levels) }}
/>
<a href="#main" className="skip-link">
跳到主要内容
</a>
<SiteBrandProvider initial={settings}>
<MotionProvider initial={motionCfg}>
<CommentsPolicyProvider
allowComments={settings.allow_comments !== false}
requireLogin={settings.comments_require_login === true}
>
<MessagesPolicyProvider allowMessages={settings.allow_messages !== false}>
<PostLinkProvider openInNewTab={settings.post_link_new_tab !== false}>
<CodeFoldProvider
initialEnabled={settings.code_block_auto_fold}
initialFoldLines={settings.code_block_fold_lines}
>
<SiteChrome
initialIsAdmin={isAdmin}
user={user}
unread={unread}
chatUnread={settings.allow_messages !== false ? chatUnread : 0}
theme={theme}
>
<OperationalBanner />
{children}
</SiteChrome>
</CodeFoldProvider>
</PostLinkProvider>
</MessagesPolicyProvider>
</CommentsPolicyProvider>
</MotionProvider>
</SiteBrandProvider>
<Toaster />
<TipHost />
<AccountGuard />
<ThemeSync initialAccent={accent || ""} initialBg={bgCfg} />
<VisitBeacon />
<RealtimeProvider userId={user ? user.id : null} />
</body>
</html>
);
}