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
41 changes: 40 additions & 1 deletion src/app/api/projects/[id]/files/[name]/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
".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",
Expand Down Expand Up @@ -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 });
Expand Down
76 changes: 70 additions & 6 deletions src/app/editor/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,11 +34,16 @@ export default function EditorPage({ params }: { params: { id: string } }) {
const [saveState, setSaveState] = useState<SaveState>("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<EditorView | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout>>();
const compileTimer = useRef<ReturnType<typeof setTimeout>>();
Expand All @@ -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]);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -162,7 +218,15 @@ export default function EditorPage({ params }: { params: { id: string } }) {
/>

<div className="flex min-h-0 flex-1">
{showFiles && <FilesPanel projectId={id} onInsertImage={insertImage} />}
{showFiles && (
<FilesPanel
projectId={id}
activeFile={activeFile}
onInsertImage={insertImage}
onOpenFile={openFile}
onFileDeleted={handleFileDeleted}
/>
)}

<div className="min-w-0 flex-1">
<SplitPane
Expand Down
121 changes: 95 additions & 26 deletions src/components/editor/FilesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useCallback, useEffect, useRef, useState } from "react";
import { clsx } from "clsx";
import { FileText, Trash2, Upload } from "lucide-react";
import { FilePlus, FileText, Trash2, Upload } from "lucide-react";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";

interface ProjectFile {
Expand All @@ -14,22 +14,34 @@ interface ProjectFile {

interface FilesPanelProps {
projectId: string;
// insert a reference to an uploaded image at the editor cursor
activeFile: string;
onInsertImage: (name: string) => 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<ProjectFile[]>([]);
const [dragging, setDragging] = useState(false);
const [uploading, setUploading] = useState(false);
const [pendingDelete, setPendingDelete] = useState<ProjectFile | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [newName, setNewName] = useState<string | null>(null);
const uploadInput = useRef<HTMLInputElement>(null);

const refresh = useCallback(async () => {
const res = await fetch(`/api/projects/${projectId}/files`);
Expand Down Expand Up @@ -57,14 +69,29 @@ 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;
setPendingDelete(null);
await fetch(`/api/projects/${projectId}/files/${encodeURIComponent(name)}`, {
method: "DELETE",
});
refresh();
await refresh();
if (name === activeFile) onFileDeleted(name);
}

return (
Expand All @@ -86,15 +113,26 @@ export function FilesPanel({ projectId, onInsertImage }: FilesPanelProps) {
>
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-xs font-medium text-text-muted">Files</span>
<button
onClick={() => inputRef.current?.click()}
className="flex items-center gap-1 rounded-md px-1.5 py-1 text-xs font-medium text-accent transition hover:bg-accent/10"
>
<Upload className="h-3.5 w-3.5" />
Upload
</button>
<div className="flex items-center gap-1">
<button
onClick={() => setNewName("")}
className="flex items-center gap-1 rounded-md px-1.5 py-1 text-xs font-medium text-accent transition hover:bg-accent/10"
title="New file"
>
<FilePlus className="h-3.5 w-3.5" />
New
</button>
<button
onClick={() => uploadInput.current?.click()}
className="flex items-center gap-1 rounded-md px-1.5 py-1 text-xs font-medium text-accent transition hover:bg-accent/10"
title="Upload images or PDFs"
>
<Upload className="h-3.5 w-3.5" />
Upload
</button>
</div>
<input
ref={inputRef}
ref={uploadInput}
type="file"
multiple
accept=".png,.jpg,.jpeg,.gif,.webp,.pdf"
Expand All @@ -107,13 +145,28 @@ export function FilesPanel({ projectId, onInsertImage }: FilesPanelProps) {
</div>

<div className="flex-1 overflow-auto p-2">
{newName !== null && (
<input
autoFocus
value={newName}
placeholder="name.tex"
onChange={(event) => 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 && (
<p className="px-1 py-2 text-xs text-text-muted">Uploading...</p>
)}

{files.length === 0 && !uploading ? (
{files.length === 0 && !uploading && newName === null ? (
<p className="px-1 py-6 text-center text-xs text-text-muted">
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.
</p>
) : (
<ul className="space-y-1">
Expand All @@ -135,16 +188,38 @@ export function FilesPanel({ projectId, onInsertImage }: FilesPanelProps) {
{file.name}
</span>
</button>
) : (
<div className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs">
<FileText className="h-3.5 w-3.5 shrink-0 text-text-muted" />
) : TEXT_FILE.test(file.name) ? (
<button
onClick={() => onOpenFile(file.name)}
className={clsx(
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition hover:bg-surface-2",
file.name === activeFile && "bg-accent/12 text-accent",
)}
>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{file.name}</span>
{file.isMain && (
<span className="rounded bg-accent/12 px-1 text-[10px] font-medium text-accent">
<span className="rounded bg-accent/15 px-1 text-[10px] font-medium text-accent">
main
</span>
)}
</div>
</button>
) : (
<button
onClick={() =>
INSERTABLE.test(file.name) && onInsertImage(file.name)
}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition hover:bg-surface-2"
title={
INSERTABLE.test(file.name) ? `Insert ${file.name}` : undefined
}
>
<FileText className="h-3.5 w-3.5 shrink-0 text-text-muted" />
<span className="truncate">{file.name}</span>
<span className="ml-auto text-[10px] text-text-muted/60">
{formatSize(file.size)}
</span>
</button>
)}

{!file.isMain && (
Expand All @@ -156,12 +231,6 @@ export function FilesPanel({ projectId, onInsertImage }: FilesPanelProps) {
<Trash2 className="h-3.5 w-3.5" />
</button>
)}

{file.kind !== "image" && (
<span className="px-2 text-[10px] text-text-muted/60">
{formatSize(file.size)}
</span>
)}
</li>
))}
</ul>
Expand Down
Loading
Loading