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;