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 `
`;
}
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 (
);
}
interface EditableTextProps {
value: string;
placeholder: string;
onChange: (value: string) => void;
className?: string;
multiline?: boolean;
inline?: boolean;
}
// Debounce window for live-commits. Long enough that we don't spam
// Yjs with one update per keystroke when the user is typing fast,
// short enough that the "Saving..." indicator kicks in within the
// same beat as the editing action (so it actually feels like the
// app is reacting to what we're doing).
const COMMIT_DEBOUNCE_MS = 300;
function EditableText({ value, placeholder, onChange, className, multiline, inline }: EditableTextProps) {
const [draft, setDraft] = useState(value);
const inputRef = useRef(null);
// Debounce timer for live-commits while typing. Cleared on every
// keystroke and on blur (which fires `flush` to commit immediately).
const commitTimerRef = useRef | null>(null);
// Last value we propagated to Yjs. We compare against this rather
// than `value`, because `value` updates only after Yjs round-trips
// back and there's a brief window where we've committed locally
// but the prop hasn't refreshed yet - re-committing in that window
// would dispatch a no-op Yjs update and risk an event loop with
// the parent's `useFrontmatter` hook.
const lastCommittedRef = useRef(value);
useEffect(() => {
setDraft(value);
lastCommittedRef.current = value;
}, [value]);
useEffect(() => {
if (multiline && inputRef.current instanceof HTMLTextAreaElement) {
autoResize(inputRef.current);
}
}, [draft, multiline]);
// Flush pending commit timer on unmount so an in-flight edit
// isn't lost when the hero re-mounts (theme switch, etc.).
useEffect(() => {
return () => {
if (commitTimerRef.current) {
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
};
}, []);
const autoResize = (el: HTMLTextAreaElement) => {
el.style.height = "0";
el.style.height = `${el.scrollHeight}px`;
};
// Commit immediately and cancel any pending debounce. Used by
// onBlur and by Enter (single-line inputs) so leaving the field
// never feels laggy.
const flush = (next: string) => {
if (commitTimerRef.current) {
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
if (next !== lastCommittedRef.current) {
lastCommittedRef.current = next;
onChange(next);
}
};
// Schedule a debounced commit. Called on every keystroke so the
// doc updates while the user is still typing (and the sync
// indicator flips to "Saving..." in real time), without flooding
// Yjs with one update per keypress.
const scheduleCommit = (next: string) => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
commitTimerRef.current = setTimeout(() => {
commitTimerRef.current = null;
if (next !== lastCommittedRef.current) {
lastCommittedRef.current = next;
onChange(next);
}
}, COMMIT_DEBOUNCE_MS);
};
const handleChange = (
e: React.ChangeEvent,
) => {
const next = e.target.value;
setDraft(next);
scheduleCommit(next);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !multiline) {
e.preventDefault();
(e.target as HTMLElement).blur();
}
if (e.key === "Escape") {
// Cancel any pending commit and snap back to the last
// server-confirmed value. Anything we already debounce-
// committed before pressing Escape is gone from this draft
// but still persisted - Yjs undo (Ctrl+Z) is the way to
// truly roll back. Same behaviour as a Notion/Linear field.
if (commitTimerRef.current) {
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
setDraft(value);
lastCommittedRef.current = value;
(e.target as HTMLElement).blur();
}
};
const Tag = multiline ? "textarea" : "input";
const inputCls = inline
? "editable-text-input editable-text-input--inline"
: "editable-text-input";
return (
flush(draft)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={multiline ? 1 : undefined}
/>
);
}