import { useState, useRef, useEffect, useMemo, type KeyboardEvent } from "react"; import { Tooltip } from "../../components/Tooltip"; import { Plus, ImagePlus, BarChart3, Trash2, PencilLine, Pencil } from "lucide-react"; import { useFrontmatter } from "./useFrontmatter"; import { buildDoc } from "../embeds/build-doc"; import { useTheme } from "../../hooks/useTheme"; import { uploadImage } from "../upload"; import { AuthorsManager } from "./AuthorsManager"; import type { FrontmatterStore } from "./frontmatter-store"; import type { EmbedStore } from "../embeds/embed-store"; const BANNER_KEY = "banner.html"; const CHART_PLACEHOLDER_HTML = `

Generating chart...

`; function makeImageBannerHtml(url: string) { return `
Banner
`; } interface FrontmatterHeroProps { store: FrontmatterStore | null; embedStore?: EmbedStore | null; } export function FrontmatterHero({ store, embedStore }: FrontmatterHeroProps) { const { data, update } = useFrontmatter(store); // Single entry point for all byline editing (add/edit/remove/reorder of // authors and affiliations). Reordering used to be an undiscoverable inline // drag; it now lives in this modal behind an explicit affordance. const [showManager, setShowManager] = useState(false); const openManager = () => { if (store) setShowManager(true); }; const bannerSrc = data?.banner || ""; const [bannerHtml, setBannerHtml] = useState(""); const [uploadingBanner, setUploadingBanner] = useState(false); const bannerFileRef = useRef(null); useEffect(() => { if (!embedStore || !bannerSrc) { setBannerHtml(""); return; } setBannerHtml(embedStore.get(bannerSrc)); return embedStore.observeKey(bannerSrc, setBannerHtml); }, [embedStore, bannerSrc]); const { isDark, primaryColor } = useTheme(); const bannerSrcdoc = useMemo(() => { if (!bannerHtml) return ""; // Banner iframes are size-constrained by the parent (5:2 aspect ratio), // so we render them edge-to-edge: no body padding, full height chain. // primaryColor must be forwarded so banners using `--primary-color` // pick up the article's accent (otherwise they fall back to the // generic #4e79a7 from buildEmbedSrcdoc). return buildDoc(bannerHtml, { isDark, primaryColor, fullBleed: true }); }, [bannerHtml, isDark, primaryColor]); const handleBannerUpload = async (file: File) => { if (!embedStore || !file.type.startsWith("image/")) return; setUploadingBanner(true); try { const url = await uploadImage(file); embedStore.set(BANNER_KEY, makeImageBannerHtml(url)); update("banner", BANNER_KEY); } catch (err) { console.error("[banner-upload]", err); } finally { setUploadingBanner(false); } }; const handleCreateChart = () => { if (!embedStore) return; embedStore.set(BANNER_KEY, CHART_PLACEHOLDER_HTML); update("banner", BANNER_KEY); window.dispatchEvent( new CustomEvent("open-embed-studio", { detail: { src: BANNER_KEY } }), ); }; const handleRemoveBanner = () => { if (!embedStore) return; embedStore.remove(bannerSrc || BANNER_KEY); update("banner", ""); }; const handleEditBanner = () => { // The banner content lives in the shared embed store under `bannerSrc`, // so the Embed Studio handles it exactly like any other htmlEmbed: // chat with the AI, iterate on the HTML, see live preview. Works // whether the banner was originally an image upload (just ) or // a chart placeholder - the studio doesn't care, it edits whatever // HTML lives at this key. if (!embedStore) return; window.dispatchEvent( new CustomEvent("open-embed-studio", { detail: { src: bannerSrc || BANNER_KEY }, }), ); }; if (!data) { return
; } const hasAuthors = data.authors.length > 0; const hasAffiliations = data.affiliations.length > 0; const multipleAffiliations = data.affiliations.length > 1; return (
update("title", v)} className="hero-title" multiline /> {bannerSrcdoc ? (