"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 { 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; }; 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, footer_links: s.footer_links, url_style: s.url_style, }; } 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) ); } 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", footer_links: [], url_style: "default", }; } 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), 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, }; 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}; }