tfrere's picture
tfrere HF Staff
feat(editor): authors & affiliations manager modal
1f88239
Raw
History Blame Contribute Delete
16 kB
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 = `<div class="d3-chart" style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:var(--muted-color);font-family:system-ui;">
<p style="margin:0;font-size:14px;">Generating chart...</p>
</div>`;
function makeImageBannerHtml(url: string) {
return `<div style="width:100%;height:100%;overflow:hidden;">
<img src="${url}" alt="Banner" style="width:100%;height:100%;object-fit:cover;display:block;" />
</div>`;
}
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<HTMLInputElement>(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 <img>) 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 <div className="hero" />;
}
const hasAuthors = data.authors.length > 0;
const hasAffiliations = data.affiliations.length > 0;
const multipleAffiliations = data.affiliations.length > 1;
return (
<div>
<section className="hero">
<EditableText
value={data.title}
placeholder="Article title"
onChange={(v) => update("title", v)}
className="hero-title"
multiline
/>
{bannerSrcdoc ? (
<div className="hero-banner hero-banner--filled">
<iframe
srcDoc={bannerSrcdoc}
title="Banner"
sandbox="allow-scripts allow-same-origin"
style={{ width: "100%", height: "100%", border: "none", display: "block", background: "transparent" }}
/>
{embedStore && (
<div className="hero-banner-actions">
<Tooltip title="Edit banner in Embed Studio">
<button
type="button"
className="hero-banner-action"
onClick={handleEditBanner}
aria-label="Edit banner"
>
<PencilLine size={14} />
</button>
</Tooltip>
<Tooltip title="Remove banner">
<button
type="button"
className="hero-banner-action hero-banner-action--danger"
onClick={handleRemoveBanner}
aria-label="Remove banner"
>
<Trash2 size={14} />
</button>
</Tooltip>
</div>
)}
</div>
) : embedStore ? (
<div className="hero-banner hero-banner--empty" aria-label="Banner placeholder">
<input
ref={bannerFileRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleBannerUpload(file);
e.target.value = "";
}}
/>
<div className="hero-banner-empty__inner">
<p className="hero-banner-empty__hint">
Add a banner to set the mood of your article.
</p>
<div className="hero-banner-empty__actions">
<button
type="button"
className="btn"
disabled={uploadingBanner}
onClick={() => bannerFileRef.current?.click()}
>
<ImagePlus size={16} />
<span>{uploadingBanner ? "Uploading..." : "Upload image"}</span>
</button>
<button
type="button"
className="btn"
onClick={handleCreateChart}
>
<BarChart3 size={16} />
<span>Create chart</span>
</button>
</div>
</div>
</div>
) : null}
<EditableText
value={data.subtitle}
placeholder="Subtitle (optional)"
onChange={(v) => update("subtitle", v)}
className="hero-desc"
multiline
/>
</section>
<header className="meta" aria-label="Article meta information">
<div className="meta-container">
<div className="meta-container-cell">
<h3>Authors</h3>
{hasAuthors ? (
<ul className="authors">
{data.authors.map((author, i) => (
<li key={`author-${i}`}>
<span
className="author-editable"
onClick={openManager}
title="Manage authors & affiliations"
>
{author.url ? (
<a href={author.url} target="_blank" rel="noopener noreferrer">
{author.name}
</a>
) : (
author.name
)}
{multipleAffiliations && author.affiliations?.length > 0 && (
<sup>{author.affiliations.join(",")}</sup>
)}
</span>
{i < data.authors.length - 1 && <>,&nbsp;</>}
</li>
))}
<li className="author-add-btn">
<Tooltip title="Manage authors & affiliations">
<button
className="icon-btn"
onClick={openManager}
aria-label="Manage authors and affiliations"
style={{ padding: 2 }}
>
<Pencil size={13} />
</button>
</Tooltip>
</li>
</ul>
) : (
<button className="meta-placeholder-btn" onClick={openManager}>
<Plus size={14} />
<span>Add author</span>
</button>
)}
</div>
{hasAffiliations && (
<div className="meta-container-cell">
<h3>Affiliations</h3>
{multipleAffiliations ? (
<ol
className="affiliations affiliations--clickable"
onClick={openManager}
title="Manage authors & affiliations"
>
{data.affiliations.map((aff, i) => (
<li key={`aff-${i}`}>
{aff.url ? (
<a href={aff.url} target="_blank" rel="noopener noreferrer">
{aff.name}
</a>
) : (
aff.name
)}
</li>
))}
</ol>
) : (
<p
className="affiliations--clickable"
onClick={openManager}
title="Manage authors & affiliations"
>
{data.affiliations[0].url ? (
<a href={data.affiliations[0].url} target="_blank" rel="noopener noreferrer">
{data.affiliations[0].name}
</a>
) : (
data.affiliations[0].name
)}
</p>
)}
</div>
)}
<div className="meta-container-cell">
<h3>Published</h3>
<EditableText
value={data.published}
placeholder="Date"
onChange={(v) => update("published", v)}
inline
/>
</div>
</div>
</header>
{store && (
<AuthorsManager
open={showManager}
store={store}
authors={data.authors}
affiliations={data.affiliations}
onClose={() => setShowManager(false)}
/>
)}
</div>
);
}
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<HTMLInputElement | HTMLTextAreaElement>(null);
// Debounce timer for live-commits while typing. Cleared on every
// keystroke and on blur (which fires `flush` to commit immediately).
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement | HTMLTextAreaElement>,
) => {
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 (
<span
className={`editable-text ${className || ""}`}
style={{ display: inline ? "inline" : "block" }}
>
<Tag
ref={inputRef as any}
className={inputCls}
value={draft}
onChange={handleChange}
onBlur={() => flush(draft)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={multiline ? 1 : undefined}
/>
</span>
);
}