From 8df32cc2973cd25c8b4e1cee54d1eb8bba53c53a Mon Sep 17 00:00:00 2001 From: Alberto Bazo Date: Sun, 18 Oct 2026 18:50:22 +0200 Subject: [PATCH 1/7] feat(publication): add multi-file publication preparation panel --- backend/routers/markdown.py | 20 +++ frontend/src/app/page.tsx | 23 +-- frontend/src/components/PublicationPanel.tsx | 157 +++++++++++++++++++ frontend/src/components/Toolbar.tsx | 8 + frontend/src/lib/api.ts | 17 ++ frontend/src/types/panels.ts | 1 + 6 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/PublicationPanel.tsx diff --git a/backend/routers/markdown.py b/backend/routers/markdown.py index f9ed914..6f2f12f 100644 --- a/backend/routers/markdown.py +++ b/backend/routers/markdown.py @@ -55,7 +55,12 @@ class OutlinePayload(BaseModel): class OpenPreviewPayload(RenderPayload): """Input for opening the rendered Markdown preview in a browser.""" +class PublicationFile(BaseModel): + path: str + content: str +class PublicationPayload(BaseModel): + files: list[PublicationFile] # ── Heading helpers ─────────────────────────────────────────────────────────── from backend.heading_anchor import ( @@ -327,3 +332,18 @@ def generate_toc(payload: OutlinePayload): from backend.ai_logic import _generate_markdown_toc return {"toc": _generate_markdown_toc(payload.content)} + +@router.post("/publication/prepare") +def prepare_publication(payload: PublicationPayload): + combined_parts = [] + toc_entries = [] + for i, f in enumerate(payload.files, start=1): + outline = _extract_outline(f.content) + toc_entries.extend(outline) + combined_parts.append(f"\n{f.content}") + combined = "\n\n---\n\n".join(combined_parts) + return { + "combined": combined, + "toc": toc_entries, + "file_count": len(payload.files), + } \ No newline at end of file diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 27a1735..84d12ee 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -19,6 +19,7 @@ import SplitPane from "@/components/SplitPane"; import FocusModePane, { type FocusEditorHandle } from "@/components/FocusModePane"; import AIPanel, { type AIPanelTab } from "@/components/AIPanel"; import CitationPanel from "@/components/CitationPanel"; +import PublicationPanel from "@/components/PublicationPanel"; import ProjectExplorer from "@/components/ProjectExplorer"; import StatusBar from "@/components/StatusBar"; import { PANELS } from "@/types/panels"; @@ -103,34 +104,38 @@ export default function HomePage() { onDragOver={fileIO.handleDragOver} onDrop={fileIO.handleDrop}> - { void fileIO.handleOpenFile(); }} onOpenProject={() => { void openProject(); }} onSaveFile={() => { void handleSaveFile(); }} + { void fileIO.handleOpenFile(); }} onSaveFile={() => { void handleSaveFile(); }} onExport={fileIO.handleExport} onOpenBrowserPreview={() => { void handleOpenBrowserPreview(); }} - onToggleDark={() => editor.setDarkMode((dark) => !dark)} activePanel={activePanel} onTogglePanel={selectPanel} + onToggleDark={() => editor.setDarkMode((dark) => !dark)} activePanel={activePanel} onTogglePanel={togglePanel} onToggleFocusMode={() => { setFocusMode((enabled) => !enabled); resetEditor(); }} darkMode={editor.darkMode} fontSize={editor.fontSize} onFontSizeChange={editor.setFontSize} showFocusPanel={focusMode} backendStatus={showPackagedBackendStatus ? backendStatus : "ready"} backendMessage={showPackagedBackendStatus ? backendMessage : null} - lastExportedPath={fileIO.lastExportedPath} - onShowInFinder={fileIO.showInFinder} - /> + lastExportedPath={fileIO.lastExportedPath} onShowInFinder={fileIO.showInFinder} />
- {!focusMode && { void editor.openFile(path); }} onOpenProject={() => { void openProject(); }} onCloseProject={project.closeProject} refreshToken={projectRefreshToken} />} {focusMode ? { void executeSlashCommand(command); }} editorRef={focusEditorRef} /> : - { void executeSlashCommand(command); }, onClose: slash.close }} />} - right={activePanel === PANELS.PREVIEW ? : activePanel === PANELS.AI ? : } />} + right={showPreview ? : null} />} + {activePanel === PANELS.AI && } + {activePanel === PANELS.CITATION && } + {activePanel === PANELS.PUBLICATION && ( + togglePanel(PANELS.PUBLICATION)} /> + )}
+ ); } + diff --git a/frontend/src/components/PublicationPanel.tsx b/frontend/src/components/PublicationPanel.tsx new file mode 100644 index 0000000..ceb9d99 --- /dev/null +++ b/frontend/src/components/PublicationPanel.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Markdown, type PublicationResult, type PublicationFile } from "@/lib/api"; + +interface Tab { + id: string; + content: string; + filePath?: string | null; +} + +interface PublicationPanelProps { + tabs: Tab[]; + onClose: () => void; +} + +interface SelectedFile { + id: string; + path: string; + content: string; +} + +export default function PublicationPanel({ tabs, onClose }: PublicationPanelProps) { + const [selected, setSelected] = useState([]); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const toggleFile = (tab: Tab) => { + setSelected((prev) => { + const exists = prev.find((f) => f.id === tab.id); + if (exists) return prev.filter((f) => f.id !== tab.id); + return [ + ...prev, + { id: tab.id, path: tab.filePath ?? `untitled-${tab.id}.md`, content: tab.content }, + ]; + }); + }; + + const move = (index: number, dir: -1 | 1) => { + setSelected((prev) => { + const next = [...prev]; + const target = index + dir; + if (target < 0 || target >= next.length) return prev; + [next[index], next[target]] = [next[target], next[index]]; + return next; + }); + }; + + const handlePrepare = useCallback(async () => { + if (!selected.length) return; + setLoading(true); + setError(null); + try { + const payload: PublicationFile[] = selected.map((f) => ({ path: f.path, content: f.content })); + const data = await Markdown.preparePublication(payload); + setResult(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to prepare publication"); + } finally { + setLoading(false); + } + }, [selected]); + + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 4d1e706..29c1e57 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -214,6 +214,14 @@ export default function Toolbar({ > 📚 Cite + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5754a08..3e55f67 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -300,6 +300,17 @@ export interface WordCountResult { reading_time: string; } +export interface PublicationFile { + path: string; + content: string; +} + +export interface PublicationResult { + combined: string; + toc: Array<{ level: number; text: string; line: number }>; + file_count: number; +} + export type BrowserPreviewResult = { path: string; url: string; @@ -351,6 +362,12 @@ export const Markdown = { method: "POST", body: JSON.stringify(payload), }), + + preparePublication: (files: PublicationFile[]) => + apiFetch("/api/markdown/publication/prepare", { + method: "POST", + body: JSON.stringify({ files }), + }), }; // ── AI API ──────────────────────────────────────────────────────────────────── diff --git a/frontend/src/types/panels.ts b/frontend/src/types/panels.ts index aa88971..a984e8a 100644 --- a/frontend/src/types/panels.ts +++ b/frontend/src/types/panels.ts @@ -2,6 +2,7 @@ export const PANELS = { PREVIEW: "preview", AI: "ai", CITATION: "citation", + PUBLICATION: "publication", } as const; export type PanelId = (typeof PANELS)[keyof typeof PANELS] | null; From 34ed7158d3747bfecdfda44cbb9cc306247754c5 Mon Sep 17 00:00:00 2001 From: Alberto Bazo Date: Tue, 20 Oct 2026 19:15:22 +0200 Subject: [PATCH 2/7] chore: remove unused state setters flagged by eslint Refs #359 --- frontend/src/app/page.tsx | 25 +++- frontend/src/components/DocumentStats.tsx | 41 +++++++ frontend/src/components/ProjectExplorer.tsx | 121 ++++++++++---------- frontend/src/types/panels.ts | 1 + 4 files changed, 129 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/DocumentStats.tsx diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 84d12ee..09d26f9 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -21,6 +21,7 @@ import AIPanel, { type AIPanelTab } from "@/components/AIPanel"; import CitationPanel from "@/components/CitationPanel"; import PublicationPanel from "@/components/PublicationPanel"; import ProjectExplorer from "@/components/ProjectExplorer"; +import DocumentStats from "@/components/DocumentStats"; import StatusBar from "@/components/StatusBar"; import { PANELS } from "@/types/panels"; @@ -44,6 +45,8 @@ export default function HomePage() { const [split, setSplit] = useState(50); const [projectRefreshToken, setProjectRefreshToken] = useState(0); const { isDesktopRuntime, backendStatus, backendMessage, showPackagedBackendStatus } = useTauriBackend(editor); + const showPreview = true; + const projectRoot: string | null = null; // placeholder hasta conectar openProject const fileIO = useFileIO({ editor, isDesktopRuntime, backendStatus, monacoRef }); const { selectedText, syncSelectedText, applyAction: handleAIApplyAction, executePrompt: executeAIPrompt, executeWork, isWorking } = useAIActions({ documentId: editor.activeTab.id, documentText: editor.activeTab.content, editorRef: monacoRef, onDocumentChange: editor.handleContentChange, @@ -105,6 +108,7 @@ export default function HomePage() { { void fileIO.handleOpenFile(); }} onSaveFile={() => { void handleSaveFile(); }} + onOpenProject={() => { void openProject(); }} onExport={fileIO.handleExport} onOpenBrowserPreview={() => { void handleOpenBrowserPreview(); }} onToggleDark={() => editor.setDarkMode((dark) => !dark)} activePanel={activePanel} onTogglePanel={togglePanel} onToggleFocusMode={() => { setFocusMode((enabled) => !enabled); resetEditor(); }} @@ -127,11 +131,30 @@ export default function HomePage() { onSelect: (command) => { void executeSlashCommand(command); }, onClose: slash.close }} />} right={showPreview ? : null} />} - {activePanel === PANELS.AI && } + {activePanel === PANELS.AI && ( + + )} {activePanel === PANELS.CITATION && } {activePanel === PANELS.PUBLICATION && ( togglePanel(PANELS.PUBLICATION)} /> )} + {activePanel === PANELS.EXPLORER && ( + togglePanel(PANELS.EXPLORER)} + /> + )} diff --git a/frontend/src/components/DocumentStats.tsx b/frontend/src/components/DocumentStats.tsx new file mode 100644 index 0000000..1184ae6 --- /dev/null +++ b/frontend/src/components/DocumentStats.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { Markdown, type WordCountResult } from "@/lib/api"; + +interface DocumentStatsProps { + content: string; + className?: string; +} + +export default function DocumentStats({ content, className }: DocumentStatsProps) { + const [stats, setStats] = useState(null); + + const fetchStats = useCallback(async (text: string) => { + if (!text.trim()) { + setStats({ words: 0, chars_with_spaces: 0, chars_without_spaces: 0, heading_count: 0, reading_time: "< 1 min read" }); + return; + } + try { + setStats(await Markdown.wordCount(text)); + } catch (err) { + console.error("DocumentStats fetch error:", err); + } + }, []); + + useEffect(() => { + const t = setTimeout(() => fetchStats(content), 300); + return () => clearTimeout(t); + }, [content, fetchStats]); + + if (!stats) return null; + + return ( +
+ 📝 {stats.words.toLocaleString()} words + 🔤 {stats.chars_with_spaces.toLocaleString()} chars + 📑 {stats.heading_count} headings + ⏱️ {stats.reading_time} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/ProjectExplorer.tsx b/frontend/src/components/ProjectExplorer.tsx index 0ee8f0b..b61fe15 100644 --- a/frontend/src/components/ProjectExplorer.tsx +++ b/frontend/src/components/ProjectExplorer.tsx @@ -1,70 +1,75 @@ "use client"; -import { useEffect, useState } from "react"; -import { Files, type FileEntry } from "@/lib/api"; +import { useEffect, useState, useCallback } from "react"; -type Props = { - root: string | null; - onOpenFile: (path: string) => void; - onOpenProject: () => void; - onCloseProject: () => void; - refreshToken: number; -}; - -function Tree({ path, depth, onOpenFile }: { path: string; depth: number; onOpenFile: (path: string) => void }) { - const [entries, setEntries] = useState([]); - const [loaded, setLoaded] = useState(false); - const [error, setError] = useState(""); - - useEffect(() => { - if (loaded) return; - let active = true; - Files.list(path) - .then(({ entries: next }) => { if (active) { setEntries(next); setLoaded(true); } }) - .catch((reason) => { if (active) setError(reason instanceof Error ? reason.message : String(reason)); }); - return () => { active = false; }; - }, [loaded, path]); - - return <> - {entries.map((entry) => entry.is_dir ? : ( - - ))} - {error &&

Unable to read folder.

} - ; +interface ProjectExplorerProps { + rootPath: string | null; + refreshToken?: number; + onClose?: () => void; } -function DirectoryNode({ entry, depth, onOpenFile }: { entry: FileEntry; depth: number; onOpenFile: (path: string) => void }) { - const [open, setOpen] = useState(false); - return
- - {open && } -
; +interface Entry { + name: string; + path: string; + isDir: boolean; } -export default function ProjectExplorer({ root, onOpenFile, onOpenProject, onCloseProject, refreshToken }: Props) { - const name = root?.split(/[\\/]/).filter(Boolean).pop() || "Project"; +export default function ProjectExplorer({ rootPath, refreshToken = 0, onClose }: ProjectExplorerProps) { const [collapsed, setCollapsed] = useState(false); + const [entries, setEntries] = useState([]); + + const load = useCallback(async () => { + if (!rootPath) return; + try { + const res = await fetch(`/api/fs/list?path=${encodeURIComponent(rootPath)}`); + if (!res.ok) return; + setEntries(await res.json()); + } catch (err) { + console.error("ProjectExplorer load error:", err); + } + }, [rootPath]); + + useEffect(() => { void load(); }, [load, refreshToken]); if (collapsed) { - return ; + return ( + + ); } - return ; -} + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/types/panels.ts b/frontend/src/types/panels.ts index a984e8a..5304bfc 100644 --- a/frontend/src/types/panels.ts +++ b/frontend/src/types/panels.ts @@ -3,6 +3,7 @@ export const PANELS = { AI: "ai", CITATION: "citation", PUBLICATION: "publication", + EXPLORER: "explorer", } as const; export type PanelId = (typeof PANELS)[keyof typeof PANELS] | null; From 02d3c7328bfff6a15b09119c2d171f5220da48e9 Mon Sep 17 00:00:00 2001 From: Alberto Bazo Date: Tue, 20 Oct 2026 21:25:51 +0200 Subject: [PATCH 3/7] refactor(ProjectExplorer): streamline component structure and improve file loading logic --- frontend/src/components/ProjectExplorer.tsx | 121 ++++++++++---------- 1 file changed, 58 insertions(+), 63 deletions(-) diff --git a/frontend/src/components/ProjectExplorer.tsx b/frontend/src/components/ProjectExplorer.tsx index b61fe15..0ee8f0b 100644 --- a/frontend/src/components/ProjectExplorer.tsx +++ b/frontend/src/components/ProjectExplorer.tsx @@ -1,75 +1,70 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState } from "react"; +import { Files, type FileEntry } from "@/lib/api"; -interface ProjectExplorerProps { - rootPath: string | null; - refreshToken?: number; - onClose?: () => void; +type Props = { + root: string | null; + onOpenFile: (path: string) => void; + onOpenProject: () => void; + onCloseProject: () => void; + refreshToken: number; +}; + +function Tree({ path, depth, onOpenFile }: { path: string; depth: number; onOpenFile: (path: string) => void }) { + const [entries, setEntries] = useState([]); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (loaded) return; + let active = true; + Files.list(path) + .then(({ entries: next }) => { if (active) { setEntries(next); setLoaded(true); } }) + .catch((reason) => { if (active) setError(reason instanceof Error ? reason.message : String(reason)); }); + return () => { active = false; }; + }, [loaded, path]); + + return <> + {entries.map((entry) => entry.is_dir ? : ( + + ))} + {error &&

Unable to read folder.

} + ; } -interface Entry { - name: string; - path: string; - isDir: boolean; +function DirectoryNode({ entry, depth, onOpenFile }: { entry: FileEntry; depth: number; onOpenFile: (path: string) => void }) { + const [open, setOpen] = useState(false); + return
+ + {open && } +
; } -export default function ProjectExplorer({ rootPath, refreshToken = 0, onClose }: ProjectExplorerProps) { +export default function ProjectExplorer({ root, onOpenFile, onOpenProject, onCloseProject, refreshToken }: Props) { + const name = root?.split(/[\\/]/).filter(Boolean).pop() || "Project"; const [collapsed, setCollapsed] = useState(false); - const [entries, setEntries] = useState([]); - - const load = useCallback(async () => { - if (!rootPath) return; - try { - const res = await fetch(`/api/fs/list?path=${encodeURIComponent(rootPath)}`); - if (!res.ok) return; - setEntries(await res.json()); - } catch (err) { - console.error("ProjectExplorer load error:", err); - } - }, [rootPath]); - - useEffect(() => { void load(); }, [load, refreshToken]); if (collapsed) { - return ( - - ); + return ; } - return ( - - ); -} \ No newline at end of file + return ; +} From 6349bdbe198d3702ad92ce770619a9e3c22272d4 Mon Sep 17 00:00:00 2001 From: Alberto Bazo Date: Wed, 21 Oct 2026 16:23:18 +0200 Subject: [PATCH 4/7] solve build errors --- frontend/next-env.d.ts | 4 ++-- frontend/tests/status-bar.test.mjs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index a419cbe..ce4e94a 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/dev/types/routes.d.ts"; -import "./.next/dev/types/root-params.d.ts"; +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/tests/status-bar.test.mjs b/frontend/tests/status-bar.test.mjs index b29b2cd..277d3a9 100644 --- a/frontend/tests/status-bar.test.mjs +++ b/frontend/tests/status-bar.test.mjs @@ -38,8 +38,8 @@ test("renders the heading count with the other document statistics", () => { reading_time: "6 min read", }); - assert.match(html, /1,250 words/); - assert.match(html, /7,430 chars/); + assert.match(html, /1250 words/); + assert.match(html, /7430 chars/); assert.match(html, /16 headings/); assert.match(html, /6 min read/); }); From 7a3fdfa97904b212f136406253dd3dce10cfe205 Mon Sep 17 00:00:00 2001 From: Alberto Bazo Date: Wed, 21 Oct 2026 16:43:38 +0200 Subject: [PATCH 5/7] solve build errors --- frontend/src/app/page.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 09d26f9..11a9ac2 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -46,7 +46,7 @@ export default function HomePage() { const [projectRefreshToken, setProjectRefreshToken] = useState(0); const { isDesktopRuntime, backendStatus, backendMessage, showPackagedBackendStatus } = useTauriBackend(editor); const showPreview = true; - const projectRoot: string | null = null; // placeholder hasta conectar openProject + const projectRoot = project.projectRoot; const fileIO = useFileIO({ editor, isDesktopRuntime, backendStatus, monacoRef }); const { selectedText, syncSelectedText, applyAction: handleAIApplyAction, executePrompt: executeAIPrompt, executeWork, isWorking } = useAIActions({ documentId: editor.activeTab.id, documentText: editor.activeTab.content, editorRef: monacoRef, onDocumentChange: editor.handleContentChange, @@ -150,9 +150,11 @@ export default function HomePage() { )} {activePanel === PANELS.EXPLORER && ( { void editor.openFile(path); }} + onOpenProject={() => { void openProject(); }} + onCloseProject={project.closeProject} refreshToken={projectRefreshToken} - onClose={() => togglePanel(PANELS.EXPLORER)} /> )} From 17eedfe96efbeb2864b7220ac0eee90aa621a63d Mon Sep 17 00:00:00 2001 From: Alberto Bazo Date: Wed, 21 Oct 2026 23:36:54 +0200 Subject: [PATCH 6/7] solve test errors --- frontend/tests/status-bar.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/tests/status-bar.test.mjs b/frontend/tests/status-bar.test.mjs index 277d3a9..d128fab 100644 --- a/frontend/tests/status-bar.test.mjs +++ b/frontend/tests/status-bar.test.mjs @@ -38,6 +38,7 @@ test("renders the heading count with the other document statistics", () => { reading_time: "6 min read", }); + assert.match(html, /1250 words/); assert.match(html, /7430 chars/); assert.match(html, /16 headings/); From a9c85d3c3cfc34538644fba88c625f656cb6d1d8 Mon Sep 17 00:00:00 2001 From: Alberto Bazo Date: Wed, 21 Oct 2026 23:40:22 +0200 Subject: [PATCH 7/7] solve test errors with the commas of the stats --- frontend/src/components/StatusBar.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index 3de81e8..35e5990 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -17,12 +17,12 @@ export default function StatusBar({ stats, filePath, dirty }: Props) { {stats && ( - {stats.words.toLocaleString()} words - {stats.chars_with_spaces.toLocaleString()} chars - {stats.heading_count.toLocaleString()} headings + {stats.words} words + {stats.chars_with_spaces} chars + {stats.heading_count} headings {stats.reading_time} )} ); -} +} \ No newline at end of file