tfrere HF Staff commited on
Commit
20354ec
·
1 Parent(s): 56a5607

refactor: rename to Science Article Template Editor, remove MDX export, fix settings drawer

Browse files

- Rename project from "Collab Editor" to "Science Article Template Editor"
across README, package.json, HTML titles, and docs
- Remove MDX export: delete to-mdx.ts, serializers, UI button, and
registerComponentSerializers; use editor.getText() for agent context
- Settings drawer: flatten Advanced accordion, fix toggle clipping
(ml: 0), widen to 380px, use slotProps.paper (MUI v9)

Made-with: Cursor

README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Collab Editor
3
  emoji: ✏️
4
  colorFrom: purple
5
  colorTo: blue
@@ -11,9 +11,9 @@ hf_oauth_scopes:
11
  - manage-repos
12
  ---
13
 
14
- # Collab Editor
15
 
16
- Collaborative real-time article editor with MDX export. Built with TipTap, Yjs, and Hocuspocus.
17
 
18
  ## Features
19
 
 
1
  ---
2
+ title: Science Article Template Editor
3
  emoji: ✏️
4
  colorFrom: purple
5
  colorTo: blue
 
11
  - manage-repos
12
  ---
13
 
14
+ # Science Article Template Editor
15
 
16
+ Collaborative real-time scientific article editor. Built with TipTap, Yjs, and Hocuspocus.
17
 
18
  ## Features
19
 
backend/package.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "name": "collab-editor-backend",
3
  "version": "0.1.0",
4
  "private": true,
5
  "type": "module",
 
1
  {
2
+ "name": "science-article-template-editor-backend",
3
  "version": "0.1.0",
4
  "private": true,
5
  "type": "module",
backend/src/publisher/html-renderer.ts CHANGED
@@ -11,14 +11,23 @@ import { getServerExtensions } from "./extensions.js";
11
  export interface PublishAuthor {
12
  name: string;
13
  url?: string;
 
14
  affiliationNames: string[];
15
  }
16
 
 
 
 
 
 
17
  export interface PublishMeta {
18
  title: string;
 
19
  description: string;
20
  authors: PublishAuthor[];
 
21
  date: string;
 
22
  ogImage?: string;
23
  }
24
 
@@ -100,49 +109,112 @@ body {
100
  align-items: start;
101
  }
102
 
103
- .article-header {
104
- margin-bottom: 2rem;
105
- padding-bottom: 1.5rem;
106
- border-bottom: 1px solid var(--border);
 
107
  }
108
 
109
- .article-header h1 {
110
- font-size: 2.5rem;
111
- font-weight: 700;
112
- color: var(--text-heading);
113
- line-height: 1.2;
114
- margin: 0 0 0.5rem;
115
  letter-spacing: -0.02em;
 
 
 
 
116
  }
117
 
118
- .article-header .authors {
119
- color: var(--text-secondary);
120
- font-size: 0.9375rem;
121
- margin-bottom: 0.25rem;
 
 
122
  line-height: 1.6;
123
  }
124
 
125
- .article-header .author-link {
126
- color: var(--accent-light);
127
- text-decoration: none;
 
 
 
128
  }
129
 
130
- .article-header .author-link:hover {
131
- text-decoration: underline;
 
 
 
 
 
 
 
 
132
  }
133
 
134
- .article-header .author-affiliation {
 
 
 
 
 
 
 
 
 
 
135
  color: var(--text-tertiary);
136
- font-size: 0.8125rem;
137
- margin-left: 0.25rem;
138
  }
139
 
140
- .article-header .author-affiliation::before { content: "("; }
141
- .article-header .author-affiliation::after { content: ")"; }
142
 
143
- .article-header .date {
144
- color: var(--text-tertiary);
145
- font-size: 0.8125rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  }
147
 
148
  /* Theme toggle */
@@ -381,6 +453,15 @@ dialog.lightbox img { max-width: 95vw; max-height: 90vh; object-fit: contain; bo
381
  <div class="toc-mobile-sidebar__body" id="toc-mobile-placeholder"></div>
382
  </aside>
383
 
 
 
 
 
 
 
 
 
 
384
  <section class="content-grid">
385
  <nav class="table-of-contents" aria-label="Table of Contents">
386
  <div class="title">Table of Contents</div>
@@ -388,12 +469,6 @@ dialog.lightbox img { max-width: 95vw; max-height: 90vh; object-fit: contain; bo
388
  </nav>
389
 
390
  <article>
391
- <header class="article-header">
392
- <h1>${safeTitle}</h1>
393
- ${meta.authors.length > 0 ? `<div class="authors">${renderAuthors(meta.authors)}</div>` : ""}
394
- ${meta.date ? `<p class="date">${escapeHtml(formatDate(meta.date))}</p>` : ""}
395
- </header>
396
-
397
  <div class="tiptap">
398
  ${enrichedBody}
399
  </div>
@@ -719,16 +794,59 @@ function escapeHtml(str: string): string {
719
  .replace(/'/g, "&#039;");
720
  }
721
 
722
- function renderAuthors(authors: PublishAuthor[]): string {
723
- return authors.map((a) => {
724
- const name = a.url
725
- ? `<a href="${escapeHtml(a.url)}" class="author-link" target="_blank" rel="noopener">${escapeHtml(a.name)}</a>`
726
- : `<span class="author-name">${escapeHtml(a.name)}</span>`;
727
- const affil = a.affiliationNames.length > 0
728
- ? `<span class="author-affiliation">${escapeHtml(a.affiliationNames.join(", "))}</span>`
729
- : "";
730
- return `<span class="author">${name}${affil}</span>`;
731
- }).join('<span class="author-sep">, </span>');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732
  }
733
 
734
  function formatDate(dateStr: string): string {
 
11
  export interface PublishAuthor {
12
  name: string;
13
  url?: string;
14
+ affiliationIndices: number[];
15
  affiliationNames: string[];
16
  }
17
 
18
+ export interface PublishAffiliation {
19
+ name: string;
20
+ url?: string;
21
+ }
22
+
23
  export interface PublishMeta {
24
  title: string;
25
+ subtitle?: string;
26
  description: string;
27
  authors: PublishAuthor[];
28
+ affiliations: PublishAffiliation[];
29
  date: string;
30
+ doi?: string;
31
  ogImage?: string;
32
  }
33
 
 
109
  align-items: start;
110
  }
111
 
112
+ /* Hero section */
113
+ .article-hero {
114
+ width: 100%;
115
+ padding: 64px 16px 16px;
116
+ text-align: center;
117
  }
118
 
119
+ .article-hero h1 {
120
+ font-size: clamp(34px, 5vw, 54px);
121
+ font-weight: 800;
122
+ line-height: 1.12;
 
 
123
  letter-spacing: -0.02em;
124
+ margin: 0 auto 8px;
125
+ max-width: 780px;
126
+ text-wrap: balance;
127
+ color: var(--text-heading);
128
  }
129
 
130
+ .article-hero .hero-desc {
131
+ color: var(--text-tertiary);
132
+ font-style: italic;
133
+ margin: 0 auto 16px;
134
+ max-width: 55%;
135
+ font-size: 1.15em;
136
  line-height: 1.6;
137
  }
138
 
139
+ /* Meta bar */
140
+ .article-meta {
141
+ border-top: 1px solid var(--border);
142
+ border-bottom: 1px solid var(--border);
143
+ padding: 1rem 0;
144
+ font-size: 0.9rem;
145
  }
146
 
147
+ .article-meta .meta-container {
148
+ max-width: 980px;
149
+ margin: 0 auto;
150
+ padding: 0 16px;
151
+ display: flex;
152
+ flex-direction: row;
153
+ justify-content: space-between;
154
+ gap: 8px;
155
+ flex-wrap: wrap;
156
+ row-gap: 12px;
157
  }
158
 
159
+ .article-meta .meta-cell {
160
+ display: flex;
161
+ flex-direction: column;
162
+ gap: 8px;
163
+ max-width: 400px;
164
+ }
165
+
166
+ .article-meta .meta-cell h3 {
167
+ margin: 0;
168
+ font-size: 12px;
169
+ font-weight: 400;
170
  color: var(--text-tertiary);
171
+ text-transform: uppercase;
172
+ letter-spacing: 0.02em;
173
  }
174
 
175
+ .article-meta .meta-cell p { margin: 0; }
 
176
 
177
+ .article-meta .authors-list {
178
+ margin: 0;
179
+ list-style: none;
180
+ padding-left: 0;
181
+ display: flex;
182
+ flex-wrap: wrap;
183
+ }
184
+
185
+ .article-meta .authors-list li {
186
+ white-space: nowrap;
187
+ padding: 0;
188
+ }
189
+
190
+ .article-meta .author-link {
191
+ color: var(--accent-light);
192
+ text-decoration: underline;
193
+ text-underline-offset: 2px;
194
+ text-decoration-thickness: 0.06em;
195
+ }
196
+
197
+ .article-meta .author-link:hover {
198
+ color: var(--text-primary);
199
+ }
200
+
201
+ .article-meta .affiliations-list {
202
+ margin: 0;
203
+ padding-left: 1.25em;
204
+ }
205
+
206
+ .article-meta .affiliations-list li { margin: 0; }
207
+
208
+ .article-meta .doi-link {
209
+ color: var(--accent-light);
210
+ text-decoration: underline;
211
+ text-underline-offset: 2px;
212
+ font-family: monospace;
213
+ }
214
+
215
+ @media (max-width: 768px) {
216
+ .article-hero .hero-desc { max-width: 90%; }
217
+ .article-meta .meta-container { flex-direction: column; }
218
  }
219
 
220
  /* Theme toggle */
 
453
  <div class="toc-mobile-sidebar__body" id="toc-mobile-placeholder"></div>
454
  </aside>
455
 
456
+ <!-- Hero -->
457
+ <section class="article-hero">
458
+ <h1>${safeTitle}</h1>
459
+ ${meta.subtitle ? `<p class="hero-desc">${escapeHtml(meta.subtitle)}</p>` : ""}
460
+ </section>
461
+
462
+ <!-- Meta bar -->
463
+ ${renderMetaBar(meta)}
464
+
465
  <section class="content-grid">
466
  <nav class="table-of-contents" aria-label="Table of Contents">
467
  <div class="title">Table of Contents</div>
 
469
  </nav>
470
 
471
  <article>
 
 
 
 
 
 
472
  <div class="tiptap">
473
  ${enrichedBody}
474
  </div>
 
794
  .replace(/'/g, "&#039;");
795
  }
796
 
797
+ function renderMetaBar(meta: PublishMeta): string {
798
+ const hasContent = meta.authors.length > 0 || meta.date || meta.doi;
799
+ if (!hasContent) return "";
800
+
801
+ const multipleAff = meta.affiliations.length > 1;
802
+ const cells: string[] = [];
803
+
804
+ // Authors cell
805
+ if (meta.authors.length > 0) {
806
+ const items = meta.authors.map((a, i) => {
807
+ const name = a.url
808
+ ? `<a href="${escapeHtml(a.url)}" class="author-link" target="_blank" rel="noopener">${escapeHtml(a.name)}</a>`
809
+ : escapeHtml(a.name);
810
+ const sup = multipleAff && a.affiliationIndices.length > 0
811
+ ? `<sup>${a.affiliationIndices.join(",")}</sup>`
812
+ : "";
813
+ const sep = i < meta.authors.length - 1 ? ",&nbsp;" : "";
814
+ return `<li>${name}${sup}${sep}</li>`;
815
+ }).join("");
816
+ cells.push(`<div class="meta-cell"><h3>Authors</h3><ul class="authors-list">${items}</ul></div>`);
817
+ }
818
+
819
+ // Affiliations cell
820
+ if (meta.affiliations.length > 0) {
821
+ let affContent: string;
822
+ if (multipleAff) {
823
+ const items = meta.affiliations.map((aff) => {
824
+ const name = aff.url
825
+ ? `<a href="${escapeHtml(aff.url)}" target="_blank" rel="noopener">${escapeHtml(aff.name)}</a>`
826
+ : escapeHtml(aff.name);
827
+ return `<li>${name}</li>`;
828
+ }).join("");
829
+ affContent = `<ol class="affiliations-list">${items}</ol>`;
830
+ } else {
831
+ const aff = meta.affiliations[0];
832
+ affContent = aff.url
833
+ ? `<p><a href="${escapeHtml(aff.url)}" target="_blank" rel="noopener">${escapeHtml(aff.name)}</a></p>`
834
+ : `<p>${escapeHtml(aff.name)}</p>`;
835
+ }
836
+ cells.push(`<div class="meta-cell"><h3>Affiliations</h3>${affContent}</div>`);
837
+ }
838
+
839
+ // Published cell
840
+ if (meta.date) {
841
+ cells.push(`<div class="meta-cell"><h3>Published</h3><p>${escapeHtml(formatDate(meta.date))}</p></div>`);
842
+ }
843
+
844
+ // DOI cell
845
+ if (meta.doi) {
846
+ cells.push(`<div class="meta-cell"><h3>DOI</h3><p><a href="https://doi.org/${escapeHtml(meta.doi)}" class="doi-link" target="_blank" rel="noopener">${escapeHtml(meta.doi)}</a></p></div>`);
847
+ }
848
+
849
+ return `<header class="article-meta"><div class="meta-container">${cells.join("")}</div></header>`;
850
  }
851
 
852
  function formatDate(dateStr: string): string {
backend/src/publisher/index.ts CHANGED
@@ -152,18 +152,21 @@ export async function publishDocument(docName: string, token?: string): Promise<
152
  // Extract content
153
  const { json, frontmatter, authors, affiliations } = extractFromYDoc(ydoc);
154
 
155
- // Build metadata - authors come from Y.Array, date from "published" scalar
156
  const meta: PublishMeta = {
157
  title: (frontmatter.title as string) || docName,
 
158
  description: (frontmatter.description as string) || "",
159
  authors: authors.map((a) => ({
160
  name: a.name,
161
  url: a.url,
 
162
  affiliationNames: (a.affiliations || [])
163
  .map((idx) => affiliations[idx - 1]?.name)
164
  .filter(Boolean) as string[],
165
  })),
 
166
  date: (frontmatter.published as string) || (frontmatter.date as string) || new Date().toISOString(),
 
167
  };
168
 
169
  const css = loadCSS();
 
152
  // Extract content
153
  const { json, frontmatter, authors, affiliations } = extractFromYDoc(ydoc);
154
 
 
155
  const meta: PublishMeta = {
156
  title: (frontmatter.title as string) || docName,
157
+ subtitle: (frontmatter.subtitle as string) || undefined,
158
  description: (frontmatter.description as string) || "",
159
  authors: authors.map((a) => ({
160
  name: a.name,
161
  url: a.url,
162
+ affiliationIndices: a.affiliations || [],
163
  affiliationNames: (a.affiliations || [])
164
  .map((idx) => affiliations[idx - 1]?.name)
165
  .filter(Boolean) as string[],
166
  })),
167
+ affiliations: affiliations.map((a) => ({ name: a.name, url: a.url })),
168
  date: (frontmatter.published as string) || (frontmatter.date as string) || new Date().toISOString(),
169
+ doi: (frontmatter.doi as string) || undefined,
170
  };
171
 
172
  const css = loadCSS();
backend/src/server.ts CHANGED
@@ -321,7 +321,7 @@ function sendLoginPage(res: express.Response) {
321
  <head>
322
  <meta charset="utf-8" />
323
  <meta name="viewport" content="width=device-width, initial-scale=1" />
324
- <title>Collab Editor</title>
325
  <style>
326
  * { margin: 0; padding: 0; box-sizing: border-box; }
327
  body { display: flex; align-items: center; justify-content: center; height: 100vh;
 
321
  <head>
322
  <meta charset="utf-8" />
323
  <meta name="viewport" content="width=device-width, initial-scale=1" />
324
+ <title>Science Article Template Editor</title>
325
  <style>
326
  * { margin: 0; padding: 0; box-sizing: border-box; }
327
  body { display: flex; align-items: center; justify-content: center; height: 100vh;
docs/embed-studio.md CHANGED
@@ -2,13 +2,13 @@
2
 
3
  ## Overview
4
 
5
- The Embed Studio is a dedicated UI mode within the collab-editor for creating, editing, and previewing HTML embed visualizations (D3.js charts). It isolates the dataviz workflow from the article editing flow, providing a focused chat + preview experience similar to the standalone [dataviz-agent-space](https://huggingface.co/spaces/tfrere/dataviz-agent-space).
6
 
7
  ## Context
8
 
9
  The research-article-template uses `<HtmlEmbed>` components to embed self-contained D3.js charts into articles. These are `.html` files in `app/src/content/embeds/` with strict conventions (scoped CSS, IIFE scripts, ColorPalettes, responsive, etc.) documented in `.ai/skills/create-html-embed/directives.md`.
10
 
11
- In the collab-editor, users need to create and iterate on these charts without leaving the editor. The Embed Studio solves this by providing a dedicated panel with an AI assistant specialized in D3 chart generation.
12
 
13
  ## Storage
14
 
 
2
 
3
  ## Overview
4
 
5
+ The Embed Studio is a dedicated UI mode within the editor for creating, editing, and previewing HTML embed visualizations (D3.js charts). It isolates the dataviz workflow from the article editing flow, providing a focused chat + preview experience similar to the standalone [dataviz-agent-space](https://huggingface.co/spaces/tfrere/dataviz-agent-space).
6
 
7
  ## Context
8
 
9
  The research-article-template uses `<HtmlEmbed>` components to embed self-contained D3.js charts into articles. These are `.html` files in `app/src/content/embeds/` with strict conventions (scoped CSS, IIFE scripts, ColorPalettes, responsive, etc.) documented in `.ai/skills/create-html-embed/directives.md`.
10
 
11
+ In the editor, users need to create and iterate on these charts without leaving the editor. The Embed Studio solves this by providing a dedicated panel with an AI assistant specialized in D3 chart generation.
12
 
13
  ## Storage
14
 
frontend/index.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Collab Editor</title>
7
  </head>
8
  <body>
9
  <div id="root"></div>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Science Article Template Editor</title>
7
  </head>
8
  <body>
9
  <div id="root"></div>
frontend/package.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "name": "collab-editor-frontend",
3
  "version": "0.1.0",
4
  "private": true,
5
  "type": "module",
 
1
  {
2
+ "name": "science-article-template-editor-frontend",
3
  "version": "0.1.0",
4
  "private": true,
5
  "type": "module",
frontend/src/App.tsx CHANGED
@@ -5,16 +5,16 @@ import type * as Y from "yjs";
5
  import {
6
  Box,
7
  Chip,
8
- Collapse,
9
- Paper,
10
  IconButton,
11
  Tooltip,
 
12
  } from "@mui/material";
13
  import UndoIcon from "@mui/icons-material/Undo";
14
  import RedoIcon from "@mui/icons-material/Redo";
15
- import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
16
  import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
17
  import PublishOutlinedIcon from "@mui/icons-material/PublishOutlined";
 
 
18
  import {
19
  Button,
20
  Dialog,
@@ -23,13 +23,14 @@ import {
23
  DialogContentText,
24
  DialogActions,
25
  CircularProgress,
 
26
  } from "@mui/material";
27
  import { Editor } from "./editor/Editor";
28
  import { CommentsSidebar } from "./components/CommentsSidebar";
29
  import { CommentDialog } from "./components/CommentDialog";
30
  import { ChatPanel } from "./components/ChatPanel";
 
31
  import { useAgentChat } from "./hooks/useAgentChat";
32
- import { toMdx } from "./export/to-mdx";
33
  import type { CommentStore } from "./editor/comments";
34
  import type { FrontmatterStore } from "./editor/frontmatter/frontmatter-store";
35
  import { FrontmatterHero } from "./editor/frontmatter/FrontmatterHero";
@@ -64,8 +65,6 @@ export default function App() {
64
  const [commentStore, setCommentStore] = useState<CommentStore | null>(null);
65
  const [frontmatterStore, setFrontmatterStore] = useState<FrontmatterStore | null>(null);
66
  const [settingsMap, setSettingsMap] = useState<Y.Map<any> | null>(null);
67
- const [mdxOutput, setMdxOutput] = useState("");
68
- const [showExport, setShowExport] = useState(false);
69
  const [commentDialogOpen, setCommentDialogOpen] = useState(false);
70
  const [settingsOpen, setSettingsOpen] = useState(false);
71
  const [publishDialogOpen, setPublishDialogOpen] = useState(false);
@@ -74,6 +73,7 @@ export default function App() {
74
  const selectionRange = useRef<{ from: number; to: number } | null>(null);
75
  const [hasSelection, setHasSelection] = useState(false);
76
  const [undoManager, setUndoManager] = useState<UndoManager | null>(null);
 
77
 
78
  const agentChat = useAgentChat({ editor: editorInstance, undoManager, frontmatterStore });
79
 
@@ -97,14 +97,6 @@ export default function App() {
97
  setEditorInstance(editor);
98
  }, []);
99
 
100
- const handleExport = () => {
101
- if (!editorRef.current) return;
102
- const json = editorRef.current.getJSON();
103
- const fm = frontmatterStore?.getAll();
104
- setMdxOutput(toMdx(json, fm));
105
- setShowExport((v) => !v);
106
- };
107
-
108
  const onCommentStoreReady = useCallback((store: CommentStore) => {
109
  setCommentStore(store);
110
  }, []);
@@ -221,11 +213,6 @@ export default function App() {
221
  <SettingsOutlinedIcon sx={{ fontSize: 18 }} />
222
  </IconButton>
223
  </Tooltip>
224
- <Tooltip title="Export MDX" arrow>
225
- <IconButton size="small" onClick={handleExport} sx={{ color: "text.disabled" }}>
226
- <FileDownloadOutlinedIcon sx={{ fontSize: 18 }} />
227
- </IconButton>
228
- </Tooltip>
229
  <Box sx={{ width: "1px", height: 16, bgcolor: "divider", mx: 0.5 }} />
230
  <Tooltip title="Publish article" arrow>
231
  <IconButton
@@ -250,100 +237,144 @@ export default function App() {
250
  />
251
  </Box>
252
 
253
- {/* 3-column layout: chat | editor | comments */}
254
  <Box
 
255
  sx={{
256
  flex: 1,
257
- display: "grid",
258
- gridTemplateColumns: "280px 1fr 240px",
259
- minHeight: 0,
260
- overflow: "hidden",
261
  }}
262
  >
263
- {/* Chat - fixed, own scroll */}
264
- <Box
265
- sx={{
266
- borderRight: "1px solid",
267
- borderColor: "divider",
268
- overflow: "hidden",
269
- display: "flex",
270
- }}
271
- >
272
- <ChatPanel
273
- messages={agentChat.messages}
274
- isLoading={agentChat.isLoading}
275
- error={agentChat.error}
276
- input={agentChat.input}
277
- hasSelection={hasSelection}
278
- onSend={agentChat.sendMessage}
279
- onQuickAction={agentChat.sendQuickAction}
280
- onSetInput={agentChat.setInput}
281
- onStop={agentChat.stop}
282
- onNewChat={() => window.location.reload()}
283
- />
284
- </Box>
285
-
286
- {/* Editor - scrolls independently */}
287
  <Box
288
- ref={editorContainerCallback}
289
  sx={{
290
- overflowY: "auto",
291
- overflowX: "hidden",
292
- py: 4,
 
293
  }}
294
  >
295
- <FrontmatterHero store={frontmatterStore} />
296
-
297
- <Editor
298
- docName={docName}
299
- user={user}
300
- editorRef={editorRef}
301
- onCommentStoreReady={onCommentStoreReady}
302
- onFrontmatterStoreReady={onFrontmatterStoreReady}
303
- onSettingsMapReady={onSettingsMapReady}
304
- onEditorReady={onEditorReady}
305
- onUndoManagerReady={setUndoManager}
306
- onAddComment={handleAddComment}
307
- />
308
-
309
- <Collapse in={showExport}>
310
- <Paper
311
- variant="outlined"
312
- sx={{
313
- mt: 4,
314
- mx: "auto",
315
- maxWidth: 680,
316
- p: 2.5,
317
- fontFamily: "monospace",
318
- fontSize: "0.8rem",
319
- whiteSpace: "pre-wrap",
320
- maxHeight: 400,
321
- overflow: "auto",
322
- color: "text.secondary",
323
- }}
324
- >
325
- {mdxOutput}
326
- </Paper>
327
- </Collapse>
 
 
 
328
  </Box>
 
329
 
330
- {/* Comments - fixed, own scroll */}
 
331
  <Box
332
  sx={{
333
- overflowY: "auto",
334
- overflowX: "hidden",
335
- pl: 2,
336
- py: 2,
 
 
 
 
 
 
 
 
 
 
 
337
  }}
338
  >
339
- <CommentsSidebar
340
- editor={editorInstance}
341
- commentStore={commentStore}
342
- user={user}
343
- editorContainer={containerEl}
344
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  </Box>
346
- </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
 
348
  <CommentDialog
349
  open={commentDialogOpen}
 
5
  import {
6
  Box,
7
  Chip,
 
 
8
  IconButton,
9
  Tooltip,
10
+ Typography,
11
  } from "@mui/material";
12
  import UndoIcon from "@mui/icons-material/Undo";
13
  import RedoIcon from "@mui/icons-material/Redo";
 
14
  import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
15
  import PublishOutlinedIcon from "@mui/icons-material/PublishOutlined";
16
+ import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutlined";
17
+ import CloseIcon from "@mui/icons-material/Close";
18
  import {
19
  Button,
20
  Dialog,
 
23
  DialogContentText,
24
  DialogActions,
25
  CircularProgress,
26
+ Badge,
27
  } from "@mui/material";
28
  import { Editor } from "./editor/Editor";
29
  import { CommentsSidebar } from "./components/CommentsSidebar";
30
  import { CommentDialog } from "./components/CommentDialog";
31
  import { ChatPanel } from "./components/ChatPanel";
32
+ import { TableOfContents } from "./components/TableOfContents";
33
  import { useAgentChat } from "./hooks/useAgentChat";
 
34
  import type { CommentStore } from "./editor/comments";
35
  import type { FrontmatterStore } from "./editor/frontmatter/frontmatter-store";
36
  import { FrontmatterHero } from "./editor/frontmatter/FrontmatterHero";
 
65
  const [commentStore, setCommentStore] = useState<CommentStore | null>(null);
66
  const [frontmatterStore, setFrontmatterStore] = useState<FrontmatterStore | null>(null);
67
  const [settingsMap, setSettingsMap] = useState<Y.Map<any> | null>(null);
 
 
68
  const [commentDialogOpen, setCommentDialogOpen] = useState(false);
69
  const [settingsOpen, setSettingsOpen] = useState(false);
70
  const [publishDialogOpen, setPublishDialogOpen] = useState(false);
 
73
  const selectionRange = useRef<{ from: number; to: number } | null>(null);
74
  const [hasSelection, setHasSelection] = useState(false);
75
  const [undoManager, setUndoManager] = useState<UndoManager | null>(null);
76
+ const [chatOpen, setChatOpen] = useState(false);
77
 
78
  const agentChat = useAgentChat({ editor: editorInstance, undoManager, frontmatterStore });
79
 
 
97
  setEditorInstance(editor);
98
  }, []);
99
 
 
 
 
 
 
 
 
 
100
  const onCommentStoreReady = useCallback((store: CommentStore) => {
101
  setCommentStore(store);
102
  }, []);
 
213
  <SettingsOutlinedIcon sx={{ fontSize: 18 }} />
214
  </IconButton>
215
  </Tooltip>
 
 
 
 
 
216
  <Box sx={{ width: "1px", height: 16, bgcolor: "divider", mx: 0.5 }} />
217
  <Tooltip title="Publish article" arrow>
218
  <IconButton
 
237
  />
238
  </Box>
239
 
240
+ {/* Single scroll container */}
241
  <Box
242
+ ref={editorContainerCallback}
243
  sx={{
244
  flex: 1,
245
+ overflowY: "auto",
246
+ overflowX: "hidden",
 
 
247
  }}
248
  >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  <Box
 
250
  sx={{
251
+ display: "grid",
252
+ gridTemplateColumns: "200px 1fr 240px",
253
+ gridTemplateRows: "auto 1fr",
254
+ minHeight: "100%",
255
  }}
256
  >
257
+ {/* Hero - center column only, row 1 */}
258
+ <Box sx={{ gridColumn: 2, gridRow: 1, pt: 4 }}>
259
+ <FrontmatterHero store={frontmatterStore} />
260
+ </Box>
261
+
262
+ {/* TOC - left column, row 2, sticky */}
263
+ <Box sx={{ gridColumn: 1, gridRow: 2 }}>
264
+ <Box sx={{ position: "sticky", top: 16 }}>
265
+ <TableOfContents editor={editorInstance} />
266
+ </Box>
267
+ </Box>
268
+
269
+ {/* Editor - center column, row 2 */}
270
+ <Box sx={{ gridColumn: 2, gridRow: 2, pb: 4 }}>
271
+ <Editor
272
+ docName={docName}
273
+ user={user}
274
+ editorRef={editorRef}
275
+ onCommentStoreReady={onCommentStoreReady}
276
+ onFrontmatterStoreReady={onFrontmatterStoreReady}
277
+ onSettingsMapReady={onSettingsMapReady}
278
+ onEditorReady={onEditorReady}
279
+ onUndoManagerReady={setUndoManager}
280
+ onAddComment={handleAddComment}
281
+ />
282
+ </Box>
283
+
284
+ {/* Comments - right column, row 2 */}
285
+ <Box sx={{ gridColumn: 3, gridRow: 2, pl: 2, py: 2 }}>
286
+ <CommentsSidebar
287
+ editor={editorInstance}
288
+ commentStore={commentStore}
289
+ user={user}
290
+ editorContainer={containerEl}
291
+ />
292
+ </Box>
293
  </Box>
294
+ </Box>
295
 
296
+ {/* Floating chat - bottom left */}
297
+ {chatOpen ? (
298
  <Box
299
  sx={{
300
+ position: "fixed",
301
+ bottom: 16,
302
+ left: 16,
303
+ width: 360,
304
+ height: 520,
305
+ maxHeight: "calc(100vh - 80px)",
306
+ borderRadius: 3,
307
+ overflow: "hidden",
308
+ display: "flex",
309
+ flexDirection: "column",
310
+ bgcolor: "background.paper",
311
+ border: "1px solid",
312
+ borderColor: "divider",
313
+ boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
314
+ zIndex: 1300,
315
  }}
316
  >
317
+ <Box
318
+ sx={{
319
+ display: "flex",
320
+ alignItems: "center",
321
+ justifyContent: "space-between",
322
+ px: 1.5,
323
+ py: 0.75,
324
+ borderBottom: "1px solid",
325
+ borderColor: "divider",
326
+ flexShrink: 0,
327
+ }}
328
+ >
329
+ <Typography variant="caption" sx={{ fontWeight: 600, color: "text.secondary", fontSize: "0.7rem" }}>
330
+ AI Assistant
331
+ </Typography>
332
+ <IconButton size="small" onClick={() => setChatOpen(false)} sx={{ color: "text.disabled" }}>
333
+ <CloseIcon sx={{ fontSize: 16 }} />
334
+ </IconButton>
335
+ </Box>
336
+ <Box sx={{ flex: 1, overflow: "hidden", display: "flex" }}>
337
+ <ChatPanel
338
+ messages={agentChat.messages}
339
+ isLoading={agentChat.isLoading}
340
+ error={agentChat.error}
341
+ input={agentChat.input}
342
+ hasSelection={hasSelection}
343
+ onSend={agentChat.sendMessage}
344
+ onQuickAction={agentChat.sendQuickAction}
345
+ onSetInput={agentChat.setInput}
346
+ onStop={agentChat.stop}
347
+ onNewChat={() => window.location.reload()}
348
+ />
349
+ </Box>
350
  </Box>
351
+ ) : (
352
+ <Tooltip title="AI Assistant" placement="right" arrow>
353
+ <IconButton
354
+ onClick={() => setChatOpen(true)}
355
+ sx={{
356
+ position: "fixed",
357
+ bottom: 20,
358
+ left: 20,
359
+ width: 48,
360
+ height: 48,
361
+ bgcolor: "primary.main",
362
+ color: "primary.contrastText",
363
+ boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
364
+ zIndex: 1300,
365
+ "&:hover": { bgcolor: "primary.dark" },
366
+ }}
367
+ >
368
+ <Badge
369
+ color="error"
370
+ variant="dot"
371
+ invisible={!agentChat.isLoading}
372
+ >
373
+ <ChatBubbleOutlineIcon sx={{ fontSize: 22 }} />
374
+ </Badge>
375
+ </IconButton>
376
+ </Tooltip>
377
+ )}
378
 
379
  <CommentDialog
380
  open={commentDialogOpen}
frontend/src/components/TableOfContents.tsx ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
2
+ import { Box, Typography } from "@mui/material";
3
+ import type { Editor } from "@tiptap/core";
4
+
5
+ const SCROLL_OFFSET_PX = 80;
6
+
7
+ interface TocItem {
8
+ id: string;
9
+ text: string;
10
+ level: number;
11
+ pos: number;
12
+ }
13
+
14
+ interface TocNode extends TocItem {
15
+ children: TocNode[];
16
+ }
17
+
18
+ function extractHeadings(editor: Editor): TocItem[] {
19
+ const items: TocItem[] = [];
20
+ editor.state.doc.descendants((node, pos) => {
21
+ if (node.type.name === "heading") {
22
+ const level = node.attrs.level as number;
23
+ const text = node.textContent;
24
+ if (text.trim()) {
25
+ items.push({ id: `heading-${pos}`, text, level, pos });
26
+ }
27
+ }
28
+ });
29
+ return items;
30
+ }
31
+
32
+ function buildTree(headings: TocItem[]): TocNode[] {
33
+ const root: TocNode[] = [];
34
+ const stack: TocNode[] = [];
35
+
36
+ for (const h of headings) {
37
+ const node: TocNode = { ...h, children: [] };
38
+
39
+ while (stack.length > 0 && stack[stack.length - 1].level >= h.level) {
40
+ stack.pop();
41
+ }
42
+
43
+ if (stack.length === 0) {
44
+ root.push(node);
45
+ } else {
46
+ stack[stack.length - 1].children.push(node);
47
+ }
48
+
49
+ stack.push(node);
50
+ }
51
+
52
+ return root;
53
+ }
54
+
55
+ interface TableOfContentsProps {
56
+ editor: Editor | null;
57
+ }
58
+
59
+ export function TableOfContents({ editor }: TableOfContentsProps) {
60
+ const [headings, setHeadings] = useState<TocItem[]>([]);
61
+ const [activePos, setActivePos] = useState<number | null>(null);
62
+ const rafRef = useRef(0);
63
+
64
+ useEffect(() => {
65
+ if (!editor) return;
66
+ const update = () => setHeadings(extractHeadings(editor));
67
+ update();
68
+ editor.on("update", update);
69
+ return () => { editor.off("update", update); };
70
+ }, [editor]);
71
+
72
+ useEffect(() => {
73
+ if (!editor || headings.length === 0) return;
74
+
75
+ const detect = () => {
76
+ const domHeadings = Array.from(
77
+ editor.view.dom.querySelectorAll("h1, h2, h3, h4, h5, h6"),
78
+ ).filter((el) => (el as HTMLElement).textContent?.trim());
79
+
80
+ let activeIdx = -1;
81
+ for (let i = domHeadings.length - 1; i >= 0; i--) {
82
+ if (domHeadings[i].getBoundingClientRect().top <= SCROLL_OFFSET_PX) {
83
+ activeIdx = i;
84
+ break;
85
+ }
86
+ }
87
+ setActivePos(activeIdx >= 0 && activeIdx < headings.length ? headings[activeIdx].pos : null);
88
+ };
89
+
90
+ const onScroll = () => {
91
+ cancelAnimationFrame(rafRef.current);
92
+ rafRef.current = requestAnimationFrame(detect);
93
+ };
94
+
95
+ detect();
96
+ document.addEventListener("scroll", onScroll, { capture: true, passive: true });
97
+ return () => {
98
+ document.removeEventListener("scroll", onScroll, { capture: true });
99
+ cancelAnimationFrame(rafRef.current);
100
+ };
101
+ }, [editor, headings]);
102
+
103
+ const tree = useMemo(() => buildTree(headings), [headings]);
104
+
105
+ const expandedPositions = useMemo(() => {
106
+ const set = new Set<number>();
107
+ if (activePos == null) return set;
108
+
109
+ const walk = (nodes: TocNode[], ancestors: number[]): boolean => {
110
+ for (const node of nodes) {
111
+ if (node.pos === activePos) {
112
+ ancestors.forEach((p) => set.add(p));
113
+ set.add(node.pos);
114
+ return true;
115
+ }
116
+ if (node.children.length > 0 && walk(node.children, [...ancestors, node.pos])) {
117
+ return true;
118
+ }
119
+ }
120
+ return false;
121
+ };
122
+ walk(tree, []);
123
+ return set;
124
+ }, [tree, activePos]);
125
+
126
+ const scrollTo = useCallback((pos: number) => {
127
+ if (!editor) return;
128
+ editor.chain().focus().setTextSelection(pos).run();
129
+ const dom = editor.view.domAtPos(pos);
130
+ const el = dom.node instanceof HTMLElement ? dom.node : dom.node.parentElement;
131
+ el?.scrollIntoView({ behavior: "smooth", block: "start" });
132
+ }, [editor]);
133
+
134
+ if (!editor || headings.length === 0) {
135
+ return (
136
+ <Box sx={{ px: 2, pt: 3 }}>
137
+ <Typography variant="caption" sx={{ color: "text.disabled", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem" }}>
138
+ Table of contents
139
+ </Typography>
140
+ <Typography variant="caption" sx={{ color: "text.disabled", display: "block", mt: 1, fontSize: "0.7rem" }}>
141
+ Headings will appear here as you write.
142
+ </Typography>
143
+ </Box>
144
+ );
145
+ }
146
+
147
+ const renderList = (nodes: TocNode[], isRoot: boolean) => (
148
+ <Box
149
+ component="ul"
150
+ sx={{
151
+ m: 0,
152
+ mb: isRoot ? 0 : "6px",
153
+ pl: isRoot ? 0 : "1em",
154
+ listStyle: "none",
155
+ }}
156
+ >
157
+ {nodes.map((node) => {
158
+ const isExpanded = expandedPositions.has(node.pos);
159
+ const hasChildren = node.children.length > 0;
160
+
161
+ return (
162
+ <Box component="li" key={node.id} sx={{ my: "0.25em" }}>
163
+ <Box
164
+ component="a"
165
+ onClick={(e: React.MouseEvent) => {
166
+ e.preventDefault();
167
+ scrollTo(node.pos);
168
+ }}
169
+ sx={{
170
+ cursor: "pointer",
171
+ fontSize: "13px",
172
+ lineHeight: 1.5,
173
+ fontWeight: isRoot ? 700 : 400,
174
+ color: "text.secondary",
175
+ textDecoration: activePos === node.pos ? "underline" : "none",
176
+ textUnderlineOffset: "3px",
177
+ transition: "color 120ms ease",
178
+ "&:hover": {
179
+ textDecoration: "underline solid",
180
+ textDecorationColor: "text.disabled",
181
+ },
182
+ }}
183
+ >
184
+ {node.text}
185
+ </Box>
186
+ {hasChildren && (
187
+ <Box
188
+ sx={{
189
+ overflow: "hidden",
190
+ maxHeight: isExpanded ? 500 : 0,
191
+ opacity: isExpanded ? 1 : 0,
192
+ transition: "max-height 200ms ease, opacity 200ms ease",
193
+ }}
194
+ >
195
+ {renderList(node.children, false)}
196
+ </Box>
197
+ )}
198
+ </Box>
199
+ );
200
+ })}
201
+ </Box>
202
+ );
203
+
204
+ return (
205
+ <Box sx={{ px: 1, pt: 3 }}>
206
+ <Typography
207
+ variant="caption"
208
+ sx={{
209
+ color: "text.disabled",
210
+ fontWeight: 600,
211
+ textTransform: "uppercase",
212
+ letterSpacing: "0.05em",
213
+ fontSize: "0.65rem",
214
+ pl: "16px",
215
+ mb: 1,
216
+ display: "block",
217
+ }}
218
+ >
219
+ Table of contents
220
+ </Typography>
221
+
222
+ <Box
223
+ component="nav"
224
+ sx={{
225
+ borderLeft: "1px solid",
226
+ borderColor: "divider",
227
+ pl: "16px",
228
+ fontSize: "13px",
229
+ }}
230
+ >
231
+ {renderList(tree, true)}
232
+ </Box>
233
+ </Box>
234
+ );
235
+ }
frontend/src/editor/Editor.tsx CHANGED
@@ -33,13 +33,10 @@ import { CitationPanel } from "./CitationPanel";
33
  import { createCommentStore, CommentStore } from "./comments";
34
  import { createFrontmatterStore, FrontmatterStore } from "./frontmatter/frontmatter-store";
35
  import { seedFrontmatter } from "./frontmatter/seed-frontmatter";
36
- import { COMPONENTS, createWrapperExtension, createAtomicExtension, registerComponentSerializers } from "./components";
37
  import { uploadImage } from "./upload";
38
  import { DEFAULT_CONTENT, SEED_CITATIONS } from "./default-content";
39
 
40
- // Auto-register MDX serializers for all custom components (once)
41
- registerComponentSerializers();
42
-
43
  const lowlight = createLowlight(all);
44
 
45
  interface EditorProps {
 
33
  import { createCommentStore, CommentStore } from "./comments";
34
  import { createFrontmatterStore, FrontmatterStore } from "./frontmatter/frontmatter-store";
35
  import { seedFrontmatter } from "./frontmatter/seed-frontmatter";
36
+ import { COMPONENTS, createWrapperExtension, createAtomicExtension } from "./components";
37
  import { uploadImage } from "./upload";
38
  import { DEFAULT_CONTENT, SEED_CITATIONS } from "./default-content";
39
 
 
 
 
40
  const lowlight = createLowlight(all);
41
 
42
  interface EditorProps {
frontend/src/editor/components/index.ts CHANGED
@@ -1,5 +1,4 @@
1
  export { COMPONENTS } from "./registry";
2
  export type { ComponentDef, ComponentField } from "./registry";
3
  export { createWrapperExtension, createAtomicExtension } from "./factory";
4
- export { registerComponentSerializers } from "./serializers";
5
  export { getComponentSlashItems } from "./slash-items";
 
1
  export { COMPONENTS } from "./registry";
2
  export type { ComponentDef, ComponentField } from "./registry";
3
  export { createWrapperExtension, createAtomicExtension } from "./factory";
 
4
  export { getComponentSlashItems } from "./slash-items";
frontend/src/editor/components/serializers.ts DELETED
@@ -1,41 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // Auto-register MDX serializers for every component in the registry.
3
- //
4
- // Call `registerComponentSerializers()` once at startup (side-effect import).
5
- // ---------------------------------------------------------------------------
6
-
7
- import { registerSerializer, serializeNode } from "../../export/serializers/index";
8
- import { COMPONENTS } from "./registry";
9
- import type { ComponentDef } from "./registry";
10
-
11
- function serializeAttrs(def: ComponentDef, attrs: Record<string, unknown>): string {
12
- const parts: string[] = [];
13
-
14
- for (const f of def.fields) {
15
- const val = attrs[f.name];
16
- if (val === undefined || val === null || val === f.default) continue;
17
-
18
- if (f.type === "boolean") {
19
- if (val) parts.push(f.name);
20
- } else {
21
- parts.push(`${f.name}="${String(val)}"`);
22
- }
23
- }
24
-
25
- return parts.length ? ` ${parts.join(" ")}` : "";
26
- }
27
-
28
- export function registerComponentSerializers() {
29
- for (const def of COMPONENTS) {
30
- registerSerializer(def.name, (node) => {
31
- const attrStr = serializeAttrs(def, node.attrs ?? {});
32
-
33
- if (def.kind === "atomic") {
34
- return `<${def.tag}${attrStr} />`;
35
- }
36
-
37
- const inner = node.content?.map(serializeNode).join("\n\n") ?? "";
38
- return `<${def.tag}${attrStr}>\n${inner}\n</${def.tag}>`;
39
- });
40
- }
41
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/editor/frontmatter/FrontmatterHero.tsx CHANGED
@@ -1,11 +1,11 @@
1
  import { useState, useRef, useEffect, type KeyboardEvent } from "react";
2
  import {
3
  Box,
4
- Typography,
5
  Chip,
6
  IconButton,
7
- Tooltip,
8
  TextField,
 
 
9
  } from "@mui/material";
10
  import AddIcon from "@mui/icons-material/Add";
11
  import CloseIcon from "@mui/icons-material/Close";
@@ -22,143 +22,233 @@ export function FrontmatterHero({ store }: FrontmatterHeroProps) {
22
  const [showAuthorForm, setShowAuthorForm] = useState(false);
23
 
24
  if (!data) {
25
- return <Box sx={{ maxWidth: 680, mx: "auto", mb: 4, px: 2 }} />;
26
  }
27
 
28
- const hasContent = data.title || data.subtitle || data.authors.length > 0;
 
 
 
29
 
30
  return (
31
- <Box sx={{ maxWidth: 680, mx: "auto", mb: 4, px: 2 }}>
32
- {/* Title */}
33
- <EditableText
34
- value={data.title}
35
- placeholder="Article title"
36
- onChange={(v) => update("title", v)}
37
- typographySx={{
38
- fontSize: "2.2rem",
39
- fontWeight: 700,
40
- lineHeight: 1.2,
41
- letterSpacing: "-0.02em",
42
- color: "text.primary",
43
- mb: 0.5,
44
- }}
45
- multiline
46
- />
47
-
48
- {/* Subtitle */}
49
- <EditableText
50
- value={data.subtitle}
51
- placeholder="Subtitle (optional)"
52
- onChange={(v) => update("subtitle", v)}
53
- typographySx={{
54
- fontSize: "1.15rem",
55
- fontWeight: 400,
56
- lineHeight: 1.5,
57
- color: "text.secondary",
58
- mb: 2.5,
59
- }}
60
- />
61
 
62
- {/* Authors + Affiliations */}
63
- <Box sx={{ mb: 2 }}>
64
- <Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 0.75 }}>
65
- {data.authors.map((author, i) => (
66
- <AuthorChip
67
- key={`author-${i}`}
68
- author={author}
69
- affiliations={data.affiliations}
70
- onEdit={() => setEditingAuthorIdx(i)}
71
- onRemove={() => store?.removeAuthor(i)}
72
- />
73
- ))}
74
- <Tooltip title="Add author" arrow>
75
- <IconButton
76
- size="small"
77
- onClick={() => setShowAuthorForm(true)}
78
- sx={{ color: "text.disabled", border: "1px dashed", borderColor: "divider", borderRadius: 1, p: 0.5 }}
79
- >
80
- <AddIcon sx={{ fontSize: 14 }} />
81
- </IconButton>
82
- </Tooltip>
83
  </Box>
 
84
 
85
- {/* Affiliation footnotes */}
86
- {data.affiliations.length > 0 && (
87
- <Box sx={{ color: "text.disabled", mt: 0.75, fontSize: "0.7rem" }}>
88
- {data.affiliations.map((aff, i) => (
89
- <span key={`aff-${i}`}>
90
- <sup>{i + 1}</sup>{" "}
91
- {aff.url ? (
92
- <a href={aff.url} style={{ color: "inherit", textDecoration: "underline" }} target="_blank" rel="noopener noreferrer">
93
- {aff.name}
94
- </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  ) : (
96
- aff.name
 
 
 
 
 
 
 
 
97
  )}
98
- {i < data.affiliations.length - 1 ? " " : ""}
99
- </span>
100
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  )}
103
- </Box>
104
 
105
- {/* Meta line: date + DOI */}
106
- <Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
107
- <EditableText
108
- value={data.published}
109
- placeholder="Publication date"
110
- onChange={(v) => update("published", v)}
111
- typographySx={{ fontSize: "0.8rem", color: "text.disabled" }}
112
- inline
113
- />
114
- {data.published && data.doi && (
115
- <Box component="span" sx={{ color: "text.disabled", fontSize: "0.8rem" }}>-</Box>
 
 
 
116
  )}
117
- <EditableText
118
- value={data.doi}
119
- placeholder="DOI"
120
- onChange={(v) => update("doi", v)}
121
- typographySx={{ fontSize: "0.8rem", color: "text.disabled", fontFamily: "monospace" }}
122
- inline
123
- />
124
  </Box>
 
 
 
125
 
126
- {/* Separator */}
127
- {hasContent && (
128
- <Box sx={{ borderBottom: "1px solid", borderColor: "divider", mt: 3, mb: 1 }} />
129
- )}
130
-
131
- {/* Author forms */}
132
- {showAuthorForm && (
133
- <AuthorInlineForm
134
- affiliations={data.affiliations}
135
- onSubmit={(author, newAff) => {
136
- if (newAff) {
137
- const idx = store!.addAffiliation(newAff);
138
- author.affiliations = [...author.affiliations, idx];
139
- }
140
- store?.addAuthor(author);
141
- setShowAuthorForm(false);
142
- }}
143
- onCancel={() => setShowAuthorForm(false)}
144
- />
145
- )}
146
 
147
- {editingAuthorIdx !== null && data.authors[editingAuthorIdx] && (
148
- <AuthorInlineForm
149
- initial={data.authors[editingAuthorIdx]}
150
- affiliations={data.affiliations}
151
- onSubmit={(author, newAff) => {
152
- if (newAff) {
153
- const idx = store!.addAffiliation(newAff);
154
- author.affiliations = [...author.affiliations, idx];
155
- }
156
- store?.updateAuthor(editingAuthorIdx, author);
157
- setEditingAuthorIdx(null);
158
- }}
159
- onCancel={() => setEditingAuthorIdx(null)}
160
- />
161
- )}
 
 
162
  </Box>
163
  );
164
  }
@@ -172,9 +262,10 @@ interface EditableTextProps {
172
  typographySx?: Record<string, any>;
173
  multiline?: boolean;
174
  inline?: boolean;
 
175
  }
176
 
177
- function EditableText({ value, placeholder, onChange, typographySx = {}, multiline, inline }: EditableTextProps) {
178
  const [editing, setEditing] = useState(false);
179
  const [draft, setDraft] = useState(value);
180
  const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
@@ -211,6 +302,7 @@ function EditableText({ value, placeholder, onChange, typographySx = {}, multili
211
 
212
  const displayValue = value || placeholder;
213
  const isEmpty = !value;
 
214
 
215
  if (editing) {
216
  return (
@@ -230,6 +322,7 @@ function EditableText({ value, placeholder, onChange, typographySx = {}, multili
230
  disableUnderline: true,
231
  sx: {
232
  ...typographySx,
 
233
  p: 0,
234
  m: 0,
235
  },
@@ -245,6 +338,7 @@ function EditableText({ value, placeholder, onChange, typographySx = {}, multili
245
  onClick={() => setEditing(true)}
246
  sx={{
247
  ...typographySx,
 
248
  cursor: "text",
249
  opacity: isEmpty ? 0.35 : 1,
250
  borderRadius: 1,
@@ -262,45 +356,6 @@ function EditableText({ value, placeholder, onChange, typographySx = {}, multili
262
  );
263
  }
264
 
265
- // ---- Author chip ----
266
-
267
- function AuthorChip({
268
- author,
269
- affiliations,
270
- onEdit,
271
- onRemove,
272
- }: {
273
- author: Author;
274
- affiliations: Affiliation[];
275
- onEdit: () => void;
276
- onRemove: () => void;
277
- }) {
278
- const affIndices = author.affiliations || [];
279
-
280
- return (
281
- <Chip
282
- label={
283
- affIndices.length > 0
284
- ? `${author.name} (${affIndices.join(",")})`
285
- : author.name
286
- }
287
- size="small"
288
- onClick={onEdit}
289
- onDelete={onRemove}
290
- deleteIcon={<CloseIcon sx={{ fontSize: 12 }} />}
291
- sx={{
292
- fontSize: "0.8rem",
293
- height: 26,
294
- bgcolor: "rgba(255,255,255,0.06)",
295
- border: "1px solid",
296
- borderColor: "divider",
297
- cursor: "pointer",
298
- "&:hover": { borderColor: "text.disabled" },
299
- }}
300
- />
301
- );
302
- }
303
-
304
  // ---- Author inline form ----
305
 
306
  function AuthorInlineForm({
 
1
  import { useState, useRef, useEffect, type KeyboardEvent } from "react";
2
  import {
3
  Box,
 
4
  Chip,
5
  IconButton,
 
6
  TextField,
7
+ Tooltip,
8
+ Typography,
9
  } from "@mui/material";
10
  import AddIcon from "@mui/icons-material/Add";
11
  import CloseIcon from "@mui/icons-material/Close";
 
22
  const [showAuthorForm, setShowAuthorForm] = useState(false);
23
 
24
  if (!data) {
25
+ return <Box sx={{ width: "100%", mb: 4 }} />;
26
  }
27
 
28
+ const hasAuthors = data.authors.length > 0;
29
+ const hasAffiliations = data.affiliations.length > 0;
30
+ const multipleAffiliations = data.affiliations.length > 1;
31
+ const hasMeta = hasAuthors || data.published || data.doi;
32
 
33
  return (
34
+ <Box sx={{ width: "100%", mb: 2 }}>
35
+ {/* Hero section - centered title + subtitle */}
36
+ <Box sx={{ textAlign: "center", pt: 4, pb: 2, px: 2 }}>
37
+ <Box sx={{ maxWidth: 780, mx: "auto" }}>
38
+ <EditableText
39
+ value={data.title}
40
+ placeholder="Article title"
41
+ onChange={(v) => update("title", v)}
42
+ typographySx={{
43
+ fontSize: "clamp(34px, 5vw, 54px)",
44
+ fontWeight: 800,
45
+ lineHeight: 1.12,
46
+ letterSpacing: "-0.02em",
47
+ color: "text.primary",
48
+ mb: 1,
49
+ textWrap: "balance",
50
+ }}
51
+ multiline
52
+ center
53
+ />
54
+ </Box>
 
 
 
 
 
 
 
 
 
55
 
56
+ <Box sx={{ maxWidth: "55%", mx: "auto" }}>
57
+ <EditableText
58
+ value={data.subtitle}
59
+ placeholder="Subtitle (optional)"
60
+ onChange={(v) => update("subtitle", v)}
61
+ typographySx={{
62
+ fontSize: "1.15em",
63
+ fontWeight: 400,
64
+ lineHeight: 1.6,
65
+ color: "text.secondary",
66
+ fontStyle: "italic",
67
+ }}
68
+ center
69
+ />
 
 
 
 
 
 
 
70
  </Box>
71
+ </Box>
72
 
73
+ {/* Meta bar - matches published template */}
74
+ {hasMeta && (
75
+ <Box
76
+ sx={{
77
+ borderTop: "1px solid",
78
+ borderBottom: "1px solid",
79
+ borderColor: "divider",
80
+ py: 2,
81
+ fontSize: "0.9rem",
82
+ }}
83
+ >
84
+ <Box
85
+ sx={{
86
+ maxWidth: 980,
87
+ mx: "auto",
88
+ px: 2,
89
+ display: "flex",
90
+ flexDirection: "row",
91
+ justifyContent: "space-between",
92
+ gap: 1,
93
+ flexWrap: "wrap",
94
+ rowGap: 1.5,
95
+ }}
96
+ >
97
+ {/* Authors cell */}
98
+ <MetaCell label="Authors">
99
+ <Box sx={{ display: "flex", flexWrap: "wrap", m: 0, p: 0, listStyle: "none" }} component="ul">
100
+ {data.authors.map((author, i) => (
101
+ <Box component="li" key={`author-${i}`} sx={{ whiteSpace: "nowrap", p: 0 }}>
102
+ <Box
103
+ component="span"
104
+ onClick={() => setEditingAuthorIdx(i)}
105
+ sx={{
106
+ cursor: "pointer",
107
+ borderRadius: 0.5,
108
+ transition: "background 0.15s",
109
+ "&:hover": { bgcolor: "rgba(255,255,255,0.06)" },
110
+ }}
111
+ >
112
+ {author.url ? (
113
+ <Box component="span" sx={{ color: "primary.main", textDecoration: "underline", textUnderlineOffset: "2px", textDecorationThickness: "0.06em" }}>
114
+ {author.name}
115
+ </Box>
116
+ ) : (
117
+ author.name
118
+ )}
119
+ {multipleAffiliations && author.affiliations?.length > 0 && (
120
+ <sup>{author.affiliations.join(",")}</sup>
121
+ )}
122
+ </Box>
123
+ {i < data.authors.length - 1 && <>,&nbsp;</>}
124
+ </Box>
125
+ ))}
126
+ <Box component="li" sx={{ p: 0, display: "inline-flex", alignItems: "center", ml: 0.5 }}>
127
+ <Tooltip title="Add author" arrow>
128
+ <IconButton
129
+ size="small"
130
+ onClick={() => setShowAuthorForm(true)}
131
+ sx={{ color: "text.disabled", p: 0.25 }}
132
+ >
133
+ <AddIcon sx={{ fontSize: 14 }} />
134
+ </IconButton>
135
+ </Tooltip>
136
+ </Box>
137
+ </Box>
138
+ </MetaCell>
139
+
140
+ {/* Affiliations cell */}
141
+ {hasAffiliations && (
142
+ <MetaCell label="Affiliations">
143
+ {multipleAffiliations ? (
144
+ <Box component="ol" sx={{ m: 0, pl: "1.25em" }}>
145
+ {data.affiliations.map((aff, i) => (
146
+ <Box component="li" key={`aff-${i}`} sx={{ m: 0 }}>
147
+ {aff.url ? (
148
+ <a href={aff.url} target="_blank" rel="noopener noreferrer" style={{ color: "inherit" }}>
149
+ {aff.name}
150
+ </a>
151
+ ) : (
152
+ aff.name
153
+ )}
154
+ </Box>
155
+ ))}
156
+ </Box>
157
  ) : (
158
+ <Box>
159
+ {data.affiliations[0].url ? (
160
+ <a href={data.affiliations[0].url} target="_blank" rel="noopener noreferrer" style={{ color: "inherit" }}>
161
+ {data.affiliations[0].name}
162
+ </a>
163
+ ) : (
164
+ data.affiliations[0].name
165
+ )}
166
+ </Box>
167
  )}
168
+ </MetaCell>
169
+ )}
170
+
171
+ {/* Published cell */}
172
+ <MetaCell label="Published">
173
+ <EditableText
174
+ value={data.published}
175
+ placeholder="Date"
176
+ onChange={(v) => update("published", v)}
177
+ typographySx={{ fontSize: "0.9rem", color: "text.primary" }}
178
+ inline
179
+ />
180
+ </MetaCell>
181
+
182
+ {/* DOI cell */}
183
+ <MetaCell label="DOI">
184
+ <EditableText
185
+ value={data.doi}
186
+ placeholder="10.xxxx/xxxxx"
187
+ onChange={(v) => update("doi", v)}
188
+ typographySx={{ fontSize: "0.9rem", color: "text.primary", fontFamily: "monospace" }}
189
+ inline
190
+ />
191
+ </MetaCell>
192
  </Box>
193
+ </Box>
194
+ )}
195
+
196
+ {/* Author forms */}
197
+ <Box sx={{ maxWidth: 680, mx: "auto", px: 2 }}>
198
+ {showAuthorForm && (
199
+ <AuthorInlineForm
200
+ affiliations={data.affiliations}
201
+ onSubmit={(author, newAff) => {
202
+ if (newAff) {
203
+ const idx = store!.addAffiliation(newAff);
204
+ author.affiliations = [...author.affiliations, idx];
205
+ }
206
+ store?.addAuthor(author);
207
+ setShowAuthorForm(false);
208
+ }}
209
+ onCancel={() => setShowAuthorForm(false)}
210
+ />
211
  )}
 
212
 
213
+ {editingAuthorIdx !== null && data.authors[editingAuthorIdx] && (
214
+ <AuthorInlineForm
215
+ initial={data.authors[editingAuthorIdx]}
216
+ affiliations={data.affiliations}
217
+ onSubmit={(author, newAff) => {
218
+ if (newAff) {
219
+ const idx = store!.addAffiliation(newAff);
220
+ author.affiliations = [...author.affiliations, idx];
221
+ }
222
+ store?.updateAuthor(editingAuthorIdx, author);
223
+ setEditingAuthorIdx(null);
224
+ }}
225
+ onCancel={() => setEditingAuthorIdx(null)}
226
+ />
227
  )}
 
 
 
 
 
 
 
228
  </Box>
229
+ </Box>
230
+ );
231
+ }
232
 
233
+ // ---- Meta cell (labeled column in the meta bar) ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
+ function MetaCell({ label, children }: { label: string; children: React.ReactNode }) {
236
+ return (
237
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 1, maxWidth: 400 }}>
238
+ <Typography
239
+ component="h3"
240
+ sx={{
241
+ m: 0,
242
+ fontSize: "12px",
243
+ fontWeight: 400,
244
+ color: "text.disabled",
245
+ textTransform: "uppercase",
246
+ letterSpacing: "0.02em",
247
+ }}
248
+ >
249
+ {label}
250
+ </Typography>
251
+ <Box sx={{ m: 0 }}>{children}</Box>
252
  </Box>
253
  );
254
  }
 
262
  typographySx?: Record<string, any>;
263
  multiline?: boolean;
264
  inline?: boolean;
265
+ center?: boolean;
266
  }
267
 
268
+ function EditableText({ value, placeholder, onChange, typographySx = {}, multiline, inline, center }: EditableTextProps) {
269
  const [editing, setEditing] = useState(false);
270
  const [draft, setDraft] = useState(value);
271
  const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
 
302
 
303
  const displayValue = value || placeholder;
304
  const isEmpty = !value;
305
+ const textAlign = center ? "center" : undefined;
306
 
307
  if (editing) {
308
  return (
 
322
  disableUnderline: true,
323
  sx: {
324
  ...typographySx,
325
+ textAlign,
326
  p: 0,
327
  m: 0,
328
  },
 
338
  onClick={() => setEditing(true)}
339
  sx={{
340
  ...typographySx,
341
+ textAlign,
342
  cursor: "text",
343
  opacity: isEmpty ? 0.35 : 1,
344
  borderRadius: 1,
 
356
  );
357
  }
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  // ---- Author inline form ----
360
 
361
  function AuthorInlineForm({
frontend/src/editor/frontmatter/SettingsDrawer.tsx CHANGED
@@ -9,12 +9,8 @@ import {
9
  Switch,
10
  Divider,
11
  IconButton,
12
- Accordion,
13
- AccordionSummary,
14
- AccordionDetails,
15
  } from "@mui/material";
16
  import CloseIcon from "@mui/icons-material/Close";
17
- import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
18
  import { useState, useEffect } from "react";
19
  import { useFrontmatter } from "./useFrontmatter";
20
  import type { FrontmatterStore, FrontmatterData } from "./frontmatter-store";
@@ -49,17 +45,19 @@ export function SettingsDrawer({ open, onClose, store, settingsMap }: SettingsDr
49
  anchor="right"
50
  open={open}
51
  onClose={onClose}
52
- PaperProps={{
53
- sx: {
54
- width: 320,
55
- bgcolor: "background.paper",
56
- backgroundImage: "none",
57
- borderLeft: "1px solid",
58
- borderColor: "divider",
 
 
59
  },
60
  }}
61
  >
62
- <Box sx={{ p: 2, display: "flex", flexDirection: "column", height: "100%" }}>
63
  {/* Header */}
64
  <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 2 }}>
65
  <Typography variant="subtitle2" sx={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "text.secondary", fontSize: "0.75rem" }}>
@@ -133,6 +131,7 @@ export function SettingsDrawer({ open, onClose, store, settingsMap }: SettingsDr
133
 
134
  {/* Toggles */}
135
  <FormControlLabel
 
136
  control={
137
  <Switch
138
  size="small"
@@ -144,6 +143,7 @@ export function SettingsDrawer({ open, onClose, store, settingsMap }: SettingsDr
144
  />
145
 
146
  <FormControlLabel
 
147
  control={
148
  <Switch
149
  size="small"
@@ -154,58 +154,42 @@ export function SettingsDrawer({ open, onClose, store, settingsMap }: SettingsDr
154
  label={<Typography variant="body2" sx={{ fontSize: "0.85rem" }}>Auto-collapse TOC</Typography>}
155
  />
156
 
157
- {/* Advanced */}
158
- <Accordion
159
- disableGutters
160
- elevation={0}
161
- sx={{
162
- bgcolor: "transparent",
163
- "&:before": { display: "none" },
164
- border: "1px solid",
165
- borderColor: "divider",
166
- borderRadius: "8px !important",
167
- }}
168
- >
169
- <AccordionSummary expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}>
170
- <Typography variant="caption" sx={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "text.secondary" }}>
171
- Advanced
172
- </Typography>
173
- </AccordionSummary>
174
- <AccordionDetails sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
175
- <FieldGroup label="Licence">
176
- <TextField
177
- size="small"
178
- fullWidth
179
- multiline
180
- minRows={2}
181
- placeholder="e.g. CC BY 4.0 (HTML allowed)"
182
- value={data.licence}
183
- onChange={(e) => update("licence", e.target.value)}
184
- />
185
- </FieldGroup>
186
-
187
- <FieldGroup label="SEO thumbnail image">
188
- <TextField
189
- size="small"
190
- fullWidth
191
- placeholder="https://..."
192
- value={data.seoThumbImage}
193
- onChange={(e) => update("seoThumbImage", e.target.value)}
194
- />
195
- </FieldGroup>
196
-
197
- <FormControlLabel
198
- control={
199
- <Switch
200
- size="small"
201
- checked={data.pdfProOnly}
202
- onChange={(_, v) => update("pdfProOnly", v)}
203
- />
204
- }
205
- label={<Typography variant="body2" sx={{ fontSize: "0.85rem" }}>PDF Pro only</Typography>}
206
  />
207
- </AccordionDetails>
208
- </Accordion>
 
209
  </Box>
210
  </Box>
211
  </Drawer>
 
9
  Switch,
10
  Divider,
11
  IconButton,
 
 
 
12
  } from "@mui/material";
13
  import CloseIcon from "@mui/icons-material/Close";
 
14
  import { useState, useEffect } from "react";
15
  import { useFrontmatter } from "./useFrontmatter";
16
  import type { FrontmatterStore, FrontmatterData } from "./frontmatter-store";
 
45
  anchor="right"
46
  open={open}
47
  onClose={onClose}
48
+ slotProps={{
49
+ paper: {
50
+ sx: {
51
+ width: 380,
52
+ bgcolor: "background.paper",
53
+ backgroundImage: "none",
54
+ borderLeft: "1px solid",
55
+ borderColor: "divider",
56
+ },
57
  },
58
  }}
59
  >
60
+ <Box sx={{ px: 2.5, py: 2, display: "flex", flexDirection: "column", height: "100%" }}>
61
  {/* Header */}
62
  <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 2 }}>
63
  <Typography variant="subtitle2" sx={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "text.secondary", fontSize: "0.75rem" }}>
 
131
 
132
  {/* Toggles */}
133
  <FormControlLabel
134
+ sx={{ ml: 0 }}
135
  control={
136
  <Switch
137
  size="small"
 
143
  />
144
 
145
  <FormControlLabel
146
+ sx={{ ml: 0 }}
147
  control={
148
  <Switch
149
  size="small"
 
154
  label={<Typography variant="body2" sx={{ fontSize: "0.85rem" }}>Auto-collapse TOC</Typography>}
155
  />
156
 
157
+ <Divider />
158
+
159
+ {/* Licence / SEO / PDF Pro */}
160
+ <FieldGroup label="Licence">
161
+ <TextField
162
+ size="small"
163
+ fullWidth
164
+ multiline
165
+ minRows={2}
166
+ placeholder="e.g. CC BY 4.0 (HTML allowed)"
167
+ value={data.licence}
168
+ onChange={(e) => update("licence", e.target.value)}
169
+ />
170
+ </FieldGroup>
171
+
172
+ <FieldGroup label="SEO thumbnail image">
173
+ <TextField
174
+ size="small"
175
+ fullWidth
176
+ placeholder="https://..."
177
+ value={data.seoThumbImage}
178
+ onChange={(e) => update("seoThumbImage", e.target.value)}
179
+ />
180
+ </FieldGroup>
181
+
182
+ <FormControlLabel
183
+ sx={{ ml: 0 }}
184
+ control={
185
+ <Switch
186
+ size="small"
187
+ checked={data.pdfProOnly}
188
+ onChange={(_, v) => update("pdfProOnly", v)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  />
190
+ }
191
+ label={<Typography variant="body2" sx={{ fontSize: "0.85rem" }}>PDF Pro only</Typography>}
192
+ />
193
  </Box>
194
  </Box>
195
  </Drawer>
frontend/src/editor/frontmatter/seed-frontmatter.ts CHANGED
@@ -12,7 +12,7 @@ export function seedFrontmatter(store: FrontmatterStore) {
12
  subtitle: "How model size, data, and compute interact",
13
  description: "An empirical study of scaling behavior across model size, data, and compute budgets.",
14
  published: "Apr. 13, 2026",
15
- doi: "10.1234/collab-editor.2026",
16
  template: "article",
17
  });
18
 
 
12
  subtitle: "How model size, data, and compute interact",
13
  description: "An empirical study of scaling behavior across model size, data, and compute budgets.",
14
  published: "Apr. 13, 2026",
15
+ doi: "10.1234/science-article-template-editor.2026",
16
  template: "article",
17
  });
18
 
frontend/src/export/serializers/index.ts DELETED
@@ -1,133 +0,0 @@
1
- import type { JSONContent } from "@tiptap/core";
2
-
3
- type Serializer = (node: JSONContent) => string;
4
-
5
- const serializers: Record<string, Serializer> = {};
6
-
7
- export function registerSerializer(type: string, fn: Serializer) {
8
- serializers[type] = fn;
9
- }
10
-
11
- export function serializeNode(node: JSONContent): string {
12
- const fn = serializers[node.type!];
13
- if (fn) return fn(node);
14
- if (node.text) return serializeMarks(node);
15
- if (node.content) return node.content.map(serializeNode).join("");
16
- return "";
17
- }
18
-
19
- function serializeMarks(node: JSONContent): string {
20
- let text = node.text || "";
21
- if (!node.marks) return text;
22
-
23
- for (const mark of node.marks) {
24
- switch (mark.type) {
25
- case "bold":
26
- text = `**${text}**`;
27
- break;
28
- case "italic":
29
- text = `*${text}*`;
30
- break;
31
- case "strike":
32
- text = `~~${text}~~`;
33
- break;
34
- case "code":
35
- text = `\`${text}\``;
36
- break;
37
- case "link":
38
- text = `[${text}](${mark.attrs?.href || ""})`;
39
- break;
40
- }
41
- }
42
-
43
- return text;
44
- }
45
-
46
- function inlineContent(node: JSONContent): string {
47
- if (!node.content) return "";
48
- return node.content.map(serializeNode).join("");
49
- }
50
-
51
- // --- Built-in serializers ---
52
-
53
- registerSerializer("paragraph", (node) => inlineContent(node));
54
-
55
- registerSerializer("heading", (node) => {
56
- const level = node.attrs?.level || 1;
57
- const prefix = "#".repeat(level);
58
- return `${prefix} ${inlineContent(node)}`;
59
- });
60
-
61
- registerSerializer("bulletList", (node) => {
62
- if (!node.content) return "";
63
- return node.content
64
- .map((item) => {
65
- const inner = item.content?.map(serializeNode).join("\n") || "";
66
- return `- ${inner}`;
67
- })
68
- .join("\n");
69
- });
70
-
71
- registerSerializer("orderedList", (node) => {
72
- if (!node.content) return "";
73
- return node.content
74
- .map((item, i) => {
75
- const inner = item.content?.map(serializeNode).join("\n") || "";
76
- return `${i + 1}. ${inner}`;
77
- })
78
- .join("\n");
79
- });
80
-
81
- registerSerializer("blockquote", (node) => {
82
- if (!node.content) return "";
83
- const inner = node.content.map(serializeNode).join("\n");
84
- return inner
85
- .split("\n")
86
- .map((line) => `> ${line}`)
87
- .join("\n");
88
- });
89
-
90
- registerSerializer("codeBlock", (node) => {
91
- const lang = node.attrs?.language || "";
92
- const code = inlineContent(node);
93
- return `\`\`\`${lang}\n${code}\n\`\`\``;
94
- });
95
-
96
- registerSerializer("horizontalRule", () => "---");
97
-
98
- registerSerializer("hardBreak", () => " \n");
99
-
100
- registerSerializer("listItem", (node) => {
101
- if (!node.content) return "";
102
- return node.content.map(serializeNode).join("\n");
103
- });
104
-
105
- // --- Inline atoms ---
106
-
107
- registerSerializer("glossary", (node) => {
108
- const term = node.attrs?.term || "";
109
- const definition = node.attrs?.definition || "";
110
- return `<Glossary term="${term}" definition="${definition}">${term}</Glossary>`;
111
- });
112
-
113
- registerSerializer("footnote", (node) => {
114
- return `[^fn]: ${node.attrs?.content || ""}`;
115
- });
116
-
117
- // --- Stack (multi-column) ---
118
-
119
- registerSerializer("stack", (node) => {
120
- const layout = node.attrs?.layout || "2-column";
121
- const gap = node.attrs?.gap || "medium";
122
- const attrs: string[] = [];
123
- if (layout !== "2-column") attrs.push(`layout="${layout}"`);
124
- if (gap !== "medium") attrs.push(`gap="${gap}"`);
125
- const attrStr = attrs.length ? ` ${attrs.join(" ")}` : "";
126
- const inner = node.content?.map(serializeNode).join("\n\n") ?? "";
127
- return `<Stack${attrStr}>\n${inner}\n</Stack>`;
128
- });
129
-
130
- registerSerializer("stackColumn", (node) => {
131
- if (!node.content) return "";
132
- return node.content.map(serializeNode).join("\n\n");
133
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/export/to-mdx.ts DELETED
@@ -1,70 +0,0 @@
1
- import type { JSONContent } from "@tiptap/core";
2
- import { serializeNode } from "./serializers/index";
3
- import type { FrontmatterData } from "../editor/frontmatter/frontmatter-store";
4
-
5
- export function toMdx(doc: JSONContent, frontmatter?: FrontmatterData): string {
6
- const body = doc.content
7
- ? doc.content.map((node) => serializeNode(node)).join("\n\n")
8
- : "";
9
-
10
- if (!frontmatter) return body;
11
-
12
- const yaml = serializeFrontmatter(frontmatter);
13
- if (!yaml) return body;
14
-
15
- return `---\n${yaml}---\n\n${body}`;
16
- }
17
-
18
- function serializeFrontmatter(fm: FrontmatterData): string {
19
- const lines: string[] = [];
20
-
21
- if (fm.title) lines.push(`title: ${yamlString(fm.title)}`);
22
- if (fm.subtitle) lines.push(`subtitle: ${yamlString(fm.subtitle)}`);
23
- if (fm.description) lines.push(`description: ${yamlString(fm.description)}`);
24
-
25
- if (fm.authors.length > 0) {
26
- lines.push("authors:");
27
- for (const author of fm.authors) {
28
- lines.push(` - name: ${yamlString(author.name)}`);
29
- if (author.url) lines.push(` url: ${yamlString(author.url)}`);
30
- if (author.affiliations.length > 0) {
31
- lines.push(` affiliations: [${author.affiliations.join(", ")}]`);
32
- }
33
- }
34
- }
35
-
36
- if (fm.affiliations.length > 0) {
37
- lines.push("affiliations:");
38
- for (const aff of fm.affiliations) {
39
- lines.push(` - name: ${yamlString(aff.name)}`);
40
- if (aff.url) lines.push(` url: ${yamlString(aff.url)}`);
41
- }
42
- }
43
-
44
- if (fm.published) lines.push(`published: ${yamlString(fm.published)}`);
45
- if (fm.template !== "article") lines.push(`template: ${yamlString(fm.template)}`);
46
- if (fm.banner && fm.banner !== "banner.html") lines.push(`banner: ${yamlString(fm.banner)}`);
47
- if (fm.doi) lines.push(`doi: ${yamlString(fm.doi)}`);
48
- if (!fm.showPdf) lines.push("showPdf: false");
49
- if (fm.tableOfContentsAutoCollapse) lines.push("tableOfContentsAutoCollapse: true");
50
- if (fm.licence) lines.push(`licence: ${yamlString(fm.licence)}`);
51
- if (fm.pdfProOnly) lines.push("pdfProOnly: true");
52
- if (fm.seoThumbImage) lines.push(`seoThumbImage: ${yamlString(fm.seoThumbImage)}`);
53
-
54
- if (fm.links.length > 0) {
55
- lines.push("links:");
56
- for (const link of fm.links) {
57
- lines.push(` - label: ${yamlString(link.label)}`);
58
- lines.push(` url: ${yamlString(link.url)}`);
59
- }
60
- }
61
-
62
- return lines.length > 0 ? lines.join("\n") + "\n" : "";
63
- }
64
-
65
- function yamlString(value: string): string {
66
- if (/[\n:"{}[\],&*?|>!%@`#]/.test(value) || value !== value.trim()) {
67
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
68
- }
69
- return `"${value}"`;
70
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -7,7 +7,6 @@ import { useCallback, useRef, useState } from "react";
7
  import type { Editor } from "@tiptap/core";
8
  import type { UndoManager } from "yjs";
9
  import type { UIMessage } from "ai";
10
- import { toMdx } from "../export/to-mdx";
11
  import type { FrontmatterStore } from "../editor/frontmatter/frontmatter-store";
12
 
13
  interface UseAgentChatOptions {
@@ -29,7 +28,7 @@ export function useAgentChat({ editor, undoManager, frontmatterStore }: UseAgent
29
  const { from, to } = editor.state.selection;
30
  const hasSelection = from !== to;
31
 
32
- const document = toMdx(editor.getJSON());
33
  const selection = hasSelection
34
  ? editor.state.doc.textBetween(from, to, "\n")
35
  : undefined;
 
7
  import type { Editor } from "@tiptap/core";
8
  import type { UndoManager } from "yjs";
9
  import type { UIMessage } from "ai";
 
10
  import type { FrontmatterStore } from "../editor/frontmatter/frontmatter-store";
11
 
12
  interface UseAgentChatOptions {
 
28
  const { from, to } = editor.state.selection;
29
  const hasSelection = from !== to;
30
 
31
+ const document = editor.getText("\n");
32
  const selection = hasSelection
33
  ? editor.state.doc.textBetween(from, to, "\n")
34
  : undefined;