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

188 lines
6.4 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.
/** 站点品牌与页脚:公开设置字段的前端归一化(与 Go service/brand.go 对齐) */
export type FooterLink = {
label: string;
url: string;
new_tab: boolean;
/** 所属栏位:left / center / right;缺省 right */
align: FooterLinksAlign;
};
/** 页脚链接栏位 */
export type FooterLinksAlign = "left" | "center" | "right";
export const FOOTER_LINKS_ALIGNS: { id: FooterLinksAlign; label: string }[] = [
{ id: "left", label: "左" },
{ id: "center", label: "中" },
{ id: "right", label: "右" },
];
export function normalizeFooterLinksAlign(raw: unknown): FooterLinksAlign {
const s = String(raw ?? "").trim();
if (s === "left" || s === "center" || s === "right") return s;
return "right";
}
export type BrandSlot = "logo_light" | "logo_dark" | "favicon";
/** 页眉品牌形式 */
export type BrandMark = "image_text" | "image" | "text";
/** 图槽画幅。方标是默认;其余是页眉里常见的横版比例 */
export type BrandLogoSize = "sq" | "2x1" | "3x1" | "4x1" | "16x9";
export const BRAND_LOGO_SIZES: { id: BrandLogoSize; label: string; w: number; h: number }[] = [
{ id: "sq", label: "方标", w: 36, h: 36 },
{ id: "2x1", label: "2:1", w: 80, h: 40 },
{ id: "3x1", label: "3:1", w: 120, h: 40 },
{ id: "4x1", label: "4:1", w: 176, h: 44 },
{ id: "16x9", label: "16:9", w: 96, h: 54 },
];
export function normalizeBrandMark(raw: unknown): BrandMark {
const s = String(raw ?? "").trim();
if (s === "image" || s === "text" || s === "image_text") return s;
return "image_text";
}
export function normalizeBrandLogoSize(raw: unknown): BrandLogoSize {
const s = String(raw ?? "").trim();
if (s === "sq" || s === "2x1" || s === "3x1" || s === "4x1" || s === "16x9") return s;
return "sq";
}
export function brandLogoBox(size: BrandLogoSize): { w: number; h: number } {
const found = BRAND_LOGO_SIZES.find((item) => item.id === size);
return found ? { w: found.w, h: found.h } : { w: 36, h: 36 };
}
/** 图槽铺法,与背景图同一套:填充裁切、适应留边、平铺重复、拉伸变形 */
export type BrandLogoFit = "cover" | "contain" | "repeat" | "stretch";
export const BRAND_LOGO_FITS: { id: BrandLogoFit; label: string }[] = [
{ id: "cover", label: "填充" },
{ id: "contain", label: "适应" },
{ id: "repeat", label: "平铺" },
{ id: "stretch", label: "拉伸" },
];
export function normalizeBrandLogoFit(raw: unknown): BrandLogoFit {
const s = String(raw ?? "").trim();
if (s === "cover" || s === "contain" || s === "repeat" || s === "stretch") return s;
return "contain";
}
export const BRAND_LIMITS = {
wordmark: 16,
slogan: 32,
keywordCount: 16,
keywordRunes: 20,
footerLinks: 8,
footerLabel: 32,
footerURL: 200,
logoBytes: 2 * 1024 * 1024,
faviconBytes: 512 * 1024,
} as const;
const LOGO_URL_RE = /^\/uploads\/brand\/[0-9a-f]{32}\.(png|webp|svg)$/;
const FAVICON_URL_RE = /^\/uploads\/brand\/[0-9a-f]{32}\.(png|webp|svg|ico)$/;
export function runeCount(s: string): number {
return [...s].length;
}
/** 浏览器默认标题:有标语则为「名称 · 标语」,否则只有名称 */
export function siteDocumentTitle(name: string, slogan: string): string {
const n = name.trim() || "姜十三论坛";
const s = slogan.trim();
return s ? `${n} · ${s}` : n;
}
export function normalizeBrandURL(slot: BrandSlot, raw: unknown): string {
const u = String(raw ?? "")
.trim()
.toLowerCase();
if (!u) return "";
const re = slot === "favicon" ? FAVICON_URL_RE : LOGO_URL_RE;
return re.test(u) ? u : "";
}
export function normalizeKeywords(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const item of raw) {
const parts = String(item ?? "").split(/[,,;;]/);
for (const part of parts) {
const k = part.trim();
if (!k || runeCount(k) > BRAND_LIMITS.keywordRunes) continue;
const key = k.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(k);
if (out.length >= BRAND_LIMITS.keywordCount) return out;
}
}
return out;
}
export function isExternalFooterURL(url: string): boolean {
return /^https?:\/\//i.test(url.trim());
}
/** 页脚地址:站内单斜杠路径,或 http(s) */
export function footerURLAllowed(raw: string): boolean {
const u = raw.trim();
if (!u || runeCount(u) > BRAND_LIMITS.footerURL) return false;
if (/\s/.test(u) || u.includes("\\")) return false;
if (/^(javascript|data):/i.test(u) || u.startsWith("//")) return false;
if (isExternalFooterURL(u)) return true;
return u.startsWith("/") && !u.startsWith("//");
}
export function normalizeFooterLinks(raw: unknown): FooterLink[] {
if (!Array.isArray(raw)) return [];
const out: FooterLink[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") continue;
const rec = item as { label?: unknown; url?: unknown; new_tab?: unknown };
const label = String(rec.label ?? "").trim();
const url = String(rec.url ?? "").trim();
if (!label || runeCount(label) > BRAND_LIMITS.footerLabel) continue;
if (!footerURLAllowed(url)) continue;
const omitted = rec.new_tab === undefined || rec.new_tab === null;
const newTab = omitted ? isExternalFooterURL(url) : rec.new_tab !== false;
const align = normalizeFooterLinksAlign((rec as { align?: unknown }).align);
out.push({ label, url, new_tab: newTab, align });
if (out.length >= BRAND_LIMITS.footerLinks) break;
}
return out;
}
export function brandAccept(slot: BrandSlot): string {
if (slot === "favicon") return "image/png,image/webp,image/svg+xml,.ico,image/x-icon";
return "image/png,image/webp,image/svg+xml";
}
export function brandFileOk(slot: BrandSlot, file: File): string | null {
const name = file.name.toLowerCase();
const logo = slot !== "favicon";
const okExt = logo
? /\.(png|webp|svg)$/.test(name)
: /\.(png|webp|svg|ico)$/.test(name);
if (!okExt) {
return logo ? "Logo 仅支持 PNG / WebP / SVG" : "Favicon 仅支持 PNG / WebP / SVG / ICO";
}
const max = logo ? BRAND_LIMITS.logoBytes : BRAND_LIMITS.faviconBytes;
if (file.size > max) {
return logo ? "Logo 不能超过 2MB" : "Favicon 不能超过 512KB";
}
return null;
}
export function mediaURLFitsSlot(slot: BrandSlot, url: string): boolean {
const u = url.toLowerCase();
if (slot === "favicon") return /\.(png|webp|svg|ico)(\?|$)/.test(u);
return /\.(png|webp|svg)(\?|$)/.test(u);
}