| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function AuthorsManager({ |
| open, |
| store, |
| authors, |
| affiliations, |
| onClose, |
| }: AuthorsManagerProps) { |
| const dialogRef = useRef<HTMLDialogElement>(null); |
| |
| 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 & 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> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| 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) { |
| |
| 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> |
| ); |
| } |
|
|
| |
| function prettyUrl(url: string): string { |
| return url.replace(/^https?:\/\//, "").replace(/\/$/, ""); |
| } |
|
|