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
10 changes: 8 additions & 2 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,17 @@ FROM node:20-bookworm-slim AS runner
WORKDIR /app

# the full TeX Live so any package a document needs is already there, including
# language support like babel-spanish. it makes the image large, but it means
# documents just compile instead of failing on a missing package.
# language support like babel-spanish. plus common free system fonts so fontspec
# works: Liberation (fontconfig even aliases "Times New Roman" to Liberation
# Serif) and Noto for CJK and Arabic. it makes the image large, but documents
# just compile instead of failing on a missing package or font.
RUN apt-get update && apt-get install -y --no-install-recommends \
texlive-full \
fontconfig \
fonts-liberation \
fonts-dejavu \
fonts-noto-core \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*

ENV NODE_ENV=production
Expand Down
6 changes: 5 additions & 1 deletion docker/Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ RUN corepack enable

WORKDIR /app

# build toolchain for better-sqlite3, plus xelatex for compiling documents
# build toolchain for better-sqlite3, plus xelatex and common free fonts
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
texlive-full \
fontconfig \
fonts-liberation \
fonts-dejavu \
fonts-noto-core \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*

ENV NEXT_TELEMETRY_DISABLED=1
Expand Down
32 changes: 32 additions & 0 deletions src/app/api/projects/[id]/files/[name]/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import { NextResponse } from "next/server";
import { getEngine } from "@/lib/engines";
import { getProject, touchUpdated } from "@/lib/projects";
import {
deleteProjectFile,
renameProjectFile,
resolveProjectFile,
safeFileName,
writeProjectFile,
Expand Down Expand Up @@ -76,6 +78,36 @@ export async function PUT(request: Request, { params }: Params) {
return NextResponse.json({ name });
}

// Renames a file. The main source can't be renamed since the engine compiles a
// fixed file name.
export async function PATCH(request: Request, { params }: Params) {
const project = getProject(params.id);
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
if (params.name === getEngine(project.engine).mainFileName) {
return NextResponse.json(
{ error: "The main file can't be renamed" },
{ status: 400 },
);
}

const body = await request.json().catch(() => ({}));
const newName = typeof body.name === "string" ? safeFileName(body.name) : null;
if (!newName) {
return NextResponse.json({ error: "Invalid name" }, { status: 400 });
}

if (!renameProjectFile(params.id, params.name, newName)) {
return NextResponse.json(
{ error: "Could not rename (the name may be taken)" },
{ status: 400 },
);
}
touchUpdated(params.id);
return NextResponse.json({ name: newName });
}

export function DELETE(_request: Request, { params }: Params) {
if (!deleteProjectFile(params.id, params.name)) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
Expand Down
8 changes: 8 additions & 0 deletions src/app/editor/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,13 @@ export default function EditorPage({ params }: { params: { id: string } }) {
openFile(mainFileRef.current);
}

// the open file was renamed: keep editing it under the new name
function handleFileRenamed(oldName: string, newName: string) {
if (oldName !== activeFileRef.current) return;
setActiveFile(newName);
activeFileRef.current = newName;
}

// jump the editor to a line, used when clicking a compile error
function goToLine(lineNumber: number) {
if (!editorView) return;
Expand Down Expand Up @@ -262,6 +269,7 @@ export default function EditorPage({ params }: { params: { id: string } }) {
onInsertImage={insertImage}
onOpenFile={openFile}
onFileDeleted={handleFileDeleted}
onFileRenamed={handleFileRenamed}
/>
)}

Expand Down
16 changes: 14 additions & 2 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,20 @@
--color-accent-soft: 220 235 255;
}

/* Dark theme tokens live here once we ship dark mode. Toggling will be a matter
of setting data-theme="dark" on <html> and overriding the values above. */
/* Dark theme. Toggling sets data-theme="dark" on <html>. The per-engine accents
above still apply, so green/blue carry over; only the neutrals change here. */
[data-theme="dark"] {
--color-bg: 16 17 21;
--color-surface: 24 25 30;
--color-surface-2: 34 36 42;
--color-border: 46 48 56;

--color-text: 236 237 242;
--color-text-muted: 150 155 165;

--color-danger: 239 105 105;
--color-warning: 234 179 8;
}

* {
border-color: rgb(var(--color-border));
Expand Down
13 changes: 11 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,27 @@ export const metadata: Metadata = {
};

export const viewport: Viewport = {
themeColor: "#ffffff",
width: "device-width",
initialScale: 1,
};

// applied before paint so there's no flash of the wrong theme on load
const themeScript = `(function(){try{var t=localStorage.getItem('texset-theme');document.documentElement.dataset.theme=t==='dark'?'dark':'light';}catch(e){}})();`;

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
<html
lang="en"
className={`${inter.variable} ${jetbrainsMono.variable}`}
suppressHydrationWarning
>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body>{children}</body>
</html>
);
Expand Down
162 changes: 109 additions & 53 deletions src/components/dashboard/ProjectCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { useState } from "react";
import Link from "next/link";
import { Pin, Trash2 } from "lucide-react";
import { Pencil, Pin, Trash2 } from "lucide-react";
import { clsx } from "clsx";
import { engines } from "@/lib/engines";
import { formatDate, formatRelativeTime } from "@/lib/format";
Expand All @@ -12,73 +13,128 @@ interface ProjectCardProps {
project: Project;
onTogglePin: (project: Project) => void;
onDelete: (project: Project) => void;
onRename: (project: Project, name: string) => void;
}

export function ProjectCard({
project,
onTogglePin,
onDelete,
onRename,
}: ProjectCardProps) {
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(project.name);

function commitRename() {
setRenaming(false);
onRename(project, renameValue);
}

const meta = (
<div className="space-y-1 p-3">
{renaming ? (
<input
autoFocus
value={renameValue}
onChange={(event) => setRenameValue(event.target.value)}
onBlur={commitRename}
onKeyDown={(event) => {
if (event.key === "Enter") commitRename();
if (event.key === "Escape") {
setRenameValue(project.name);
setRenaming(false);
}
}}
className="w-full rounded-md border border-accent bg-surface px-1.5 py-0.5 text-sm font-medium focus:outline-none"
/>
) : (
<span className="block truncate font-medium">{project.name}</span>
)}
<p className="text-xs text-text-muted" suppressHydrationWarning>
Edited {formatRelativeTime(project.updatedAt)}
</p>
<div className="flex items-center justify-between pt-0.5">
<span className="text-xs text-text-muted/70">
Created {formatDate(project.createdAt)}
</span>
<span className="rounded-md bg-accent/15 px-2 py-0.5 text-[11px] font-semibold text-accent">
{engines[project.engine].name}
</span>
</div>
</div>
);

const cardBody = (
<>
<div className="h-1 bg-accent" />
<ProjectThumbnail projectId={project.id} />
{meta}
</>
);

// data-engine sets the accent (green for LaTeX, blue for Typst) for everything
// tinted in this card: the top stripe, the badge, and the pinned ring
return (
<div className="group relative" data-engine={project.engine}>
<Link
href={`/editor/${project.id}`}
className={clsx(
"block overflow-hidden rounded-xl border bg-surface shadow-soft transition hover:-translate-y-0.5 hover:shadow-lift",
project.pinned ? "border-accent ring-1 ring-accent" : "border-border",
)}
>
<div className="h-1 bg-accent" />
<ProjectThumbnail projectId={project.id} />

<div className="space-y-1 p-3">
<span className="block truncate font-medium">{project.name}</span>
<p className="text-xs text-text-muted" suppressHydrationWarning>
Edited {formatRelativeTime(project.updatedAt)}
</p>
<div className="flex items-center justify-between pt-0.5">
<span className="text-xs text-text-muted/70">
Created {formatDate(project.createdAt)}
</span>
<span className="rounded-md bg-accent/15 px-2 py-0.5 text-[11px] font-semibold text-accent">
{engines[project.engine].name}
</span>
</div>
{renaming ? (
<div className="overflow-hidden rounded-xl border border-accent bg-surface shadow-soft ring-1 ring-accent">
{cardBody}
</div>
</Link>

{/* actions sit over the card; preventDefault keeps the link from firing */}
<div className="absolute right-2 top-3 flex gap-1">
<button
onClick={(event) => {
event.preventDefault();
onTogglePin(project);
}}
) : (
<Link
href={`/editor/${project.id}`}
className={clsx(
"flex h-7 w-7 items-center justify-center rounded-md shadow-soft backdrop-blur transition",
project.pinned
? "bg-accent text-accent-fg"
: "bg-surface/90 text-text-muted opacity-0 hover:text-text group-hover:opacity-100",
"block overflow-hidden rounded-xl border bg-surface shadow-soft transition hover:-translate-y-0.5 hover:shadow-lift",
project.pinned ? "border-accent ring-1 ring-accent" : "border-border",
)}
aria-label={project.pinned ? "Unpin" : "Pin to top"}
title={project.pinned ? "Unpin" : "Pin to top"}
>
<Pin className={clsx("h-3.5 w-3.5", project.pinned && "fill-current")} />
</button>
<button
onClick={(event) => {
event.preventDefault();
onDelete(project);
}}
className="flex h-7 w-7 items-center justify-center rounded-md bg-surface/90 text-text-muted opacity-0 shadow-soft backdrop-blur transition hover:text-danger group-hover:opacity-100"
aria-label="Delete"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{cardBody}
</Link>
)}

{!renaming && (
<div className="absolute right-2 top-3 flex gap-1">
<button
onClick={(event) => {
event.preventDefault();
onTogglePin(project);
}}
className={clsx(
"flex h-7 w-7 items-center justify-center rounded-md shadow-soft backdrop-blur transition",
project.pinned
? "bg-accent text-accent-fg"
: "bg-surface/90 text-text-muted opacity-0 hover:text-text group-hover:opacity-100",
)}
aria-label={project.pinned ? "Unpin" : "Pin to top"}
title={project.pinned ? "Unpin" : "Pin to top"}
>
<Pin className={clsx("h-3.5 w-3.5", project.pinned && "fill-current")} />
</button>
<button
onClick={(event) => {
event.preventDefault();
setRenameValue(project.name);
setRenaming(true);
}}
className="flex h-7 w-7 items-center justify-center rounded-md bg-surface/90 text-text-muted opacity-0 shadow-soft backdrop-blur transition hover:text-text group-hover:opacity-100"
aria-label="Rename"
title="Rename"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={(event) => {
event.preventDefault();
onDelete(project);
}}
className="flex h-7 w-7 items-center justify-center rounded-md bg-surface/90 text-text-muted opacity-0 shadow-soft backdrop-blur transition hover:text-danger group-hover:opacity-100"
aria-label="Delete"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
);
}
Loading
Loading