新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
198 lines
7.3 KiB
TypeScript
198 lines
7.3 KiB
TypeScript
"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>;
|
||
}
|