Skip to content
Merged
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
45 changes: 45 additions & 0 deletions src/app/api/projects/[id]/files/[name]/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
".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 });
}
58 changes: 58 additions & 0 deletions src/app/api/projects/[id]/files/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
71 changes: 48 additions & 23 deletions src/app/editor/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,11 +32,13 @@ export default function EditorPage({ params }: { params: { id: string } }) {
const [missing, setMissing] = useState(false);
const [saveState, setSaveState] = useState<SaveState>("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<EditorView | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout>>();
const compileTimer = useRef<ReturnType<typeof setTimeout>>();

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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}
/>

<div className="min-h-0 flex-1">
<SplitPane
left={
<div className="flex h-full flex-col">
<div className="min-h-0 flex-1">
<EditorPane value={source} onChange={handleChange} />
<div className="flex min-h-0 flex-1">
{showFiles && <FilesPanel projectId={id} onInsertImage={insertImage} />}

<div className="min-w-0 flex-1">
<SplitPane
left={
<div className="flex h-full flex-col">
<div className="min-h-0 flex-1">
<EditorPane
value={source}
onChange={handleChange}
onReady={(view) => {
editorViewRef.current = view;
}}
/>
</div>
{showLog && (
<CompileLog
log={log}
status={status}
onClose={() => setShowLog(false)}
/>
)}
</div>
{showLog && (
<CompileLog
log={log}
status={status}
onClose={() => setShowLog(false)}
/>
)}
</div>
}
right={
<PreviewPane
projectId={id}
version={pdfVersion}
documentStatus={status}
/>
}
/>
}
right={
<PreviewPane
projectId={id}
version={pdfVersion}
documentStatus={status}
/>
}
/>
</div>
</div>
</div>
);
Expand Down
7 changes: 6 additions & 1 deletion src/components/editor/EditorPane.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="h-full overflow-hidden bg-surface">
<CodeMirror
value={value}
onChange={onChange}
onCreateEditor={(view) => onReady?.(view)}
height="100%"
theme="light"
extensions={latexExtensions}
Expand Down
Loading
Loading