// components-placeholders.jsx — Image placeholder ("PicSlot") used across the page. // Renders only when the path is in window.UPLOADED_IMAGES // (so missing files don't pollute the console). Add a filename to the set // when the user uploads it. // ─── Manifest of uploaded images. Populate as the user uploads. ──────── window.UPLOADED_IMAGES = window.UPLOADED_IMAGES || new Set([ "uploads/fenwei1.png", "uploads/fenwei2.png", "uploads/hero.png", "uploads/seal-bi.png", "uploads/bg-rainy-guest.png", // Screen placeholders — drop real screenshots in and uncomment. "uploads/scr-hero.webp", "uploads/scr-overview.webp", // "uploads/scr-world.webp", "uploads/scr-characters.webp", // "uploads/scr-objects.webp", // "uploads/scr-events.webp", // "uploads/scr-writing.webp", ]); function isUploaded(src) { return !!src && window.UPLOADED_IMAGES && window.UPLOADED_IMAGES.has(src); } function PicSlot({ src, alt = "", label, // human label, e.g. "Hero · 主视觉" hint, // one-line description of what goes here aspect = "16/10", // CSS aspect-ratio string variant = "product", // "product" | "mood" | "portrait" | "map" | "desk" className = "", style = {}, }) { const [errored, setErrored] = React.useState(false); const [loaded, setLoaded] = React.useState(false); const showImg = isUploaded(src) && !errored; return (
{showImg && ( {alt setLoaded(true)} onError={() => setErrored(true)} /> )} {(!showImg || !loaded) && (
{variantLabel(variant)}
{label &&
{label}
} {hint &&
{hint}
} {src && (
{src}
)}
)}
); } function variantLabel(v) { return { product: "PRODUCT SHOT", mood: "MOOD IMAGE", portrait: "INK PORTRAIT", map: "MAP / ATLAS", desk: "FULL DESK SHOT", }[v] || "IMAGE"; } /* ──────────────────────────────────────────────────────────── SceneDivider — full-bleed cinematic banner between sections. Used to break the page rhythm with an atmospheric image and a short literal excerpt from the demo book. ──────────────────────────────────────────────────────────── */ function SceneDivider({ src, alt = "", chapter, // small uppercase label (e.g. "雨中客 · 卷一 · 第 1 章") quote, // one short line, taken literally from the prose side = "left", // caption alignment: "left" | "right" height = "62vh", // banner height; clamped in CSS }) { const exists = isUploaded(src); return (
{exists ? {alt} :
MOOD BANNER
{src}
}
{chapter &&
{chapter}
} {quote &&
{quote}
}
); } /* ──────────────────────────────────────────────────────────── Divider — a small set of decorative dividers, one per variant. Place between sections, never two in a row. ──────────────────────────────────────────────────────────── */ function Divider({ variant = "brush", label, lang = "zh" }) { return (
{variant === "brush" && } {variant === "dots" && } {variant === "seal" && } {variant === "chapter" && } {variant === "wave" && }
); } // A · 水墨笔触 function DividerBrush() { return ( ); } // B · 三点 function DividerDots() { return (
); } // C · 印章 — uses the real seal PNG (transparent BG) function DividerSeal({ lang }) { return (
{lang
); } // D · 章节标记 function DividerChapter({ label }) { return (
· {label || "笔随 · Novel Studio"} ·
); } // E (bonus) · 古书云纹波浪 function DividerWave() { return ( ); } /* ──────────────────────────────────────────────────────────── Zoomable — click an image to open a fullscreen lightbox. Click backdrop (or press Esc) to dismiss. Locks body scroll and signals fullpage-scroll.js to pause its wheel hijack via the `lightbox-open` class on . Reusable for all screen screenshots as they replace mocks. ──────────────────────────────────────────────────────────── */ function Zoomable({ src, alt = "", className = "", imgProps = {} }) { const [open, setOpen] = React.useState(false); React.useEffect(() => { if (!open) return; const onKey = (e) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("keydown", onKey); // Class signals to fullpage-scroll.js + locks underlying scroll. document.body.classList.add("lightbox-open"); const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onKey); document.body.classList.remove("lightbox-open"); document.body.style.overflow = prevOverflow; }; }, [open]); return ( {alt} setOpen(true)} {...imgProps} /> {open && (
setOpen(false)} > {alt} e.stopPropagation()} />
)}
); } Object.assign(window, { PicSlot, SceneDivider, Divider, Zoomable });