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/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/src/app/page.tsx b/frontend/src/app/page.tsx index 27a1735..11a9ac2 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -19,7 +19,9 @@ 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 DocumentStats from "@/components/DocumentStats"; import StatusBar from "@/components/StatusBar"; import { PANELS } from "@/types/panels"; @@ -43,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 = 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, @@ -103,34 +107,60 @@ 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(); }} + onOpenProject={() => { void openProject(); }} 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)} /> + )} + {activePanel === PANELS.EXPLORER && ( + { void editor.openFile(path); }} + onOpenProject={() => { void openProject(); }} + onCloseProject={project.closeProject} + refreshToken={projectRefreshToken} + /> + )}
+ ); } + 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/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/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 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..5304bfc 100644 --- a/frontend/src/types/panels.ts +++ b/frontend/src/types/panels.ts @@ -2,6 +2,8 @@ export const PANELS = { PREVIEW: "preview", AI: "ai", CITATION: "citation", + PUBLICATION: "publication", + EXPLORER: "explorer", } as const; export type PanelId = (typeof PANELS)[keyof typeof PANELS] | null; diff --git a/frontend/tests/status-bar.test.mjs b/frontend/tests/status-bar.test.mjs index b29b2cd..d128fab 100644 --- a/frontend/tests/status-bar.test.mjs +++ b/frontend/tests/status-bar.test.mjs @@ -38,8 +38,9 @@ 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/); });