tfrere's picture
tfrere HF Staff
feat(editor): authors & affiliations manager modal
1f88239
Raw
History Blame Contribute Delete
10.5 kB
import { useState, useRef, useEffect } from "react";
import {
ChevronUp,
ChevronDown,
Pencil,
Trash2,
Plus,
ExternalLink,
} from "lucide-react";
import { AuthorInlineForm } from "./AuthorInlineForm";
import type { FrontmatterStore, Author, Affiliation } from "./frontmatter-store";
interface AuthorsManagerProps {
open: boolean;
store: FrontmatterStore;
authors: Author[];
affiliations: Affiliation[];
onClose: () => void;
}
/**
* One-stop modal to manage the article byline: add/edit/remove authors,
* add/edit/remove affiliations, and reorder both lists with explicit
* up/down controls (the discoverable replacement for the invisible inline
* drag). All mutations go through the FrontmatterStore so they stay
* collaborative + undoable; reordering or deleting an affiliation remaps
* every author's 1-based references inside the store.
*/
export function AuthorsManager({
open,
store,
authors,
affiliations,
onClose,
}: AuthorsManagerProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
// `null` = closed, `"new"` = add form, number = edit author at that index.
const [authorForm, setAuthorForm] = useState<number | "new" | null>(null);
const [newAffName, setNewAffName] = useState("");
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
setAuthorForm(null);
setNewAffName("");
dialog.showModal();
} else if (!open && dialog.open) {
dialog.close();
}
}, [open]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const onCloseEvent = () => onClose();
dialog.addEventListener("close", onCloseEvent);
return () => dialog.removeEventListener("close", onCloseEvent);
}, [onClose]);
const multipleAffiliations = affiliations.length > 1;
const submitAuthorForm = (author: Author, newAff?: Affiliation) => {
const finalAuthor = { ...author };
if (newAff) {
const idx = store.addAffiliation(newAff);
finalAuthor.affiliations = [...finalAuthor.affiliations, idx];
}
if (authorForm === "new") {
store.addAuthor(finalAuthor);
} else if (typeof authorForm === "number") {
store.updateAuthor(authorForm, finalAuthor);
}
setAuthorForm(null);
};
const addAffiliation = () => {
const name = newAffName.trim();
if (!name) return;
store.addAffiliation({ name });
setNewAffName("");
};
return (
<dialog ref={dialogRef} className="ed-dialog ed-dialog--manager">
<h3 className="ed-dialog__title">Authors &amp; affiliations</h3>
<div className="ed-dialog__body authors-manager">
{/* ---- Authors ---- */}
<section className="am-section">
<div className="am-section__head">
<span className="am-section__title">Authors</span>
<button
className="btn btn--primary am-add-btn"
onClick={() => setAuthorForm("new")}
>
<Plus size={14} />
<span>Add author</span>
</button>
</div>
{authors.length === 0 ? (
<p className="am-empty">No authors yet.</p>
) : (
<ul className="am-list">
{authors.map((author, i) => (
<li className="am-row" key={`am-author-${i}`}>
<div className="am-row__reorder">
<button
className="icon-btn icon-btn--sm"
aria-label="Move up"
disabled={i === 0}
onClick={() => store.moveAuthor(i, i - 1)}
>
<ChevronUp size={14} />
</button>
<button
className="icon-btn icon-btn--sm"
aria-label="Move down"
disabled={i === authors.length - 1}
onClick={() => store.moveAuthor(i, i + 1)}
>
<ChevronDown size={14} />
</button>
</div>
<div className="am-row__main">
<span className="am-row__name">
{author.name || <em className="am-muted">Unnamed</em>}
{multipleAffiliations && author.affiliations?.length > 0 && (
<sup className="am-row__affs">
{[...author.affiliations].sort((a, b) => a - b).join(",")}
</sup>
)}
</span>
{author.url && (
<a
className="am-row__url"
href={author.url}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink size={11} />
<span>{prettyUrl(author.url)}</span>
</a>
)}
</div>
<div className="am-row__actions">
<button
className="icon-btn icon-btn--sm"
aria-label="Edit author"
onClick={() => setAuthorForm(i)}
>
<Pencil size={14} />
</button>
<button
className="icon-btn icon-btn--sm am-row__danger"
aria-label="Remove author"
onClick={() => store.removeAuthor(i)}
>
<Trash2 size={14} />
</button>
</div>
</li>
))}
</ul>
)}
</section>
{/* ---- Affiliations ---- */}
<section className="am-section">
<div className="am-section__head">
<span className="am-section__title">Affiliations</span>
</div>
{affiliations.length === 0 ? (
<p className="am-empty">
No affiliations yet. Add one below, then attach it to authors.
</p>
) : (
<ol className="am-list am-list--aff">
{affiliations.map((aff, i) => (
<AffiliationRow
key={`am-aff-${i}`}
index={i}
total={affiliations.length}
affiliation={aff}
store={store}
/>
))}
</ol>
)}
<div className="am-add-row">
<input
className="form-input"
placeholder="New affiliation, e.g. Hugging Face"
value={newAffName}
onChange={(e) => setNewAffName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addAffiliation()}
/>
<button
className="btn"
onClick={addAffiliation}
disabled={!newAffName.trim()}
>
<Plus size={14} />
<span>Add</span>
</button>
</div>
</section>
</div>
<div className="ed-dialog__actions">
<button className="btn btn--primary" onClick={() => dialogRef.current?.close()}>
Done
</button>
</div>
{/* Author add/edit form, stacked on top of this dialog. */}
<AuthorInlineForm
open={authorForm !== null}
initial={typeof authorForm === "number" ? authors[authorForm] : undefined}
affiliations={affiliations}
onSubmit={submitAuthorForm}
onCancel={() => setAuthorForm(null)}
/>
</dialog>
);
}
/**
* Single affiliation row with inline name + URL editing. Keeps a local draft
* so typing doesn't round-trip through Yjs on every keystroke; commits on
* blur / Enter only when the value actually changed.
*/
function AffiliationRow({
index,
total,
affiliation,
store,
}: {
index: number;
total: number;
affiliation: Affiliation;
store: FrontmatterStore;
}) {
const [name, setName] = useState(affiliation.name);
const [url, setUrl] = useState(affiliation.url || "");
useEffect(() => setName(affiliation.name), [affiliation.name]);
useEffect(() => setUrl(affiliation.url || ""), [affiliation.url]);
const commit = () => {
const nextName = name.trim();
const nextUrl = url.trim();
if (nextName === affiliation.name && nextUrl === (affiliation.url || "")) return;
if (!nextName) {
// Don't allow blanking the name: revert to the stored value.
setName(affiliation.name);
return;
}
store.updateAffiliation(index, {
name: nextName,
url: nextUrl || undefined,
});
};
return (
<li className="am-row am-row--aff">
<span className="am-row__index">{index + 1}.</span>
<div className="am-row__reorder">
<button
className="icon-btn icon-btn--sm"
aria-label="Move up"
disabled={index === 0}
onClick={() => store.moveAffiliation(index, index - 1)}
>
<ChevronUp size={14} />
</button>
<button
className="icon-btn icon-btn--sm"
aria-label="Move down"
disabled={index === total - 1}
onClick={() => store.moveAffiliation(index, index + 1)}
>
<ChevronDown size={14} />
</button>
</div>
<div className="am-row__fields">
<input
className="form-input"
placeholder="Affiliation name"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
/>
<input
className="form-input"
placeholder="URL (optional)"
value={url}
onChange={(e) => setUrl(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
/>
</div>
<button
className="icon-btn icon-btn--sm am-row__danger"
aria-label="Remove affiliation"
onClick={() => store.removeAffiliation(index)}
>
<Trash2 size={14} />
</button>
</li>
);
}
/** Strip protocol + trailing slash for a compact URL label. */
function prettyUrl(url: string): string {
return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
}