tfrere HF Staff Cursor commited on
Commit
1f88239
·
1 Parent(s): 09a820f

feat(editor): authors & affiliations manager modal

Browse files

Replace the undiscoverable inline drag with a single, discoverable modal for
the whole byline. From the hero (pencil button, clicking an author, or
clicking an affiliation) users open AuthorsManager to add/edit/remove authors
and affiliations and reorder both lists via explicit up/down controls.

- store: add updateAffiliation/removeAffiliation; removeAffiliation reconciles
every author's 1-based references (drop + shift) like moveAffiliation does
- extract AuthorInlineForm into its own file, reused as the add/edit form
(stacked dialog) inside the manager
- affiliations get inline name/URL editing with debounced commits
- drop the inline drag-and-drop handlers/styles from the displayed header

Co-authored-by: Cursor <cursoragent@cursor.com>

frontend/src/editor/frontmatter/AuthorInlineForm.tsx ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect } from "react";
2
+ import type { Author, Affiliation } from "./frontmatter-store";
3
+
4
+ /**
5
+ * Add/Edit form for a single author, rendered as a native modal dialog.
6
+ *
7
+ * Affiliations are selected by their 1-based index (matching the store's
8
+ * `author.affiliations` model). A free-text "new affiliation" field lets the
9
+ * user create + attach an affiliation in one go; the caller persists it via
10
+ * `onSubmit(author, newAffiliation)`.
11
+ *
12
+ * Extracted from FrontmatterHero so both the hero shortcut and the
13
+ * AuthorsManager modal share one tested form.
14
+ */
15
+ export function AuthorInlineForm({
16
+ open,
17
+ initial,
18
+ affiliations,
19
+ onSubmit,
20
+ onCancel,
21
+ }: {
22
+ open: boolean;
23
+ initial?: Author;
24
+ affiliations: Affiliation[];
25
+ onSubmit: (author: Author, newAffiliation?: Affiliation) => void;
26
+ onCancel: () => void;
27
+ }) {
28
+ const [name, setName] = useState(initial?.name || "");
29
+ const [url, setUrl] = useState(initial?.url || "");
30
+ const [affIndices, setAffIndices] = useState<number[]>(initial?.affiliations || []);
31
+ const [newAffName, setNewAffName] = useState("");
32
+ const nameRef = useRef<HTMLInputElement>(null);
33
+ const dialogRef = useRef<HTMLDialogElement>(null);
34
+
35
+ useEffect(() => {
36
+ const dialog = dialogRef.current;
37
+ if (!dialog) return;
38
+ if (open && !dialog.open) {
39
+ setName(initial?.name || "");
40
+ setUrl(initial?.url || "");
41
+ setAffIndices(initial?.affiliations || []);
42
+ setNewAffName("");
43
+ dialog.showModal();
44
+ setTimeout(() => nameRef.current?.focus(), 50);
45
+ } else if (!open && dialog.open) {
46
+ dialog.close();
47
+ }
48
+ }, [open, initial]);
49
+
50
+ useEffect(() => {
51
+ const dialog = dialogRef.current;
52
+ if (!dialog) return;
53
+ const onClose = () => onCancel();
54
+ dialog.addEventListener("close", onClose);
55
+ return () => dialog.removeEventListener("close", onClose);
56
+ }, [onCancel]);
57
+
58
+ const handleSubmit = () => {
59
+ if (!name.trim()) return;
60
+ const newAff = newAffName.trim() ? { name: newAffName.trim() } : undefined;
61
+ onSubmit(
62
+ { name: name.trim(), url: url.trim() || undefined, affiliations: affIndices },
63
+ newAff,
64
+ );
65
+ };
66
+
67
+ const handleCancel = () => {
68
+ dialogRef.current?.close();
69
+ onCancel();
70
+ };
71
+
72
+ const toggleAff = (idx: number) => {
73
+ setAffIndices((prev) =>
74
+ prev.includes(idx) ? prev.filter((i) => i !== idx) : [...prev, idx],
75
+ );
76
+ };
77
+
78
+ return (
79
+ <dialog ref={dialogRef} className="ed-dialog ed-dialog--author">
80
+ <h3 className="ed-dialog__title">
81
+ {initial ? "Edit author" : "Add author"}
82
+ </h3>
83
+ <div className="ed-dialog__body">
84
+ <div className="author-form__row">
85
+ <input
86
+ ref={nameRef}
87
+ className="form-input"
88
+ placeholder="Name"
89
+ value={name}
90
+ onChange={(e) => setName(e.target.value)}
91
+ onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
92
+ />
93
+ <input
94
+ className="form-input"
95
+ placeholder="URL (optional)"
96
+ value={url}
97
+ onChange={(e) => setUrl(e.target.value)}
98
+ onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
99
+ />
100
+ </div>
101
+
102
+ {affiliations.length > 0 && (
103
+ <div className="author-form__chips" style={{ marginTop: 12 }}>
104
+ {affiliations.map((aff, i) => (
105
+ <span
106
+ key={`aff-select-${i}`}
107
+ className={`chip chip--clickable ${affIndices.includes(i + 1) ? "" : "chip--outlined"}`}
108
+ style={affIndices.includes(i + 1) ? { background: "var(--primary-color)", color: "#000" } : {}}
109
+ onClick={() => toggleAff(i + 1)}
110
+ >
111
+ {i + 1}. {aff.name}
112
+ </span>
113
+ ))}
114
+ </div>
115
+ )}
116
+
117
+ <input
118
+ className="form-input"
119
+ placeholder="New affiliation (optional), e.g. Hugging Face"
120
+ value={newAffName}
121
+ onChange={(e) => setNewAffName(e.target.value)}
122
+ onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
123
+ style={{ marginTop: 12 }}
124
+ />
125
+ </div>
126
+ <div className="ed-dialog__actions">
127
+ <button className="btn" onClick={handleCancel}>Cancel</button>
128
+ <button
129
+ className="btn btn--primary"
130
+ onClick={handleSubmit}
131
+ disabled={!name.trim()}
132
+ >
133
+ {initial ? "Save" : "Add"}
134
+ </button>
135
+ </div>
136
+ </dialog>
137
+ );
138
+ }
frontend/src/editor/frontmatter/AuthorsManager.tsx ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect } from "react";
2
+ import {
3
+ ChevronUp,
4
+ ChevronDown,
5
+ Pencil,
6
+ Trash2,
7
+ Plus,
8
+ ExternalLink,
9
+ } from "lucide-react";
10
+ import { AuthorInlineForm } from "./AuthorInlineForm";
11
+ import type { FrontmatterStore, Author, Affiliation } from "./frontmatter-store";
12
+
13
+ interface AuthorsManagerProps {
14
+ open: boolean;
15
+ store: FrontmatterStore;
16
+ authors: Author[];
17
+ affiliations: Affiliation[];
18
+ onClose: () => void;
19
+ }
20
+
21
+ /**
22
+ * One-stop modal to manage the article byline: add/edit/remove authors,
23
+ * add/edit/remove affiliations, and reorder both lists with explicit
24
+ * up/down controls (the discoverable replacement for the invisible inline
25
+ * drag). All mutations go through the FrontmatterStore so they stay
26
+ * collaborative + undoable; reordering or deleting an affiliation remaps
27
+ * every author's 1-based references inside the store.
28
+ */
29
+ export function AuthorsManager({
30
+ open,
31
+ store,
32
+ authors,
33
+ affiliations,
34
+ onClose,
35
+ }: AuthorsManagerProps) {
36
+ const dialogRef = useRef<HTMLDialogElement>(null);
37
+ // `null` = closed, `"new"` = add form, number = edit author at that index.
38
+ const [authorForm, setAuthorForm] = useState<number | "new" | null>(null);
39
+ const [newAffName, setNewAffName] = useState("");
40
+
41
+ useEffect(() => {
42
+ const dialog = dialogRef.current;
43
+ if (!dialog) return;
44
+ if (open && !dialog.open) {
45
+ setAuthorForm(null);
46
+ setNewAffName("");
47
+ dialog.showModal();
48
+ } else if (!open && dialog.open) {
49
+ dialog.close();
50
+ }
51
+ }, [open]);
52
+
53
+ useEffect(() => {
54
+ const dialog = dialogRef.current;
55
+ if (!dialog) return;
56
+ const onCloseEvent = () => onClose();
57
+ dialog.addEventListener("close", onCloseEvent);
58
+ return () => dialog.removeEventListener("close", onCloseEvent);
59
+ }, [onClose]);
60
+
61
+ const multipleAffiliations = affiliations.length > 1;
62
+
63
+ const submitAuthorForm = (author: Author, newAff?: Affiliation) => {
64
+ const finalAuthor = { ...author };
65
+ if (newAff) {
66
+ const idx = store.addAffiliation(newAff);
67
+ finalAuthor.affiliations = [...finalAuthor.affiliations, idx];
68
+ }
69
+ if (authorForm === "new") {
70
+ store.addAuthor(finalAuthor);
71
+ } else if (typeof authorForm === "number") {
72
+ store.updateAuthor(authorForm, finalAuthor);
73
+ }
74
+ setAuthorForm(null);
75
+ };
76
+
77
+ const addAffiliation = () => {
78
+ const name = newAffName.trim();
79
+ if (!name) return;
80
+ store.addAffiliation({ name });
81
+ setNewAffName("");
82
+ };
83
+
84
+ return (
85
+ <dialog ref={dialogRef} className="ed-dialog ed-dialog--manager">
86
+ <h3 className="ed-dialog__title">Authors &amp; affiliations</h3>
87
+
88
+ <div className="ed-dialog__body authors-manager">
89
+ {/* ---- Authors ---- */}
90
+ <section className="am-section">
91
+ <div className="am-section__head">
92
+ <span className="am-section__title">Authors</span>
93
+ <button
94
+ className="btn btn--primary am-add-btn"
95
+ onClick={() => setAuthorForm("new")}
96
+ >
97
+ <Plus size={14} />
98
+ <span>Add author</span>
99
+ </button>
100
+ </div>
101
+
102
+ {authors.length === 0 ? (
103
+ <p className="am-empty">No authors yet.</p>
104
+ ) : (
105
+ <ul className="am-list">
106
+ {authors.map((author, i) => (
107
+ <li className="am-row" key={`am-author-${i}`}>
108
+ <div className="am-row__reorder">
109
+ <button
110
+ className="icon-btn icon-btn--sm"
111
+ aria-label="Move up"
112
+ disabled={i === 0}
113
+ onClick={() => store.moveAuthor(i, i - 1)}
114
+ >
115
+ <ChevronUp size={14} />
116
+ </button>
117
+ <button
118
+ className="icon-btn icon-btn--sm"
119
+ aria-label="Move down"
120
+ disabled={i === authors.length - 1}
121
+ onClick={() => store.moveAuthor(i, i + 1)}
122
+ >
123
+ <ChevronDown size={14} />
124
+ </button>
125
+ </div>
126
+
127
+ <div className="am-row__main">
128
+ <span className="am-row__name">
129
+ {author.name || <em className="am-muted">Unnamed</em>}
130
+ {multipleAffiliations && author.affiliations?.length > 0 && (
131
+ <sup className="am-row__affs">
132
+ {[...author.affiliations].sort((a, b) => a - b).join(",")}
133
+ </sup>
134
+ )}
135
+ </span>
136
+ {author.url && (
137
+ <a
138
+ className="am-row__url"
139
+ href={author.url}
140
+ target="_blank"
141
+ rel="noopener noreferrer"
142
+ >
143
+ <ExternalLink size={11} />
144
+ <span>{prettyUrl(author.url)}</span>
145
+ </a>
146
+ )}
147
+ </div>
148
+
149
+ <div className="am-row__actions">
150
+ <button
151
+ className="icon-btn icon-btn--sm"
152
+ aria-label="Edit author"
153
+ onClick={() => setAuthorForm(i)}
154
+ >
155
+ <Pencil size={14} />
156
+ </button>
157
+ <button
158
+ className="icon-btn icon-btn--sm am-row__danger"
159
+ aria-label="Remove author"
160
+ onClick={() => store.removeAuthor(i)}
161
+ >
162
+ <Trash2 size={14} />
163
+ </button>
164
+ </div>
165
+ </li>
166
+ ))}
167
+ </ul>
168
+ )}
169
+ </section>
170
+
171
+ {/* ---- Affiliations ---- */}
172
+ <section className="am-section">
173
+ <div className="am-section__head">
174
+ <span className="am-section__title">Affiliations</span>
175
+ </div>
176
+
177
+ {affiliations.length === 0 ? (
178
+ <p className="am-empty">
179
+ No affiliations yet. Add one below, then attach it to authors.
180
+ </p>
181
+ ) : (
182
+ <ol className="am-list am-list--aff">
183
+ {affiliations.map((aff, i) => (
184
+ <AffiliationRow
185
+ key={`am-aff-${i}`}
186
+ index={i}
187
+ total={affiliations.length}
188
+ affiliation={aff}
189
+ store={store}
190
+ />
191
+ ))}
192
+ </ol>
193
+ )}
194
+
195
+ <div className="am-add-row">
196
+ <input
197
+ className="form-input"
198
+ placeholder="New affiliation, e.g. Hugging Face"
199
+ value={newAffName}
200
+ onChange={(e) => setNewAffName(e.target.value)}
201
+ onKeyDown={(e) => e.key === "Enter" && addAffiliation()}
202
+ />
203
+ <button
204
+ className="btn"
205
+ onClick={addAffiliation}
206
+ disabled={!newAffName.trim()}
207
+ >
208
+ <Plus size={14} />
209
+ <span>Add</span>
210
+ </button>
211
+ </div>
212
+ </section>
213
+ </div>
214
+
215
+ <div className="ed-dialog__actions">
216
+ <button className="btn btn--primary" onClick={() => dialogRef.current?.close()}>
217
+ Done
218
+ </button>
219
+ </div>
220
+
221
+ {/* Author add/edit form, stacked on top of this dialog. */}
222
+ <AuthorInlineForm
223
+ open={authorForm !== null}
224
+ initial={typeof authorForm === "number" ? authors[authorForm] : undefined}
225
+ affiliations={affiliations}
226
+ onSubmit={submitAuthorForm}
227
+ onCancel={() => setAuthorForm(null)}
228
+ />
229
+ </dialog>
230
+ );
231
+ }
232
+
233
+ /**
234
+ * Single affiliation row with inline name + URL editing. Keeps a local draft
235
+ * so typing doesn't round-trip through Yjs on every keystroke; commits on
236
+ * blur / Enter only when the value actually changed.
237
+ */
238
+ function AffiliationRow({
239
+ index,
240
+ total,
241
+ affiliation,
242
+ store,
243
+ }: {
244
+ index: number;
245
+ total: number;
246
+ affiliation: Affiliation;
247
+ store: FrontmatterStore;
248
+ }) {
249
+ const [name, setName] = useState(affiliation.name);
250
+ const [url, setUrl] = useState(affiliation.url || "");
251
+
252
+ useEffect(() => setName(affiliation.name), [affiliation.name]);
253
+ useEffect(() => setUrl(affiliation.url || ""), [affiliation.url]);
254
+
255
+ const commit = () => {
256
+ const nextName = name.trim();
257
+ const nextUrl = url.trim();
258
+ if (nextName === affiliation.name && nextUrl === (affiliation.url || "")) return;
259
+ if (!nextName) {
260
+ // Don't allow blanking the name: revert to the stored value.
261
+ setName(affiliation.name);
262
+ return;
263
+ }
264
+ store.updateAffiliation(index, {
265
+ name: nextName,
266
+ url: nextUrl || undefined,
267
+ });
268
+ };
269
+
270
+ return (
271
+ <li className="am-row am-row--aff">
272
+ <span className="am-row__index">{index + 1}.</span>
273
+
274
+ <div className="am-row__reorder">
275
+ <button
276
+ className="icon-btn icon-btn--sm"
277
+ aria-label="Move up"
278
+ disabled={index === 0}
279
+ onClick={() => store.moveAffiliation(index, index - 1)}
280
+ >
281
+ <ChevronUp size={14} />
282
+ </button>
283
+ <button
284
+ className="icon-btn icon-btn--sm"
285
+ aria-label="Move down"
286
+ disabled={index === total - 1}
287
+ onClick={() => store.moveAffiliation(index, index + 1)}
288
+ >
289
+ <ChevronDown size={14} />
290
+ </button>
291
+ </div>
292
+
293
+ <div className="am-row__fields">
294
+ <input
295
+ className="form-input"
296
+ placeholder="Affiliation name"
297
+ value={name}
298
+ onChange={(e) => setName(e.target.value)}
299
+ onBlur={commit}
300
+ onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
301
+ />
302
+ <input
303
+ className="form-input"
304
+ placeholder="URL (optional)"
305
+ value={url}
306
+ onChange={(e) => setUrl(e.target.value)}
307
+ onBlur={commit}
308
+ onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
309
+ />
310
+ </div>
311
+
312
+ <button
313
+ className="icon-btn icon-btn--sm am-row__danger"
314
+ aria-label="Remove affiliation"
315
+ onClick={() => store.removeAffiliation(index)}
316
+ >
317
+ <Trash2 size={14} />
318
+ </button>
319
+ </li>
320
+ );
321
+ }
322
+
323
+ /** Strip protocol + trailing slash for a compact URL label. */
324
+ function prettyUrl(url: string): string {
325
+ return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
326
+ }
frontend/src/editor/frontmatter/FrontmatterHero.tsx CHANGED
@@ -1,11 +1,12 @@
1
  import { useState, useRef, useEffect, useMemo, type KeyboardEvent } from "react";
2
  import { Tooltip } from "../../components/Tooltip";
3
- import { Plus, ImagePlus, BarChart3, Trash2, PencilLine } from "lucide-react";
4
  import { useFrontmatter } from "./useFrontmatter";
5
  import { buildDoc } from "../embeds/build-doc";
6
  import { useTheme } from "../../hooks/useTheme";
7
  import { uploadImage } from "../upload";
8
- import type { FrontmatterStore, Author, Affiliation } from "./frontmatter-store";
 
9
  import type { EmbedStore } from "../embeds/embed-store";
10
 
11
  const BANNER_KEY = "banner.html";
@@ -27,17 +28,13 @@ interface FrontmatterHeroProps {
27
 
28
  export function FrontmatterHero({ store, embedStore }: FrontmatterHeroProps) {
29
  const { data, update } = useFrontmatter(store);
30
- const [editingAuthorIdx, setEditingAuthorIdx] = useState<number | null>(null);
31
- const [showAuthorForm, setShowAuthorForm] = useState(false);
32
-
33
- // Drag-and-drop reordering state for the meta header. `drag` is the index
34
- // being dragged, `over` the index currently hovered as a drop target. Both
35
- // reset on drop / dragend. Authors and affiliations track their own pair so
36
- // a drag in one list never highlights the other.
37
- const [dragAuthor, setDragAuthor] = useState<number | null>(null);
38
- const [overAuthor, setOverAuthor] = useState<number | null>(null);
39
- const [dragAff, setDragAff] = useState<number | null>(null);
40
- const [overAff, setOverAff] = useState<number | null>(null);
41
 
42
  const bannerSrc = data?.banner || "";
43
  const [bannerHtml, setBannerHtml] = useState("");
@@ -212,91 +209,42 @@ export function FrontmatterHero({ store, embedStore }: FrontmatterHeroProps) {
212
  <h3>Authors</h3>
213
  {hasAuthors ? (
214
  <ul className="authors">
215
- {data.authors.map((author, i) => {
216
- const canReorder = data.authors.length > 1;
217
- const cls = [
218
- "author-item",
219
- dragAuthor === i ? "author-item--dragging" : "",
220
- overAuthor === i && dragAuthor !== null && dragAuthor !== i
221
- ? "author-item--dragover"
222
- : "",
223
- ]
224
- .filter(Boolean)
225
- .join(" ");
226
- return (
227
- <li
228
- key={`author-${i}`}
229
- className={cls}
230
- draggable={canReorder}
231
- title={canReorder ? "Drag to reorder" : undefined}
232
- onDragStart={(e) => {
233
- setDragAuthor(i);
234
- e.dataTransfer.effectAllowed = "move";
235
- }}
236
- onDragOver={(e) => {
237
- if (dragAuthor === null) return;
238
- e.preventDefault();
239
- e.dataTransfer.dropEffect = "move";
240
- if (overAuthor !== i) setOverAuthor(i);
241
- }}
242
- onDragLeave={() =>
243
- setOverAuthor((v) => (v === i ? null : v))
244
- }
245
- onDrop={(e) => {
246
- e.preventDefault();
247
- if (dragAuthor !== null && dragAuthor !== i) {
248
- store?.moveAuthor(dragAuthor, i);
249
- }
250
- setDragAuthor(null);
251
- setOverAuthor(null);
252
- }}
253
- onDragEnd={() => {
254
- setDragAuthor(null);
255
- setOverAuthor(null);
256
- }}
257
  >
258
- <span
259
- className="author-editable"
260
- onClick={() => setEditingAuthorIdx(i)}
261
- >
262
- {author.url ? (
263
- <a
264
- href={author.url}
265
- target="_blank"
266
- rel="noopener noreferrer"
267
- draggable={false}
268
- >
269
- {author.name}
270
- </a>
271
- ) : (
272
- author.name
273
- )}
274
- {multipleAffiliations && author.affiliations?.length > 0 && (
275
- <sup>{author.affiliations.join(",")}</sup>
276
- )}
277
- </span>
278
- {i < data.authors.length - 1 && <>,&nbsp;</>}
279
- </li>
280
- );
281
- })}
282
  <li className="author-add-btn">
283
- <Tooltip title="Add author">
284
  <button
285
  className="icon-btn"
286
- onClick={() => setShowAuthorForm(true)}
287
- aria-label="Add author"
288
  style={{ padding: 2 }}
289
  >
290
- <Plus size={14} />
291
  </button>
292
  </Tooltip>
293
  </li>
294
  </ul>
295
  ) : (
296
- <button
297
- className="meta-placeholder-btn"
298
- onClick={() => setShowAuthorForm(true)}
299
- >
300
  <Plus size={14} />
301
  <span>Add author</span>
302
  </button>
@@ -307,67 +255,29 @@ export function FrontmatterHero({ store, embedStore }: FrontmatterHeroProps) {
307
  <div className="meta-container-cell">
308
  <h3>Affiliations</h3>
309
  {multipleAffiliations ? (
310
- <ol className="affiliations">
311
- {data.affiliations.map((aff, i) => {
312
- const cls = [
313
- "aff-item",
314
- dragAff === i ? "aff-item--dragging" : "",
315
- overAff === i && dragAff !== null && dragAff !== i
316
- ? "aff-item--dragover"
317
- : "",
318
- ]
319
- .filter(Boolean)
320
- .join(" ");
321
- return (
322
- <li
323
- key={`aff-${i}`}
324
- className={cls}
325
- draggable
326
- title="Drag to reorder"
327
- onDragStart={(e) => {
328
- setDragAff(i);
329
- e.dataTransfer.effectAllowed = "move";
330
- }}
331
- onDragOver={(e) => {
332
- if (dragAff === null) return;
333
- e.preventDefault();
334
- e.dataTransfer.dropEffect = "move";
335
- if (overAff !== i) setOverAff(i);
336
- }}
337
- onDragLeave={() =>
338
- setOverAff((v) => (v === i ? null : v))
339
- }
340
- onDrop={(e) => {
341
- e.preventDefault();
342
- if (dragAff !== null && dragAff !== i) {
343
- store?.moveAffiliation(dragAff, i);
344
- }
345
- setDragAff(null);
346
- setOverAff(null);
347
- }}
348
- onDragEnd={() => {
349
- setDragAff(null);
350
- setOverAff(null);
351
- }}
352
- >
353
- {aff.url ? (
354
- <a
355
- href={aff.url}
356
- target="_blank"
357
- rel="noopener noreferrer"
358
- draggable={false}
359
- >
360
- {aff.name}
361
- </a>
362
- ) : (
363
- aff.name
364
- )}
365
- </li>
366
- );
367
- })}
368
  </ol>
369
  ) : (
370
- <p>
 
 
 
 
371
  {data.affiliations[0].url ? (
372
  <a href={data.affiliations[0].url} target="_blank" rel="noopener noreferrer">
373
  {data.affiliations[0].name}
@@ -392,38 +302,15 @@ export function FrontmatterHero({ store, embedStore }: FrontmatterHeroProps) {
392
  </div>
393
  </header>
394
 
395
- <AuthorInlineForm
396
- open={showAuthorForm}
397
- affiliations={data.affiliations}
398
- onSubmit={(author, newAff) => {
399
- if (newAff) {
400
- const idx = store!.addAffiliation(newAff);
401
- author.affiliations = [...author.affiliations, idx];
402
- }
403
- store?.addAuthor(author);
404
- setShowAuthorForm(false);
405
- }}
406
- onCancel={() => setShowAuthorForm(false)}
407
- />
408
-
409
- <div style={{ maxWidth: 680, margin: "0 auto", padding: "0 16px" }}>
410
- {editingAuthorIdx !== null && data.authors[editingAuthorIdx] && (
411
- <AuthorInlineForm
412
- open={true}
413
- initial={data.authors[editingAuthorIdx]}
414
- affiliations={data.affiliations}
415
- onSubmit={(author, newAff) => {
416
- if (newAff) {
417
- const idx = store!.addAffiliation(newAff);
418
- author.affiliations = [...author.affiliations, idx];
419
- }
420
- store?.updateAuthor(editingAuthorIdx, author);
421
- setEditingAuthorIdx(null);
422
- }}
423
- onCancel={() => setEditingAuthorIdx(null)}
424
- />
425
- )}
426
- </div>
427
  </div>
428
  );
429
  }
@@ -566,128 +453,3 @@ function EditableText({ value, placeholder, onChange, className, multiline, inli
566
  </span>
567
  );
568
  }
569
-
570
- function AuthorInlineForm({
571
- open,
572
- initial,
573
- affiliations,
574
- onSubmit,
575
- onCancel,
576
- }: {
577
- open: boolean;
578
- initial?: Author;
579
- affiliations: Affiliation[];
580
- onSubmit: (author: Author, newAffiliation?: Affiliation) => void;
581
- onCancel: () => void;
582
- }) {
583
- const [name, setName] = useState(initial?.name || "");
584
- const [url, setUrl] = useState(initial?.url || "");
585
- const [affIndices, setAffIndices] = useState<number[]>(initial?.affiliations || []);
586
- const [newAffName, setNewAffName] = useState("");
587
- const nameRef = useRef<HTMLInputElement>(null);
588
- const dialogRef = useRef<HTMLDialogElement>(null);
589
-
590
- useEffect(() => {
591
- const dialog = dialogRef.current;
592
- if (!dialog) return;
593
- if (open && !dialog.open) {
594
- setName(initial?.name || "");
595
- setUrl(initial?.url || "");
596
- setAffIndices(initial?.affiliations || []);
597
- setNewAffName("");
598
- dialog.showModal();
599
- setTimeout(() => nameRef.current?.focus(), 50);
600
- } else if (!open && dialog.open) {
601
- dialog.close();
602
- }
603
- }, [open, initial]);
604
-
605
- useEffect(() => {
606
- const dialog = dialogRef.current;
607
- if (!dialog) return;
608
- const onClose = () => onCancel();
609
- dialog.addEventListener("close", onClose);
610
- return () => dialog.removeEventListener("close", onClose);
611
- }, [onCancel]);
612
-
613
- const handleSubmit = () => {
614
- if (!name.trim()) return;
615
- const newAff = newAffName.trim() ? { name: newAffName.trim() } : undefined;
616
- onSubmit(
617
- { name: name.trim(), url: url.trim() || undefined, affiliations: affIndices },
618
- newAff,
619
- );
620
- };
621
-
622
- const handleCancel = () => {
623
- dialogRef.current?.close();
624
- onCancel();
625
- };
626
-
627
- const toggleAff = (idx: number) => {
628
- setAffIndices((prev) =>
629
- prev.includes(idx) ? prev.filter((i) => i !== idx) : [...prev, idx],
630
- );
631
- };
632
-
633
- return (
634
- <dialog ref={dialogRef} className="ed-dialog ed-dialog--author">
635
- <h3 className="ed-dialog__title">
636
- {initial ? "Edit author" : "Add author"}
637
- </h3>
638
- <div className="ed-dialog__body">
639
- <div className="author-form__row">
640
- <input
641
- ref={nameRef}
642
- className="form-input"
643
- placeholder="Name"
644
- value={name}
645
- onChange={(e) => setName(e.target.value)}
646
- onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
647
- />
648
- <input
649
- className="form-input"
650
- placeholder="URL (optional)"
651
- value={url}
652
- onChange={(e) => setUrl(e.target.value)}
653
- onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
654
- />
655
- </div>
656
-
657
- {affiliations.length > 0 && (
658
- <div className="author-form__chips" style={{ marginTop: 12 }}>
659
- {affiliations.map((aff, i) => (
660
- <span
661
- key={`aff-select-${i}`}
662
- className={`chip chip--clickable ${affIndices.includes(i + 1) ? "" : "chip--outlined"}`}
663
- style={affIndices.includes(i + 1) ? { background: "var(--primary-color)", color: "#000" } : {}}
664
- onClick={() => toggleAff(i + 1)}
665
- >
666
- {i + 1}. {aff.name}
667
- </span>
668
- ))}
669
- </div>
670
- )}
671
-
672
- <input
673
- className="form-input"
674
- placeholder="New affiliation (optional), e.g. Hugging Face"
675
- value={newAffName}
676
- onChange={(e) => setNewAffName(e.target.value)}
677
- onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
678
- style={{ marginTop: 12 }}
679
- />
680
- </div>
681
- <div className="ed-dialog__actions">
682
- <button className="btn" onClick={handleCancel}>Cancel</button>
683
- <button
684
- className="btn btn--primary"
685
- onClick={handleSubmit}
686
- disabled={!name.trim()}
687
- >
688
- {initial ? "Save" : "Add"}
689
- </button>
690
- </div>
691
- </dialog>
692
- );
693
- }
 
1
  import { useState, useRef, useEffect, useMemo, type KeyboardEvent } from "react";
2
  import { Tooltip } from "../../components/Tooltip";
3
+ import { Plus, ImagePlus, BarChart3, Trash2, PencilLine, Pencil } from "lucide-react";
4
  import { useFrontmatter } from "./useFrontmatter";
5
  import { buildDoc } from "../embeds/build-doc";
6
  import { useTheme } from "../../hooks/useTheme";
7
  import { uploadImage } from "../upload";
8
+ import { AuthorsManager } from "./AuthorsManager";
9
+ import type { FrontmatterStore } from "./frontmatter-store";
10
  import type { EmbedStore } from "../embeds/embed-store";
11
 
12
  const BANNER_KEY = "banner.html";
 
28
 
29
  export function FrontmatterHero({ store, embedStore }: FrontmatterHeroProps) {
30
  const { data, update } = useFrontmatter(store);
31
+ // Single entry point for all byline editing (add/edit/remove/reorder of
32
+ // authors and affiliations). Reordering used to be an undiscoverable inline
33
+ // drag; it now lives in this modal behind an explicit affordance.
34
+ const [showManager, setShowManager] = useState(false);
35
+ const openManager = () => {
36
+ if (store) setShowManager(true);
37
+ };
 
 
 
 
38
 
39
  const bannerSrc = data?.banner || "";
40
  const [bannerHtml, setBannerHtml] = useState("");
 
209
  <h3>Authors</h3>
210
  {hasAuthors ? (
211
  <ul className="authors">
212
+ {data.authors.map((author, i) => (
213
+ <li key={`author-${i}`}>
214
+ <span
215
+ className="author-editable"
216
+ onClick={openManager}
217
+ title="Manage authors & affiliations"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  >
219
+ {author.url ? (
220
+ <a href={author.url} target="_blank" rel="noopener noreferrer">
221
+ {author.name}
222
+ </a>
223
+ ) : (
224
+ author.name
225
+ )}
226
+ {multipleAffiliations && author.affiliations?.length > 0 && (
227
+ <sup>{author.affiliations.join(",")}</sup>
228
+ )}
229
+ </span>
230
+ {i < data.authors.length - 1 && <>,&nbsp;</>}
231
+ </li>
232
+ ))}
 
 
 
 
 
 
 
 
 
 
233
  <li className="author-add-btn">
234
+ <Tooltip title="Manage authors & affiliations">
235
  <button
236
  className="icon-btn"
237
+ onClick={openManager}
238
+ aria-label="Manage authors and affiliations"
239
  style={{ padding: 2 }}
240
  >
241
+ <Pencil size={13} />
242
  </button>
243
  </Tooltip>
244
  </li>
245
  </ul>
246
  ) : (
247
+ <button className="meta-placeholder-btn" onClick={openManager}>
 
 
 
248
  <Plus size={14} />
249
  <span>Add author</span>
250
  </button>
 
255
  <div className="meta-container-cell">
256
  <h3>Affiliations</h3>
257
  {multipleAffiliations ? (
258
+ <ol
259
+ className="affiliations affiliations--clickable"
260
+ onClick={openManager}
261
+ title="Manage authors & affiliations"
262
+ >
263
+ {data.affiliations.map((aff, i) => (
264
+ <li key={`aff-${i}`}>
265
+ {aff.url ? (
266
+ <a href={aff.url} target="_blank" rel="noopener noreferrer">
267
+ {aff.name}
268
+ </a>
269
+ ) : (
270
+ aff.name
271
+ )}
272
+ </li>
273
+ ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  </ol>
275
  ) : (
276
+ <p
277
+ className="affiliations--clickable"
278
+ onClick={openManager}
279
+ title="Manage authors & affiliations"
280
+ >
281
  {data.affiliations[0].url ? (
282
  <a href={data.affiliations[0].url} target="_blank" rel="noopener noreferrer">
283
  {data.affiliations[0].name}
 
302
  </div>
303
  </header>
304
 
305
+ {store && (
306
+ <AuthorsManager
307
+ open={showManager}
308
+ store={store}
309
+ authors={data.authors}
310
+ affiliations={data.affiliations}
311
+ onClose={() => setShowManager(false)}
312
+ />
313
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  </div>
315
  );
316
  }
 
453
  </span>
454
  );
455
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/editor/frontmatter/frontmatter-store.ts CHANGED
@@ -227,6 +227,39 @@ export function createFrontmatterStore(ydoc: Y.Doc) {
227
  });
228
  }
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  // --- Observe all changes ---
231
 
232
  function observe(callback: () => void) {
@@ -254,6 +287,8 @@ export function createFrontmatterStore(ydoc: Y.Doc) {
254
  moveAuthor,
255
  addAffiliation,
256
  moveAffiliation,
 
 
257
  observe,
258
  };
259
  }
 
227
  });
228
  }
229
 
230
+ function updateAffiliation(index: number, affiliation: Affiliation) {
231
+ if (index >= 0 && index < yAffiliations.length) {
232
+ ydoc.transact(() => {
233
+ yAffiliations.delete(index, 1);
234
+ yAffiliations.insert(index, [affiliation]);
235
+ });
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Remove an affiliation and reconcile every author's 1-based references:
241
+ * drop the references to the deleted one, then shift higher indices down by
242
+ * one so the remaining superscripts keep pointing at the right institution.
243
+ */
244
+ function removeAffiliation(index: number) {
245
+ const len = yAffiliations.length;
246
+ if (index < 0 || index >= len) return;
247
+ const removed = index + 1; // 1-based id used by authors
248
+ ydoc.transact(() => {
249
+ yAffiliations.delete(index, 1);
250
+ const authors = yAuthors.toArray();
251
+ yAuthors.delete(0, yAuthors.length);
252
+ yAuthors.push(
253
+ authors.map((a) => ({
254
+ ...a,
255
+ affiliations: (a.affiliations || [])
256
+ .filter((idx) => idx !== removed)
257
+ .map((idx) => (idx > removed ? idx - 1 : idx)),
258
+ })),
259
+ );
260
+ });
261
+ }
262
+
263
  // --- Observe all changes ---
264
 
265
  function observe(callback: () => void) {
 
287
  moveAuthor,
288
  addAffiliation,
289
  moveAffiliation,
290
+ updateAffiliation,
291
+ removeAffiliation,
292
  observe,
293
  };
294
  }
frontend/src/styles/_ui.css CHANGED
@@ -969,6 +969,133 @@ textarea.form-input { resize: vertical; min-height: 60px; }
969
  .author-form__chips { display: flex; flex-wrap: wrap; gap: 4px; }
970
  dialog.ed-dialog.ed-dialog--author { max-width: 480px; }
971
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
972
  /* ---- Demo-only: agent live-rewrite highlight --------------------------
973
  Two cooperating states visualize the AI agent's work on a selected
974
  paragraph during the recorded demo:
 
969
  .author-form__chips { display: flex; flex-wrap: wrap; gap: 4px; }
970
  dialog.ed-dialog.ed-dialog--author { max-width: 480px; }
971
 
972
+ /* ---- Authors & affiliations manager (rendered inside .ed-dialog) ---- */
973
+ dialog.ed-dialog.ed-dialog--manager { max-width: 600px; }
974
+
975
+ .authors-manager {
976
+ display: flex;
977
+ flex-direction: column;
978
+ gap: 22px;
979
+ /* Tall bylines shouldn't push the action bar off-screen. */
980
+ max-height: min(70vh, 640px);
981
+ overflow-y: auto;
982
+ }
983
+
984
+ .am-section { display: flex; flex-direction: column; gap: 8px; }
985
+ .am-section__head {
986
+ display: flex;
987
+ align-items: center;
988
+ justify-content: space-between;
989
+ gap: 8px;
990
+ }
991
+ .am-section__title {
992
+ font-size: 0.72rem;
993
+ font-weight: 600;
994
+ text-transform: uppercase;
995
+ letter-spacing: 0.04em;
996
+ color: var(--ed-text-secondary);
997
+ }
998
+ .am-add-btn { padding: 4px 10px; }
999
+
1000
+ .am-empty {
1001
+ margin: 0;
1002
+ font-size: 0.82rem;
1003
+ color: var(--ed-text-disabled);
1004
+ font-style: italic;
1005
+ }
1006
+
1007
+ .am-list {
1008
+ list-style: none;
1009
+ margin: 0;
1010
+ padding: 0;
1011
+ display: flex;
1012
+ flex-direction: column;
1013
+ gap: 6px;
1014
+ }
1015
+ .am-list--aff { counter-reset: none; }
1016
+
1017
+ .am-row {
1018
+ display: flex;
1019
+ align-items: center;
1020
+ gap: 8px;
1021
+ padding: 6px 8px;
1022
+ border: 1px solid var(--ed-border);
1023
+ border-radius: var(--ed-radius-sm);
1024
+ background: var(--ed-bg);
1025
+ }
1026
+
1027
+ .am-row__index {
1028
+ flex-shrink: 0;
1029
+ width: 1.4em;
1030
+ text-align: right;
1031
+ font-size: 0.8rem;
1032
+ color: var(--ed-text-secondary);
1033
+ font-variant-numeric: tabular-nums;
1034
+ }
1035
+
1036
+ .am-row__reorder {
1037
+ display: flex;
1038
+ flex-direction: column;
1039
+ flex-shrink: 0;
1040
+ gap: 1px;
1041
+ }
1042
+ .am-row__reorder .icon-btn { padding: 1px; }
1043
+ .am-row__reorder .icon-btn:disabled {
1044
+ opacity: 0.3;
1045
+ cursor: not-allowed;
1046
+ }
1047
+
1048
+ .am-row__main {
1049
+ flex: 1;
1050
+ min-width: 0;
1051
+ display: flex;
1052
+ flex-direction: column;
1053
+ gap: 1px;
1054
+ }
1055
+ .am-row__name {
1056
+ font-size: 0.9rem;
1057
+ color: var(--ed-text);
1058
+ font-weight: 500;
1059
+ }
1060
+ .am-row__affs {
1061
+ font-size: 0.65em;
1062
+ color: var(--primary-color);
1063
+ margin-left: 1px;
1064
+ }
1065
+ .am-muted { color: var(--ed-text-disabled); font-weight: 400; }
1066
+ .am-row__url {
1067
+ display: inline-flex;
1068
+ align-items: center;
1069
+ gap: 3px;
1070
+ font-size: 0.72rem;
1071
+ color: var(--ed-text-secondary);
1072
+ text-decoration: none;
1073
+ max-width: 100%;
1074
+ overflow: hidden;
1075
+ text-overflow: ellipsis;
1076
+ white-space: nowrap;
1077
+ }
1078
+ .am-row__url:hover { color: var(--primary-color); }
1079
+
1080
+ .am-row__fields {
1081
+ flex: 1;
1082
+ min-width: 0;
1083
+ display: flex;
1084
+ gap: 6px;
1085
+ }
1086
+ .am-row__fields .form-input { padding: 5px 8px; font-size: 0.82rem; }
1087
+
1088
+ .am-row__actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
1089
+ .am-row__danger:hover { color: var(--ed-error); background: color-mix(in srgb, var(--ed-error) 12%, transparent); }
1090
+
1091
+ .am-add-row { display: flex; gap: 6px; margin-top: 2px; }
1092
+ .am-add-row .form-input { flex: 1; }
1093
+ .am-add-row .btn { flex-shrink: 0; }
1094
+
1095
+ @media (max-width: 560px) {
1096
+ .am-row__fields { flex-direction: column; }
1097
+ }
1098
+
1099
  /* ---- Demo-only: agent live-rewrite highlight --------------------------
1100
  Two cooperating states visualize the AI agent's work on a selected
1101
  paragraph during the recorded demo:
frontend/src/styles/editor/_hero-editable.css CHANGED
@@ -73,21 +73,11 @@
73
  }
74
  .author-editable:hover { background: var(--bg-hover); }
75
 
76
- /* ---- Drag-and-drop reordering (authors + affiliations) ---- */
77
- /* Editor-only: lives in _hero-editable.css so the drop affordances never
78
- leak into published HTML, which loads _hero.css but not this file. */
79
-
80
- .author-item[draggable="true"] { cursor: grab; }
81
- .author-item--dragging { opacity: 0.4; }
82
- /* Authors flow horizontally (flex row), so the drop marker is a left edge. */
83
- .author-item--dragover { box-shadow: inset 2px 0 0 0 var(--primary-color); }
84
-
85
- .affiliations li[draggable="true"] { cursor: grab; }
86
- .affiliations li.aff-item--dragging { opacity: 0.4; }
87
- /* Affiliations stack vertically (ol), so the drop marker is a top edge. */
88
- .affiliations li.aff-item--dragover {
89
- box-shadow: inset 0 2px 0 0 var(--primary-color);
90
- }
91
 
92
  .author-add-btn {
93
  display: inline-flex;
 
73
  }
74
  .author-editable:hover { background: var(--bg-hover); }
75
 
76
+ /* Affiliations open the same manager modal on click (editor-only). The
77
+ pointer cursor + subtle hover signal they're interactive without leaking
78
+ into the published article, which loads _hero.css but not this file. */
79
+ .affiliations--clickable { cursor: pointer; border-radius: 4px; }
80
+ .affiliations--clickable:hover { background: var(--bg-hover); }
 
 
 
 
 
 
 
 
 
 
81
 
82
  .author-add-btn {
83
  display: inline-flex;