From b8f96328dd45c0787029f97f9a86d35936c967c1 Mon Sep 17 00:00:00 2001 From: Leonardo Salas Date: Wed, 17 Jun 2026 17:01:50 -0600 Subject: [PATCH] feat: edit multiple .tex files in one project The files panel now lists the project's .tex files, not just images. Click one to edit it, with the open file highlighted and the compiled file marked "main". Use New to add a file (defaults to .tex) and delete the ones you don't need. The main file still drives compilation; secondary files are meant to be \input from it. Saving a secondary file goes through the files API and still counts as recent activity on the project. --- .../api/projects/[id]/files/[name]/route.ts | 41 +++++- src/app/editor/[id]/page.tsx | 76 ++++++++++- src/components/editor/FilesPanel.tsx | 121 ++++++++++++++---- src/lib/projects.ts | 8 ++ 4 files changed, 213 insertions(+), 33 deletions(-) diff --git a/src/app/api/projects/[id]/files/[name]/route.ts b/src/app/api/projects/[id]/files/[name]/route.ts index 813976b..3137586 100644 --- a/src/app/api/projects/[id]/files/[name]/route.ts +++ b/src/app/api/projects/[id]/files/[name]/route.ts @@ -1,14 +1,28 @@ import fs from "node:fs"; import path from "node:path"; import { NextResponse } from "next/server"; -import { deleteProjectFile, resolveProjectFile } from "@/lib/storage"; +import { getProject, touchUpdated } from "@/lib/projects"; +import { + deleteProjectFile, + resolveProjectFile, + safeFileName, + writeProjectFile, +} from "@/lib/storage"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; type Params = { params: { id: string; name: string } }; +// text files you can create and edit alongside main.tex +const TEXT_FILE = /\.(tex|txt|bib|cls|sty)$/i; + const MIME: Record = { + ".tex": "text/plain; charset=utf-8", + ".txt": "text/plain; charset=utf-8", + ".bib": "text/plain; charset=utf-8", + ".cls": "text/plain; charset=utf-8", + ".sty": "text/plain; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", @@ -37,6 +51,31 @@ export function GET(_request: Request, { params }: Params) { }); } +// Writes a text file (creating it if needed). Used to edit secondary .tex files +// and to add new ones. +export async function PUT(request: Request, { params }: Params) { + if (!getProject(params.id)) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + const name = safeFileName(params.name); + if (!name || !TEXT_FILE.test(name)) { + return NextResponse.json( + { error: "Only text files can be written here" }, + { status: 400 }, + ); + } + + const body = await request.json().catch(() => ({})); + if (typeof body.content !== "string") { + return NextResponse.json({ error: "Missing content" }, { status: 400 }); + } + + writeProjectFile(params.id, name, Buffer.from(body.content, "utf8")); + touchUpdated(params.id); + return NextResponse.json({ name }); +} + export function DELETE(_request: Request, { params }: Params) { if (!deleteProjectFile(params.id, params.name)) { return NextResponse.json({ error: "File not found" }, { status: 404 }); diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 4bd8d97..960005e 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -9,6 +9,7 @@ import { CompileLog } from "@/components/editor/CompileLog"; import { FilesPanel } from "@/components/editor/FilesPanel"; import { Toolbar, type SaveState } from "@/components/editor/Toolbar"; import { useCompile } from "@/components/editor/useCompile"; +import { getEngine } from "@/lib/engines"; import type { Project } from "@/lib/projects"; // CodeMirror and pdf.js only run in the browser, so load them without SSR @@ -33,11 +34,16 @@ export default function EditorPage({ params }: { params: { id: string } }) { const [saveState, setSaveState] = useState("saved"); const [showLog, setShowLog] = useState(false); const [showFiles, setShowFiles] = useState(false); + // which file is open in the editor; the main file compiles, the rest are + // \input from it + const [activeFile, setActiveFile] = useState(""); const { compile, status, log, durationMs, pdfVersion } = useCompile(id); const sourceRef = useRef(""); const lastSavedRef = useRef(""); + const activeFileRef = useRef(""); + const mainFileRef = useRef("main.tex"); const editorViewRef = useRef(null); const saveTimer = useRef>(); const compileTimer = useRef>(); @@ -46,11 +52,24 @@ export default function EditorPage({ params }: { params: { id: string } }) { const value = sourceRef.current; if (value === lastSavedRef.current) return; setSaveState("saving"); - await fetch(`/api/projects/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ source: value }), - }); + + const file = activeFileRef.current; + if (file && file !== mainFileRef.current) { + // a secondary file goes through the files API + await fetch(`/api/projects/${id}/files/${encodeURIComponent(file)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: value }), + }); + } else { + // the main file is the project's source + await fetch(`/api/projects/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source: value }), + }); + } + lastSavedRef.current = value; setSaveState("saved"); }, [id]); @@ -62,6 +81,10 @@ export default function EditorPage({ params }: { params: { id: string } }) { .then((res) => (res.ok ? res.json() : Promise.reject())) .then((data) => { if (!active) return; + const main = getEngine(data.project.engine).mainFileName; + mainFileRef.current = main; + setActiveFile(main); + activeFileRef.current = main; setProject(data.project); setSource(data.source); sourceRef.current = data.source; @@ -109,6 +132,39 @@ export default function EditorPage({ params }: { params: { id: string } }) { compile(); } + // switch which file is open: save the current one, then load the chosen file + async function openFile(name: string) { + if (name === activeFileRef.current) return; + clearTimeout(saveTimer.current); + clearTimeout(compileTimer.current); + await save(); + + let content = ""; + if (name === mainFileRef.current) { + const res = await fetch(`/api/projects/${id}`); + if (res.ok) content = (await res.json()).source; + } else { + const res = await fetch( + `/api/projects/${id}/files/${encodeURIComponent(name)}`, + ); + if (res.ok) content = await res.text(); + } + + setActiveFile(name); + activeFileRef.current = name; + setSource(content); + sourceRef.current = content; + lastSavedRef.current = content; + setSaveState("saved"); + editorViewRef.current?.focus(); + } + + // the open file was deleted: fall back to main without re-saving the deleted one + function handleFileDeleted() { + lastSavedRef.current = sourceRef.current; + openFile(mainFileRef.current); + } + // drop an \includegraphics line for an uploaded image at the cursor function insertImage(name: string) { const view = editorViewRef.current; @@ -162,7 +218,15 @@ export default function EditorPage({ params }: { params: { id: string } }) { />
- {showFiles && } + {showFiles && ( + + )}
void; + onOpenFile: (name: string) => void; + onFileDeleted: (name: string) => void; } +const TEXT_FILE = /\.(tex|txt|bib|cls|sty)$/i; +const INSERTABLE = /\.(png|jpe?g|gif|webp|pdf)$/i; + function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } -export function FilesPanel({ projectId, onInsertImage }: FilesPanelProps) { +export function FilesPanel({ + projectId, + activeFile, + onInsertImage, + onOpenFile, + onFileDeleted, +}: FilesPanelProps) { const [files, setFiles] = useState([]); const [dragging, setDragging] = useState(false); const [uploading, setUploading] = useState(false); const [pendingDelete, setPendingDelete] = useState(null); - const inputRef = useRef(null); + const [newName, setNewName] = useState(null); + const uploadInput = useRef(null); const refresh = useCallback(async () => { const res = await fetch(`/api/projects/${projectId}/files`); @@ -57,6 +69,20 @@ export function FilesPanel({ projectId, onInsertImage }: FilesPanelProps) { [projectId, refresh], ); + async function createFile() { + let name = (newName ?? "").trim(); + setNewName(null); + if (!name) return; + if (!/\.[a-z0-9]+$/i.test(name)) name += ".tex"; // default to a .tex file + await fetch(`/api/projects/${projectId}/files/${encodeURIComponent(name)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "" }), + }); + await refresh(); + onOpenFile(name); + } + async function confirmDelete() { if (!pendingDelete) return; const { name } = pendingDelete; @@ -64,7 +90,8 @@ export function FilesPanel({ projectId, onInsertImage }: FilesPanelProps) { await fetch(`/api/projects/${projectId}/files/${encodeURIComponent(name)}`, { method: "DELETE", }); - refresh(); + await refresh(); + if (name === activeFile) onFileDeleted(name); } return ( @@ -86,15 +113,26 @@ export function FilesPanel({ projectId, onInsertImage }: FilesPanelProps) { >
Files - +
+ + +
+ {newName !== null && ( + setNewName(event.target.value)} + onBlur={createFile} + onKeyDown={(event) => { + if (event.key === "Enter") createFile(); + if (event.key === "Escape") setNewName(null); + }} + className="mb-2 w-full rounded-md border border-accent bg-surface px-2 py-1 text-xs focus:outline-none" + /> + )} + {uploading && (

Uploading...

)} - {files.length === 0 && !uploading ? ( + {files.length === 0 && !uploading && newName === null ? (

- Drop images here or use Upload, then click one to insert it. + Drop images here or use Upload. Use New to add a .tex file.

) : (
    @@ -135,16 +188,38 @@ export function FilesPanel({ projectId, onInsertImage }: FilesPanelProps) { {file.name} - ) : ( -
    - + ) : TEXT_FILE.test(file.name) ? ( +
    + + ) : ( + )} {!file.isMain && ( @@ -156,12 +231,6 @@ export function FilesPanel({ projectId, onInsertImage }: FilesPanelProps) { )} - - {file.kind !== "image" && ( - - {formatSize(file.size)} - - )} ))}
diff --git a/src/lib/projects.ts b/src/lib/projects.ts index 465c321..9496f12 100644 --- a/src/lib/projects.ts +++ b/src/lib/projects.ts @@ -158,6 +158,14 @@ export function touchProject(id: string): void { .run(Date.now(), id); } +// bump updated_at after a change that isn't the main source (e.g. editing a +// secondary .tex file) so the project still counts as recently worked on +export function touchUpdated(id: string): void { + getDb() + .prepare(`UPDATE projects SET updated_at = ? WHERE id = ?`) + .run(Date.now(), id); +} + export function deleteProject(id: string): boolean { const project = getProject(id); if (!project) return false;