"use client"; import { Check, ImagePlus, Images, Link2, X } from "lucide-react"; import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; import { AdminSegmented, AdminSettingsActions, AdminSettingsGroup, } from "@/components/admin"; import BrandLockup from "@/components/BrandLockup"; import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api"; import { BRAND_LOGO_FITS, BRAND_LOGO_SIZES, brandAccept, brandAliasPath, brandFileOk, type BrandLogoFit, type BrandLogoSize, type BrandMark, type BrandSlot, } from "@/lib/brand"; import { toast } from "@/lib/toast"; import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage"; import BrandMediaPicker from "./BrandMediaPicker"; type SlotDraft = { committed: string; file: File | null; blob: string; mediaId: number | null; mediaUrl: string; }; function emptyDraft(committed: string): SlotDraft { return { committed, file: null, blob: "", mediaId: null, mediaUrl: "" }; } function previewOf(d: SlotDraft): string { return d.blob || d.mediaUrl || d.committed; } function slotDirty(d: SlotDraft, saved: string): boolean { if (d.file || d.mediaId != null) return true; return d.committed !== saved; } const SLOTS: { id: BrandSlot; label: string; swatch: "light" | "dark" | "neutral"; compact?: boolean }[] = [ { id: "logo_light", label: "浅色 Logo", swatch: "light" }, { id: "logo_dark", label: "深色 Logo", swatch: "dark" }, { id: "favicon", label: "Favicon", swatch: "neutral", compact: true }, ]; export default function BrandSeoPanel({ initial, onSaved, onDirtyChange, }: { initial: PublicSettings; onSaved?: (next: PublicSettings) => void; onDirtyChange?: (dirty: boolean) => void; }) { const [light, setLight] = useState(() => emptyDraft(initial.logo_light_url)); const [dark, setDark] = useState(() => emptyDraft(initial.logo_dark_url)); const [favicon, setFavicon] = useState(() => emptyDraft(initial.favicon_url)); const [saved, setSaved] = useState({ light: initial.logo_light_url, dark: initial.logo_dark_url, favicon: initial.favicon_url, mark: initial.brand_mark, size: initial.brand_logo_size, fit: initial.brand_logo_fit, }); const [mark, setMark] = useState(initial.brand_mark); const [logoSize, setLogoSize] = useState(initial.brand_logo_size); const [logoFit, setLogoFit] = useState(initial.brand_logo_fit); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(""); const [picker, setPicker] = useState(null); const [copiedSlot, setCopiedSlot] = useState(null); const blobs = useRef([]); const saveGen = useRef(0); useEffect(() => { const held = blobs; return () => { held.current.forEach((u) => URL.revokeObjectURL(u)); }; }, []); const drafts: Record>]> = { logo_light: [light, setLight], logo_dark: [dark, setDark], favicon: [favicon, setFavicon], }; const replaceFile = (slot: BrandSlot, file: File | null) => { setSaveError(""); const [, set] = drafts[slot]; set((prev) => { if (prev.blob) URL.revokeObjectURL(prev.blob); const blob = file ? URL.createObjectURL(file) : ""; if (blob) blobs.current.push(blob); return { ...prev, file, blob, mediaId: null, mediaUrl: "" }; }); }; const dirty = slotDirty(light, saved.light) || slotDirty(dark, saved.dark) || slotDirty(favicon, saved.favicon) || mark !== saved.mark || logoSize !== saved.size || logoFit !== saved.fit; useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]); const resolveSlot = async (slot: BrandSlot, draft: SlotDraft): Promise => { if (draft.file) return apiUploadBrand(slot, draft.file); if (draft.mediaId != null) return apiBrandFromMedia(slot, draft.mediaId); return draft.committed; }; const discard = () => { [light, dark, favicon].forEach((d) => { if (d.blob) URL.revokeObjectURL(d.blob); }); setLight(emptyDraft(saved.light)); setDark(emptyDraft(saved.dark)); setFavicon(emptyDraft(saved.favicon)); setMark(saved.mark); setLogoSize(saved.size); setLogoFit(saved.fit); setSaveError(""); }; const save = async () => { if (saving || !dirty) return; const ticket = ++saveGen.current; const snap = { mark, size: logoSize, fit: logoFit }; setSaving(true); setSaveError(""); try { const logoLight = await resolveSlot("logo_light", light); const logoDark = await resolveSlot("logo_dark", dark); const fav = await resolveSlot("favicon", favicon); const res = await apiUpdateSiteSettings({ logo_light_url: logoLight, logo_dark_url: logoDark, favicon_url: fav, brand_mark: mark, brand_logo_size: logoSize, brand_logo_fit: logoFit, }); if (ticket !== saveGen.current) return; setSaved({ light: res.logo_light_url, dark: res.logo_dark_url, favicon: res.favicon_url, mark: res.brand_mark, size: res.brand_logo_size, fit: res.brand_logo_fit, }); setMark((v) => (v === snap.mark ? res.brand_mark : v)); setLogoSize((v) => (v === snap.size ? res.brand_logo_size : v)); setLogoFit((v) => (v === snap.fit ? res.brand_logo_fit : v)); setLight((cur) => { if (cur.file === light.file && cur.mediaId === light.mediaId && cur.committed === light.committed) { if (cur.blob) URL.revokeObjectURL(cur.blob); return emptyDraft(res.logo_light_url); } return cur; }); setDark((cur) => { if (cur.file === dark.file && cur.mediaId === dark.mediaId && cur.committed === dark.committed) { if (cur.blob) URL.revokeObjectURL(cur.blob); return emptyDraft(res.logo_dark_url); } return cur; }); setFavicon((cur) => { if ( cur.file === favicon.file && cur.mediaId === favicon.mediaId && cur.committed === favicon.committed ) { if (cur.blob) URL.revokeObjectURL(cur.blob); return emptyDraft(res.favicon_url); } return cur; }); onSaved?.(res); toast("已保存", "ok"); } catch (e) { if (ticket !== saveGen.current) return; const msg = e instanceof Error ? e.message : "保存失败"; setSaveError(msg); toast(msg); } finally { if (ticket === saveGen.current) setSaving(false); } }; const slotDraft = (id: BrandSlot) => id === "logo_dark" ? dark : id === "favicon" ? favicon : light; // 已保存的槽位 URL:存在才展示固定地址(未保存过无图可引用) const savedOf = (slot: BrandSlot) => slot === "logo_dark" ? saved.dark : slot === "favicon" ? saved.favicon : saved.light; const copyAlias = async (slot: BrandSlot) => { try { await navigator.clipboard.writeText(`${window.location.origin}${brandAliasPath(slot)}`); setCopiedSlot(slot); toast("已复制固定地址", "ok"); window.setTimeout(() => setCopiedSlot((cur) => (cur === slot ? null : cur)), 1500); } catch { toast("复制失败,请手动复制"); } }; const renderSlot = (slot: BrandSlot, label: string, swatch: "light" | "dark" | "neutral", compact?: boolean) => { const draft = slotDraft(slot); const preview = previewOf(draft); const swatchClass = swatch === "light" ? "admin-brand-swatch-light" : swatch === "dark" ? "admin-brand-swatch-dark" : "admin-brand-swatch-neutral"; return (
{preview ? ( // 预览槽(≤56px 高,宽标可能更宽):本站图走 160/384 密度变体 // eslint-disable-next-line @next/next/no-img-element ) : ( 未设置 )}
{label} {savedOf(slot) ? ( ) : null}
); }; return ( void save()} onDiscard={discard} /> } >
形式 { setMark(v); if (saveError) setSaveError(""); }} options={[ { key: "image_text", label: "图加文字" }, { key: "image", label: "纯图" }, { key: "text", label: "纯文字" }, ]} />
{mark !== "text" ? ( <>
比例
{BRAND_LOGO_SIZES.map((item) => { const on = logoSize === item.id; return ( ); })}
铺法 { setLogoFit(v); if (saveError) setSaveError(""); }} options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))} />
) : null}
{SLOTS.map((s) => renderSlot(s.id, s.label, s.swatch, s.compact))}
setPicker(null)} onPick={(item) => { if (!picker) return; const [prev, set] = drafts[picker]; if (prev.blob) URL.revokeObjectURL(prev.blob); set({ ...prev, file: null, blob: "", mediaId: item.id, mediaUrl: item.url }); }} />
); }