| 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); |
| |
| |
| |
| 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 ""; |
| |
| |
| |
| |
| |
| 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 = () => { |
| |
| |
| |
| |
| |
| |
| 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 && <>, </>} |
| </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; |
| } |
|
|
| |
| |
| |
| |
| |
| 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); |
| |
| |
| const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| |
| |
| |
| |
| |
| |
| const lastCommittedRef = useRef(value); |
|
|
| useEffect(() => { |
| setDraft(value); |
| lastCommittedRef.current = value; |
| }, [value]); |
|
|
| useEffect(() => { |
| if (multiline && inputRef.current instanceof HTMLTextAreaElement) { |
| autoResize(inputRef.current); |
| } |
| }, [draft, multiline]); |
|
|
| |
| |
| 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`; |
| }; |
|
|
| |
| |
| |
| const flush = (next: string) => { |
| if (commitTimerRef.current) { |
| clearTimeout(commitTimerRef.current); |
| commitTimerRef.current = null; |
| } |
| if (next !== lastCommittedRef.current) { |
| lastCommittedRef.current = next; |
| onChange(next); |
| } |
| }; |
|
|
| |
| |
| |
| |
| 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") { |
| |
| |
| |
| |
| |
| 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> |
| ); |
| } |
|
|