Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions backend/routers/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"<!-- file: {f.path} -->\n{f.content}")
combined = "\n\n---\n\n".join(combined_parts)
return {
"combined": combined,
"toc": toc_entries,
"file_count": len(payload.files),
}
4 changes: 2 additions & 2 deletions frontend/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
48 changes: 39 additions & 9 deletions frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
Expand Down Expand Up @@ -103,34 +107,60 @@ export default function HomePage() {
onDragOver={fileIO.handleDragOver} onDrop={fileIO.handleDrop}>
<input ref={fileIO.fileInputRef} type="file" accept={fileIO.fileInputAccept} className="hidden" onChange={fileIO.handleFileInputChange} />
<MenuBar groups={menuGroups} shortcuts={shortcuts} />
<Toolbar onOpenFile={() => { void fileIO.handleOpenFile(); }} onOpenProject={() => { void openProject(); }} onSaveFile={() => { void handleSaveFile(); }}
<Toolbar onOpenFile={() => { 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} />
<TabBar tabs={editor.tabs} activeTabId={editor.activeTabId} onSelect={handleTabSelect} onClose={editor.closeTab} onNew={editor.newTab} />
<div className="flex flex-1 overflow-hidden">
{!focusMode && <ProjectExplorer root={project.projectRoot} onOpenFile={(path) => { void editor.openFile(path); }} onOpenProject={() => { void openProject(); }} onCloseProject={project.closeProject} refreshToken={projectRefreshToken} />}
{focusMode ? <FocusModePane key={`${editor.activeTabId}-${revision}`} value={editor.activeTab.content}
onChange={editor.handleContentChange} darkMode={editor.darkMode} fontSize={editor.fontSize}
slashCommands={slash.filteredCommands} onSelect={(command) => { void executeSlashCommand(command); }}
editorRef={focusEditorRef} /> :
<SplitPane split={split} onSplitChange={setSplit} minSize={20}
<SplitPane split={split} onSplitChange={setSplit}
left={<EditorWorkspace tabId={editor.activeTab.id} value={editor.activeTab.content}
onChange={editor.handleContentChange} darkMode={editor.darkMode} fontSize={editor.fontSize}
revision={revision} monacoRef={monacoRef} onReady={markReady}
slash={{ isOpen: slash.isOpen, filteredCommands: slash.filteredCommands, selectedIndex: slash.selectedIndex,
position: slashMenuPosition,
onSelect: (command) => { void executeSlashCommand(command); }, onClose: slash.close }} />}
right={activePanel === PANELS.PREVIEW ? <PreviewPane html={editor.previewHtml} loading={showPackagedBackendStatus && backendStatus === "starting"}
error={showPackagedBackendStatus && backendStatus === "error" ? backendMessage : null} /> : activePanel === PANELS.AI ? <AIPanel projectRoot={project.projectRoot} projectWork={projectWork} documentId={editor.activeTab.id} documentText={editor.activeTab.content} selectedText={selectedText} onApplyAction={handleAIApplyAction} initialTab={aiPanelInitialTab} executeWork={executeWork} isWorking={isWorking} /> : <CitationPanel onInsert={formatting.insertCitation} />} />}
right={showPreview ? <PreviewPane html={editor.previewHtml} loading={showPackagedBackendStatus && backendStatus === "starting"}
error={showPackagedBackendStatus && backendStatus === "error" ? backendMessage : null} /> : null} />}
{activePanel === PANELS.AI && (
<AIPanel
documentText={editor.activeTab.content}
selectedText={selectedText}
onApplyAction={handleAIApplyAction}
initialTab={aiPanelInitialTab}
executeWork={executeWork}
isWorking={isWorking}
documentId={editor.activeTab.id} /* <-- nuevo */
projectRoot={projectRoot} /* <-- nuevo */
projectWork={projectWork}
/>
)}
{activePanel === PANELS.CITATION && <CitationPanel onInsert={formatting.insertCitation} />}
{activePanel === PANELS.PUBLICATION && (
<PublicationPanel tabs={editor.tabs} onClose={() => togglePanel(PANELS.PUBLICATION)} />
)}
{activePanel === PANELS.EXPLORER && (
<ProjectExplorer
root={projectRoot}
onOpenFile={(path) => { void editor.openFile(path); }}
onOpenProject={() => { void openProject(); }}
onCloseProject={project.closeProject}
refreshToken={projectRefreshToken}
/>
)}
</div>
<DocumentStats content={editor.activeTab.content} className="px-4 py-1 border-t" />
<StatusBar stats={editor.wordCount} filePath={editor.activeTab.filePath} dirty={editor.activeTab.dirty} />
</div>
);
}

41 changes: 41 additions & 0 deletions frontend/src/components/DocumentStats.tsx
Original file line number Diff line number Diff line change
@@ -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<WordCountResult | null>(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 (
<div className={`flex gap-4 text-sm text-gray-600 dark:text-gray-400 ${className ?? ""}`}>
<span>📝 {stats.words.toLocaleString()} words</span>
<span>🔤 {stats.chars_with_spaces.toLocaleString()} chars</span>
<span>📑 {stats.heading_count} headings</span>
<span>⏱️ {stats.reading_time}</span>
</div>
);
}
157 changes: 157 additions & 0 deletions frontend/src/components/PublicationPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<SelectedFile[]>([]);
const [result, setResult] = useState<PublicationResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<aside className="w-96 border-l border-gray-200 bg-white dark:border-[#333] dark:bg-[#252526] flex flex-col">
<header className="flex items-center justify-between border-b border-gray-200 px-4 py-2 dark:border-[#333]">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Publication</h2>
<button
onClick={onClose}
aria-label="Close publication panel"
className="rounded p-1 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-[#333]"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</header>

<div className="flex-1 overflow-auto p-3 text-sm dark:text-gray-200">
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">
Select and order files to combine into a publication draft.
</p>

<ul className="mb-4 space-y-1">
{tabs.map((tab) => {
const idx = selected.findIndex((f) => f.id === tab.id);
const isSelected = idx !== -1;
return (
<li
key={tab.id}
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-gray-50 dark:hover:bg-[#333]"
>
<input type="checkbox" checked={isSelected} onChange={() => toggleFile(tab)} />
<span className="flex-1 truncate">{tab.filePath ?? `Untitled ${tab.id}`}</span>
{isSelected && (
<>
<button
onClick={() => move(idx, -1)}
disabled={idx === 0}
className="px-1 disabled:opacity-30"
aria-label="Move up"
>
↑
</button>
<button
onClick={() => move(idx, 1)}
disabled={idx === selected.length - 1}
className="px-1 disabled:opacity-30"
aria-label="Move down"
>
↓
</button>
</>
)}
</li>
);
})}
</ul>

<button
onClick={handlePrepare}
disabled={!selected.length || loading}
className="w-full rounded bg-blue-600 px-3 py-2 text-white disabled:opacity-50 hover:bg-blue-700"
>
{loading ? "Preparing…" : "Preview publication"}
</button>

{error && <p className="mt-2 text-xs text-red-500">{error}</p>}

{result && (
<div className="mt-4">
<details open>
<summary className="cursor-pointer text-xs font-semibold">
Table of contents ({result.toc.length})
</summary>
<ul className="ml-3 mt-1 text-xs">
{result.toc.map((h, i) => (
<li key={i} style={{ marginLeft: (h.level - 1) * 8 }}>
{h.text}
</li>
))}
</ul>
</details>
<details className="mt-2">
<summary className="cursor-pointer text-xs font-semibold">Combined preview</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded bg-gray-50 p-2 text-xs dark:bg-[#1e1e1e]">
{result.combined}
</pre>
</details>
</div>
)}
</div>
</aside>
);
}
8 changes: 4 additions & 4 deletions frontend/src/components/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ export default function StatusBar({ stats, filePath, dirty }: Props) {
</span>
{stats && (
<span className="flex gap-4 opacity-90">
<span>{stats.words.toLocaleString()} words</span>
<span>{stats.chars_with_spaces.toLocaleString()} chars</span>
<span>{stats.heading_count.toLocaleString()} headings</span>
<span>{stats.words} words</span>
<span>{stats.chars_with_spaces} chars</span>
<span>{stats.heading_count} headings</span>
<span>{stats.reading_time}</span>
</span>
)}
</div>
);
}
}
8 changes: 8 additions & 0 deletions frontend/src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,14 @@ export default function Toolbar({
>
📚 Cite
</button>
<button
onClick={() => onTogglePanel(PANELS.PUBLICATION)}
title="Publication"
aria-pressed={activePanel === PANELS.PUBLICATION}
className={`rounded p-1.5 ${activePanel === PANELS.PUBLICATION ? "bg-blue-100 text-blue-700" : "text-gray-600 hover:bg-gray-100"} dark:${activePanel === PANELS.PUBLICATION ? "bg-[#333] text-blue-400" : "text-gray-300 dark:hover:bg-[#333]"}`}
>
📝Publication
</button>
<button onPointerDown={keepEditorFocusedOnPointerDown} onMouseDown={keepEditorFocused} onClick={onToggleDark} className={btnCls} title="Toggle dark mode">
{darkMode ? "☀️" : "🌙"}
</button>
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -351,6 +362,12 @@ export const Markdown = {
method: "POST",
body: JSON.stringify(payload),
}),

preparePublication: (files: PublicationFile[]) =>
apiFetch<PublicationResult>("/api/markdown/publication/prepare", {
method: "POST",
body: JSON.stringify({ files }),
}),
};

// ── AI API ────────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading