Files
jiang13-bbs/frontend/components/SiteBrandProvider.tsx
freefire 46e0cdc0b3 feat: 媒体库 blob 去重存储与品牌/注册等前端调整
新增:
- blob_store 二进制去重存储 + blob_dedup 测试

其它:
- 后端 settings/auth/media_library/upload/operations 服务与 handler 调整
- 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
2026-10-03 02:38:11 +08:00

206 lines
7.7 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, 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<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,
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 注入的 <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",
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<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),
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<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>;
}