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; +}