Files
jiang13-bbs/frontend/components/SiteBrandProvider.tsx
2026-09-19 04:14:13 +08:00

184 lines
6.2 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 } from "@/lib/brand";
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"];
};
const SiteBrandContext = createContext<SiteBrand | null>(null);
function fromSettings(s: Pick<PublicSettings, keyof SiteBrand>): 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,
};
}
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 &&
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links)
);
}
function applyFavicon(url: string) {
const existing = document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]');
if (!url) {
document.getElementById("j13-favicon")?.remove();
const hasDefault = [...existing].some((el) => el.id !== "j13-favicon" && el.href.includes("favicon"));
if (!hasDefault) {
const link = document.createElement("link");
link.rel = "icon";
link.href = "/favicon.ico";
document.head.appendChild(link);
}
return;
}
existing.forEach((el) => {
if (el.id !== "j13-favicon") el.remove();
});
let link = document.getElementById("j13-favicon") as HTMLLinkElement | null;
if (!link) {
link = document.createElement("link");
link.id = "j13-favicon";
link.rel = "icon";
document.head.appendChild(link);
}
if (link.getAttribute("href") !== url) link.href = url;
}
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: [],
};
}
return ctx;
}
/** 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);
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) ? raw.footer_links : prev.footer_links,
};
if (sameBrand(prev, next)) return;
retitle(prev, next);
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>;
}