Files
jiang13-bbs/frontend/lib/bg.ts

166 lines
5.4 KiB
TypeScript
Raw Permalink 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.
// 站点背景图(前台 / 后台分轨):仅接受本站 /uploads/backgrounds/ 下的文件。
// 铺放模式映射到 CSS background-*,真正绘制在 body::before 固定层上,
// 避免 background-attachment:fixed 在移动端掉帧。
import type { CSSProperties } from "react";
export type BgSurface = "site" | "admin";
export type BgMode = "cover" | "contain" | "repeat" | "stretch";
export const DEFAULT_BG_MODE: BgMode = "cover";
/** 预览框按常见 PC 显示器 16:9(1920×1080)等比例缩放 */
export const BG_PREVIEW_ASPECT = "16 / 9";
export const BG_MODE_OPTIONS: { key: BgMode; label: string; hint: string }[] = [
{ key: "cover", label: "填充", hint: "裁切铺满,适合照片" },
{ key: "contain", label: "适应", hint: "整图可见,可能留边" },
{ key: "repeat", label: "平铺", hint: "原尺寸重复,适合纹理" },
{ key: "stretch", label: "拉伸", hint: "拉满屏幕,可能变形" },
];
export interface SiteBgConfig {
bg_site_url: string;
bg_site_mode: BgMode;
bg_admin_url: string;
bg_admin_mode: BgMode;
}
export type SiteBgInput = {
bg_site_url?: string | null;
bg_site_mode?: string | null;
bg_admin_url?: string | null;
bg_admin_mode?: string | null;
};
const BG_URL_RE = /^\/uploads\/backgrounds\/[0-9a-f]{32}\.(jpg|jpeg|png|webp)$/;
const MODE_SET = new Set<BgMode>(BG_MODE_OPTIONS.map((o) => o.key));
const SSR_STYLE_ID = "j13-bg-vars";
const RUNTIME_STYLE_ID = "j13-bg-runtime";
export function normalizeBgURL(raw: string | null | undefined): string {
const u = (raw || "").trim().toLowerCase();
return BG_URL_RE.test(u) ? u : "";
}
export function normalizeBgMode(raw: string | null | undefined): BgMode {
const m = (raw || "").trim().toLowerCase() as BgMode;
return MODE_SET.has(m) ? m : DEFAULT_BG_MODE;
}
/** 媒体库条目是否可作为背景图(JPEG / PNG / WebP) */
export function mediaURLFitsBackground(url: string): boolean {
return /\.(jpe?g|png|webp)(\?|$)/i.test(url);
}
export function bgConfigFromSettings(s: SiteBgInput | null | undefined): SiteBgConfig {
return {
bg_site_url: normalizeBgURL(s?.bg_site_url),
bg_site_mode: normalizeBgMode(s?.bg_site_mode),
bg_admin_url: normalizeBgURL(s?.bg_admin_url),
bg_admin_mode: normalizeBgMode(s?.bg_admin_mode),
};
}
function modeCSS(mode: BgMode): { size: string; repeat: string; position: string } {
switch (mode) {
case "contain":
return { size: "contain", repeat: "no-repeat", position: "center" };
case "repeat":
return { size: "auto", repeat: "repeat", position: "0 0" };
case "stretch":
return { size: "100% 100%", repeat: "no-repeat", position: "center" };
default:
return { size: "cover", repeat: "no-repeat", position: "center" };
}
}
function cssUrl(url: string): string {
// 已由 regex 锁死路径,仍去掉引号,避免意外打断 url("…")
return `url("${url.replace(/["'\\]/g, "")}")`;
}
function surfaceVars(prefix: "site" | "admin", url: string, mode: BgMode): string {
const m = modeCSS(mode);
const image = url ? cssUrl(url) : "none";
return [
`--j13-bg-${prefix}-image:${image};`,
`--j13-bg-${prefix}-size:${m.size};`,
`--j13-bg-${prefix}-repeat:${m.repeat};`,
`--j13-bg-${prefix}-position:${m.position};`,
].join("");
}
/** SSR 内联:仅在至少一侧配置了图时输出 */
export function bgStyleCSS(cfg: SiteBgConfig): string {
if (!cfg.bg_site_url && !cfg.bg_admin_url) return "";
return (
"html{" +
surfaceVars("site", cfg.bg_site_url, cfg.bg_site_mode) +
surfaceVars("admin", cfg.bg_admin_url, cfg.bg_admin_mode) +
"}"
);
}
function ensureRuntimeStyle(): HTMLStyleElement {
let el = document.getElementById(RUNTIME_STYLE_ID) as HTMLStyleElement | null;
if (el) return el;
el = document.createElement("style");
el.id = RUNTIME_STYLE_ID;
el.setAttribute("data-bg-source", "runtime");
document.body.appendChild(el);
return el;
}
function sameConfig(a: SiteBgConfig, b: SiteBgConfig): boolean {
return (
a.bg_site_url === b.bg_site_url &&
a.bg_site_mode === b.bg_site_mode &&
a.bg_admin_url === b.bg_admin_url &&
a.bg_admin_mode === b.bg_admin_mode
);
}
let lastApplied: SiteBgConfig | null = null;
/** 客户端热替换背景;禁止改写 SSR 节点 */
export function applySiteBackground(input: SiteBgInput | null): void {
if (typeof document === "undefined") return;
const cfg = bgConfigFromSettings(input);
if (lastApplied && sameConfig(lastApplied, cfg)) return;
lastApplied = cfg;
const html = document.documentElement;
html.classList.toggle("j13-has-site-bg", !!cfg.bg_site_url);
html.classList.toggle("j13-has-admin-bg", !!cfg.bg_admin_url);
const css = bgStyleCSS(cfg);
if (!css) {
const ssr = document.getElementById(SSR_STYLE_ID);
if (ssr && ssr.textContent && ssr.textContent.trim()) {
ensureRuntimeStyle().textContent =
"html{" +
surfaceVars("site", "", DEFAULT_BG_MODE) +
surfaceVars("admin", "", DEFAULT_BG_MODE) +
"}";
} else {
document.getElementById(RUNTIME_STYLE_ID)?.remove();
}
return;
}
ensureRuntimeStyle().textContent = css;
}
export function previewBoxStyle(url: string, mode: BgMode): CSSProperties {
const m = modeCSS(mode);
return {
backgroundColor: "var(--bg)",
backgroundImage: url ? cssUrl(url) : "none",
backgroundSize: m.size,
backgroundRepeat: m.repeat,
backgroundPosition: m.position,
};
}