"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, normalizeBrandSloganVisible, 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"]; brand_slogan_visible: boolean; footer_links: PublicSettings["footer_links"]; url_style: UrlStyle; mobile_tabs: MobileTabKey[]; header_nav: HeaderNavItem[]; }; const SiteBrandContext = createContext(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, brand_slogan_visible: s.brand_slogan_visible, 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.brand_slogan_visible === b.brand_slogan_visible && 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 注入的 由 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", brand_slogan_visible: true, 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(() => 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 | 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), brand_slogan_visible: raw.brand_slogan_visible !== undefined ? normalizeBrandSloganVisible(raw.brand_slogan_visible) : prev.brand_slogan_visible, 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(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 {children}; }