From 9ddebd4a12dc08ffbc18f2946205c73726970197 Mon Sep 17 00:00:00 2001 From: Leonardo Salas Date: Wed, 17 Jun 2026 16:49:34 -0600 Subject: [PATCH] feat: upload and manage image assets Adds a Files panel in the editor for the images a document needs. You can upload images (and PDFs) with the Upload button or by dropping them onto the panel, preview them as thumbnails, click one to drop an \includegraphics line at the cursor, and delete the ones you don't need. Files live in the project directory next to main.tex, so the compiler finds them with no extra configuration. The file routes sanitize names down to a bare filename so an upload can't write outside its project. --- .../api/projects/[id]/files/[name]/route.ts | 45 +++++ src/app/api/projects/[id]/files/route.ts | 58 ++++++ src/app/editor/[id]/page.tsx | 71 ++++--- src/components/editor/EditorPane.tsx | 7 +- src/components/editor/FilesPanel.tsx | 182 ++++++++++++++++++ src/components/editor/Toolbar.tsx | 8 +- src/lib/storage.ts | 74 +++++++ 7 files changed, 420 insertions(+), 25 deletions(-) create mode 100644 src/app/api/projects/[id]/files/[name]/route.ts create mode 100644 src/app/api/projects/[id]/files/route.ts create mode 100644 src/components/editor/FilesPanel.tsx diff --git a/src/app/api/projects/[id]/files/[name]/route.ts b/src/app/api/projects/[id]/files/[name]/route.ts new file mode 100644 index 0000000..813976b --- /dev/null +++ b/src/app/api/projects/[id]/files/[name]/route.ts @@ -0,0 +1,45 @@ +import fs from "node:fs"; +import path from "node:path"; +import { NextResponse } from "next/server"; +import { deleteProjectFile, resolveProjectFile } from "@/lib/storage"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Params = { params: { id: string; name: string } }; + +const MIME: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".pdf": "application/pdf", +}; + +// Serves a project file's bytes, used by the files panel to preview images. +export function GET(_request: Request, { params }: Params) { + const filePath = resolveProjectFile(params.id, params.name); + if (!filePath || !fs.existsSync(filePath)) { + return new Response("Not found", { status: 404 }); + } + + const data = fs.readFileSync(filePath); + const body = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + const type = MIME[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"; + + return new Response(body, { + headers: { + "Content-Type": type, + "Content-Length": String(data.byteLength), + "Cache-Control": "no-store", + }, + }); +} + +export function DELETE(_request: Request, { params }: Params) { + if (!deleteProjectFile(params.id, params.name)) { + return NextResponse.json({ error: "File not found" }, { status: 404 }); + } + return new NextResponse(null, { status: 204 }); +} diff --git a/src/app/api/projects/[id]/files/route.ts b/src/app/api/projects/[id]/files/route.ts new file mode 100644 index 0000000..64b5c3f --- /dev/null +++ b/src/app/api/projects/[id]/files/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { getEngine } from "@/lib/engines"; +import { getProject } from "@/lib/projects"; +import { listProjectFiles, safeFileName, writeProjectFile } from "@/lib/storage"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Params = { params: { id: string } }; + +// what can be uploaded: images and PDFs, the things \includegraphics handles +const UPLOADABLE = /\.(png|jpe?g|gif|webp|pdf)$/i; +const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; + +export function GET(_request: Request, { params }: Params) { + const project = getProject(params.id); + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + const mainFile = getEngine(project.engine).mainFileName; + const files = listProjectFiles(params.id).map((file) => ({ + ...file, + isMain: file.name === mainFile, + })); + return NextResponse.json(files); +} + +export async function POST(request: Request, { params }: Params) { + const project = getProject(params.id); + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + const form = await request.formData().catch(() => null); + const file = form?.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + + const name = safeFileName(file.name); + if (!name || !UPLOADABLE.test(name)) { + return NextResponse.json( + { error: "Only images and PDFs can be uploaded" }, + { status: 400 }, + ); + } + if (file.size > MAX_UPLOAD_BYTES) { + return NextResponse.json({ error: "File is too large" }, { status: 400 }); + } + + const data = Buffer.from(await file.arrayBuffer()); + if (!writeProjectFile(params.id, name, data)) { + return NextResponse.json({ error: "Could not save the file" }, { status: 400 }); + } + + return NextResponse.json({ name }, { status: 201 }); +} diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index a3a0d3e..4bd8d97 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -3,8 +3,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; +import type { EditorView } from "@codemirror/view"; import { SplitPane } from "@/components/editor/SplitPane"; 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 type { Project } from "@/lib/projects"; @@ -30,11 +32,13 @@ export default function EditorPage({ params }: { params: { id: string } }) { const [missing, setMissing] = useState(false); const [saveState, setSaveState] = useState("saved"); const [showLog, setShowLog] = useState(false); + const [showFiles, setShowFiles] = useState(false); const { compile, status, log, durationMs, pdfVersion } = useCompile(id); const sourceRef = useRef(""); const lastSavedRef = useRef(""); + const editorViewRef = useRef(null); const saveTimer = useRef>(); const compileTimer = useRef>(); @@ -105,6 +109,16 @@ export default function EditorPage({ params }: { params: { id: string } }) { compile(); } + // drop an \includegraphics line for an uploaded image at the cursor + function insertImage(name: string) { + const view = editorViewRef.current; + if (!view) return; + view.dispatch( + view.state.replaceSelection(`\\includegraphics[width=\\linewidth]{${name}}`), + ); + view.focus(); + } + async function rename(name: string) { const res = await fetch(`/api/projects/${id}`, { method: "PATCH", @@ -143,33 +157,44 @@ export default function EditorPage({ params }: { params: { id: string } }) { hasPdf={pdfVersion > 0} onCompile={manualCompile} onToggleLog={() => setShowLog((open) => !open)} + onToggleFiles={() => setShowFiles((open) => !open)} onRename={rename} /> -
- -
- +
+ {showFiles && } + +
+ +
+ { + editorViewRef.current = view; + }} + /> +
+ {showLog && ( + setShowLog(false)} + /> + )}
- {showLog && ( - setShowLog(false)} - /> - )} -
- } - right={ - - } - /> + } + right={ + + } + /> +
); diff --git a/src/components/editor/EditorPane.tsx b/src/components/editor/EditorPane.tsx index b2c9469..ae902fb 100644 --- a/src/components/editor/EditorPane.tsx +++ b/src/components/editor/EditorPane.tsx @@ -1,19 +1,24 @@ "use client"; import CodeMirror from "@uiw/react-codemirror"; +import type { EditorView } from "@codemirror/view"; import { latexExtensions } from "./latex"; interface EditorPaneProps { value: string; onChange: (value: string) => void; + // hands the underlying CodeMirror view up so the page can insert text (e.g. + // an \includegraphics line) at the cursor + onReady?: (view: EditorView) => void; } -export function EditorPane({ value, onChange }: EditorPaneProps) { +export function EditorPane({ value, onChange, onReady }: EditorPaneProps) { return (
onReady?.(view)} height="100%" theme="light" extensions={latexExtensions} diff --git a/src/components/editor/FilesPanel.tsx b/src/components/editor/FilesPanel.tsx new file mode 100644 index 0000000..2edded2 --- /dev/null +++ b/src/components/editor/FilesPanel.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { clsx } from "clsx"; +import { FileText, Trash2, Upload } from "lucide-react"; +import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; + +interface ProjectFile { + name: string; + size: number; + kind: "tex" | "image" | "pdf" | "other"; + isMain: boolean; +} + +interface FilesPanelProps { + projectId: string; + // insert a reference to an uploaded image at the editor cursor + onInsertImage: (name: string) => void; +} + +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) { + const [files, setFiles] = useState([]); + const [dragging, setDragging] = useState(false); + const [uploading, setUploading] = useState(false); + const [pendingDelete, setPendingDelete] = useState(null); + const inputRef = useRef(null); + + const refresh = useCallback(async () => { + const res = await fetch(`/api/projects/${projectId}/files`); + if (res.ok) setFiles(await res.json()); + }, [projectId]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const upload = useCallback( + async (list: FileList | File[]) => { + setUploading(true); + for (const file of Array.from(list)) { + const form = new FormData(); + form.append("file", file); + await fetch(`/api/projects/${projectId}/files`, { + method: "POST", + body: form, + }); + } + setUploading(false); + refresh(); + }, + [projectId, refresh], + ); + + async function confirmDelete() { + if (!pendingDelete) return; + const { name } = pendingDelete; + setPendingDelete(null); + await fetch(`/api/projects/${projectId}/files/${encodeURIComponent(name)}`, { + method: "DELETE", + }); + refresh(); + } + + return ( +
{ + event.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(event) => { + event.preventDefault(); + setDragging(false); + if (event.dataTransfer.files.length) upload(event.dataTransfer.files); + }} + > +
+ Files + + { + if (event.target.files?.length) upload(event.target.files); + event.target.value = ""; + }} + /> +
+ +
+ {uploading && ( +

Uploading...

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

+ Drop images here or use Upload, then click one to insert it. +

+ ) : ( +
    + {files.map((file) => ( +
  • + {file.kind === "image" ? ( + + ) : ( +
    + + {file.name} + {file.isMain && ( + + main + + )} +
    + )} + + {!file.isMain && ( + + )} + + {file.kind !== "image" && ( + + {formatSize(file.size)} + + )} +
  • + ))} +
+ )} +
+ + {pendingDelete && ( + setPendingDelete(null)} + /> + )} +
+ ); +} diff --git a/src/components/editor/Toolbar.tsx b/src/components/editor/Toolbar.tsx index 2af0aac..ccf9a75 100644 --- a/src/components/editor/Toolbar.tsx +++ b/src/components/editor/Toolbar.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; -import { ArrowLeft, Download, Play, ScrollText } from "lucide-react"; +import { ArrowLeft, Download, Images, Play, ScrollText } from "lucide-react"; import { Button } from "@/components/ui/Button"; import { engines } from "@/lib/engines"; import type { Project } from "@/lib/projects"; @@ -18,6 +18,7 @@ interface ToolbarProps { hasPdf: boolean; onCompile: () => void; onToggleLog: () => void; + onToggleFiles: () => void; onRename: (name: string) => void; } @@ -55,6 +56,7 @@ export function Toolbar({ hasPdf, onCompile, onToggleLog, + onToggleFiles, onRename, }: ToolbarProps) { const [name, setName] = useState(project.name); @@ -114,6 +116,10 @@ export function Toolbar({ )} + + diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 4e553d9..8a93605 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -37,3 +37,77 @@ export function outputPdfPath(projectId: string, engine: Engine): string { export function removeProjectFiles(projectId: string): void { fs.rmSync(projectDir(projectId), { recursive: true, force: true }); } + +export type FileKind = "tex" | "image" | "pdf" | "other"; + +export interface ProjectFile { + name: string; + size: number; + kind: FileKind; +} + +const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp"]; + +function fileKind(name: string): FileKind { + const ext = path.extname(name).toLowerCase(); + if (ext === ".tex") return "tex"; + if (ext === ".pdf") return "pdf"; + if (IMAGE_EXTENSIONS.includes(ext)) return "image"; + return "other"; +} + +// Reduce whatever the client sent to a bare filename inside the project. basename +// drops any directory part, so "../../etc/passwd" becomes "passwd" and can't +// escape the project folder. "output" is reserved for compiled artifacts. +export function safeFileName(name: string): string | null { + const base = path.basename(name).trim(); + if (!base || base === "." || base === "..") return null; + if (base === "output") return null; + if (/[/\\\0]/.test(base)) return null; + return base; +} + +// the on-disk path for a project file, or null if the name isn't safe +export function resolveProjectFile( + projectId: string, + name: string, +): string | null { + const safe = safeFileName(name); + if (!safe) return null; + return path.join(projectDir(projectId), safe); +} + +// every regular file in the project directory except the output folder +export function listProjectFiles(projectId: string): ProjectFile[] { + const dir = projectDir(projectId); + if (!fs.existsSync(dir)) return []; + + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => ({ + name: entry.name, + size: fs.statSync(path.join(dir, entry.name)).size, + kind: fileKind(entry.name), + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export function writeProjectFile( + projectId: string, + name: string, + data: Buffer, +): boolean { + const target = resolveProjectFile(projectId, name); + if (!target) return false; + ensureProjectDirs(projectId); + fs.writeFileSync(target, data); + return true; +} + +export function deleteProjectFile(projectId: string, name: string): boolean { + const target = resolveProjectFile(projectId, name); + if (!target || !fs.existsSync(target)) return false; + fs.rmSync(target); + return true; +}