Files
jiang13-bbs/frontend/components/SiteBrandProvider.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

198 lines
7.3 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 { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { apiGetSettings, type PublicSettings } from "@/lib/api";
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand";
import { normalizeUrlStyle, type UrlStyle } from "@/lib/urlStyle";
import { DEFAULT_MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "@/lib/mobileTabs";
import { DEFAULT_HEADER_NAV, normalizeHeaderNav, type HeaderNavItem } from "@/lib/headerNav";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile";
export type SiteBrand = {
site_name: string;
site_wordmark: string;
site_slogan: string;
logo_light_url: string;
logo_dark_url: string;
favicon_url: string;
brand_mark: PublicSettings["brand_mark"];
brand_logo_size: PublicSettings["brand_logo_size"];
brand_logo_fit: PublicSettings["brand_logo_fit"];
footer_links: PublicSettings["footer_links"];
url_style: UrlStyle;
mobile_tabs: MobileTabKey[];
header_nav: HeaderNavItem[];
};
const SiteBrandContext = createContext<SiteBrand | null>(null);
function fromSettings(s: PublicSettings): SiteBrand {
return {
site_name: s.site_name,
site_wordmark: s.site_wordmark,
site_slogan: s.site_slogan,
logo_light_url: s.logo_light_url,
logo_dark_url: s.logo_dark_url,
favicon_url: s.favicon_url,
brand_mark: s.brand_mark,
brand_logo_size: s.brand_logo_size,
brand_logo_fit: s.brand_logo_fit,
footer_links: s.footer_links,
url_style: s.url_style,
mobile_tabs: s.mobile_tabs,
header_nav: s.header_nav,
};
}
function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
return (
a.site_name === b.site_name &&
a.site_wordmark === b.site_wordmark &&
a.site_slogan === b.site_slogan &&
a.logo_light_url === b.logo_light_url &&
a.logo_dark_url === b.logo_dark_url &&
a.favicon_url === b.favicon_url &&
a.brand_mark === b.brand_mark &&
a.brand_logo_size === b.brand_logo_size &&
a.brand_logo_fit === b.brand_logo_fit &&
a.url_style === b.url_style &&
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links) &&
JSON.stringify(a.mobile_tabs) === JSON.stringify(b.mobile_tabs) &&
JSON.stringify(a.header_nav) === JSON.stringify(b.header_nav)
);
}
function applyFavicon(url: string) {
// 只管理 #j13-favicon,禁止 remove 其它 icon 节点:
// Next Metadata 注入的 <link rel="icon"> 由 React 持有,手动删会导致 removeChild 空指针。
let link = document.getElementById("j13-favicon") as HTMLLinkElement | null;
if (!link) {
link = document.createElement("link");
link.id = "j13-favicon";
link.rel = "icon";
}
const href = url || "/favicon.ico";
if (link.getAttribute("href") !== href) link.href = href;
// 挪到 head 末尾,优先于 SSR 注入的 icon
document.head.appendChild(link);
}
function retitle(prev: SiteBrand, next: SiteBrand) {
if (prev.site_name === next.site_name && prev.site_slogan === next.site_slogan) return;
const oldDefault = siteDocumentTitle(prev.site_name, prev.site_slogan);
const nextDefault = siteDocumentTitle(next.site_name, next.site_slogan);
const cur = document.title;
if (cur === oldDefault) {
document.title = nextDefault;
return;
}
const oldName = prev.site_name.trim() || "姜十三论坛";
const newName = next.site_name.trim() || "姜十三论坛";
const suffix = ` · ${oldName}`;
if (oldName !== newName && cur.endsWith(suffix)) {
document.title = cur.slice(0, -suffix.length) + ` · ${newName}`;
}
}
export function useSiteBrand(): SiteBrand {
const ctx = useContext(SiteBrandContext);
if (!ctx) {
return {
site_name: "姜十三论坛",
site_wordmark: "",
site_slogan: "",
logo_light_url: "",
logo_dark_url: "",
favicon_url: "",
brand_mark: "image_text",
brand_logo_size: "sq",
brand_logo_fit: "contain",
footer_links: [],
url_style: "default",
mobile_tabs: [...DEFAULT_MOBILE_TABS],
header_nav: DEFAULT_HEADER_NAV.map((item) => ({ ...item })),
};
}
return ctx;
}
/** 详情页伪静态风格(SSR 初值与水合一致,设置保存后 realtime 热更) */
export function useUrlStyle(): UrlStyle {
return useSiteBrand().url_style;
}
/** SSR 初值 + settings:changed:页眉/页脚/标题/favicon 不必整页刷新 */
export default function SiteBrandProvider({
initial,
children,
}: {
initial: PublicSettings;
children: ReactNode;
}) {
const [brand, setBrand] = useState<SiteBrand>(() => fromSettings(initial));
const brandRef = useRef(brand);
brandRef.current = brand;
const value = useMemo(() => brand, [brand]);
useEffect(() => {
const next = fromSettings(initial);
setBrand((prev) => {
if (sameBrand(prev, next)) return prev;
retitle(prev, next);
if (prev.favicon_url !== next.favicon_url) applyFavicon(next.favicon_url);
return next;
});
}, [initial]);
useEffect(() => {
const apply = (raw: Partial<PublicSettings> | RtSettingsData) => {
const prev = brandRef.current;
const next: SiteBrand = {
site_name: typeof raw.site_name === "string" && raw.site_name.trim() ? raw.site_name : prev.site_name,
site_wordmark: typeof raw.site_wordmark === "string" ? raw.site_wordmark : prev.site_wordmark,
site_slogan: typeof raw.site_slogan === "string" ? raw.site_slogan : prev.site_slogan,
logo_light_url: typeof raw.logo_light_url === "string" ? raw.logo_light_url : prev.logo_light_url,
logo_dark_url: typeof raw.logo_dark_url === "string" ? raw.logo_dark_url : prev.logo_dark_url,
favicon_url: typeof raw.favicon_url === "string" ? raw.favicon_url : prev.favicon_url,
brand_mark: normalizeBrandMark(raw.brand_mark ?? prev.brand_mark),
brand_logo_size: normalizeBrandLogoSize(raw.brand_logo_size ?? prev.brand_logo_size),
brand_logo_fit: normalizeBrandLogoFit(raw.brand_logo_fit ?? prev.brand_logo_fit),
footer_links: Array.isArray(raw.footer_links)
? normalizeFooterLinks(raw.footer_links)
: prev.footer_links,
url_style: raw.url_style !== undefined ? normalizeUrlStyle(raw.url_style) : prev.url_style,
mobile_tabs: raw.mobile_tabs !== undefined ? normalizeMobileTabs(raw.mobile_tabs) : prev.mobile_tabs,
header_nav: raw.header_nav !== undefined ? normalizeHeaderNav(raw.header_nav) : prev.header_nav,
};
if (sameBrand(prev, next)) return;
retitle(prev, next);
if (prev.favicon_url !== next.favicon_url) applyFavicon(next.favicon_url);
brandRef.current = next;
setBrand(next);
};
const off = realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
if (data) apply(data);
});
let last = 0;
const offVisible = onDocumentVisible(() => {
const now = Date.now();
if (now - last < VISIBLE_RECONCILE_GAP_MS) return;
last = now;
void apiGetSettings()
.then(apply)
.catch(() => {});
}, VISIBLE_RECONCILE_GAP_MS);
return () => {
off();
offVisible();
};
}, []);
return <SiteBrandContext.Provider value={value}>{children}</SiteBrandContext.Provider>;
}