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
11 changes: 4 additions & 7 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,11 @@ RUN pnpm build
FROM node:20-bookworm-slim AS runner
WORKDIR /app

# xelatex and the package sets our starter templates rely on. --no-install-recommends
# keeps the image from ballooning with docs and language packs we don't need.
# 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.
RUN apt-get update && apt-get install -y --no-install-recommends \
texlive-xetex \
texlive-latex-recommended \
texlive-latex-extra \
texlive-fonts-recommended \
lmodern \
texlive-full \
fontconfig \
&& rm -rf /var/lib/apt/lists/*

Expand Down
6 changes: 1 addition & 5 deletions docker/Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,7 @@ WORKDIR /app
# build toolchain for better-sqlite3, plus xelatex for compiling documents
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
texlive-xetex \
texlive-latex-recommended \
texlive-latex-extra \
texlive-fonts-recommended \
lmodern \
texlive-full \
fontconfig \
&& rm -rf /var/lib/apt/lists/*

Expand Down
Binary file added public/templates/article.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/templates/letter.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/templates/presentation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/templates/resume.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions src/app/editor/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ export default function EditorPage({ params }: { params: { id: string } }) {
};
}, [save]);

// show the project name in the browser tab
useEffect(() => {
if (project) document.title = `${project.name} · TexSet`;
return () => {
document.title = "TexSet";
};
}, [project]);

function handleChange(value: string) {
setSource(value);
sourceRef.current = value;
Expand Down
66 changes: 66 additions & 0 deletions src/components/dashboard/ImportButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { FileUp } from "lucide-react";
import { Button } from "@/components/ui/Button";

// Opens a .tex file and turns it into a new project, then jumps into the editor.
// Typst (.typ) joins the accepted types once that engine exists.
export function ImportButton() {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);

async function importFile(file: File) {
setBusy(true);
try {
const content = await file.text();
const name = file.name.replace(/\.tex$/i, "").trim() || "Imported document";

const created = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!created.ok) throw new Error("Could not create the project");
const project = await created.json();

await fetch(`/api/projects/${project.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: content }),
});

router.push(`/editor/${project.id}`);
} catch (err) {
console.error(err);
setBusy(false);
}
}

return (
<>
<Button
variant="secondary"
icon={FileUp}
loading={busy}
className="w-full"
onClick={() => inputRef.current?.click()}
>
Import .tex
</Button>
<input
ref={inputRef}
type="file"
accept=".tex"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) importFile(file);
event.target.value = "";
}}
/>
</>
);
}
6 changes: 5 additions & 1 deletion src/components/dashboard/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NewProjectButton } from "./NewProjectButton";
import { ImportButton } from "./ImportButton";

// Left navigation for the dashboard. Holds the brand, the primary action, and
// jumps to the page sections. Roomy enough to grow an engine filter and a theme
Expand All @@ -10,7 +11,10 @@ export function Sidebar() {
Tex<span className="text-accent">Set</span>
</span>

<NewProjectButton />
<div className="flex flex-col gap-2">
<NewProjectButton />
<ImportButton />
</div>

<nav className="flex flex-col gap-0.5 text-sm">
<a
Expand Down
41 changes: 31 additions & 10 deletions src/components/dashboard/TemplateGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useState } from "react";
import {
FileText,
IdCard,
Expand All @@ -26,31 +27,51 @@ const icons: Record<string, LucideIcon> = {

export function TemplateGrid({ templates }: { templates: TemplateCard[] }) {
const { create, pending } = useCreateProject();
// templates whose preview image failed to load fall back to an icon
const [noPreview, setNoPreview] = useState<Set<string>>(new Set());

return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{templates.map((template) => {
const Icon = icons[template.id] ?? FileText;
const isPending = pending === template.id;
const showImage = !noPreview.has(template.id);

return (
<button
key={template.id}
data-engine={template.engine}
onClick={() => create({ templateId: template.id }, template.id)}
disabled={isPending}
className="group relative flex flex-col items-start gap-3 overflow-hidden rounded-xl border border-border bg-surface p-4 pt-5 text-left shadow-soft transition hover:-translate-y-0.5 hover:shadow-lift disabled:opacity-60"
className="group relative overflow-hidden rounded-xl border border-border bg-surface text-left shadow-soft transition hover:-translate-y-0.5 hover:shadow-lift disabled:opacity-60"
>
<span className="absolute inset-x-0 top-0 h-1 bg-accent" />
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/15 text-accent">
{isPending ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<div className="h-1 bg-accent" />

<div className="relative flex h-36 items-center justify-center overflow-hidden border-b border-border bg-white">
{showImage ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={`/templates/${template.id}.png`}
alt={`${template.name} preview`}
className="h-full w-full object-contain object-top"
onError={() =>
setNoPreview((prev) => new Set(prev).add(template.id))
}
/>
) : (
<Icon className="h-5 w-5" />
<Icon className="h-10 w-10 text-accent" />
)}
{isPending && (
<span className="absolute inset-0 flex items-center justify-center bg-surface/70">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-accent border-t-transparent" />
</span>
)}
</span>
<span className="font-medium text-text">{template.name}</span>
<span className="text-sm text-text-muted">{template.description}</span>
</div>

<div className="space-y-1 p-3">
<span className="font-medium text-text">{template.name}</span>
<p className="text-sm text-text-muted">{template.description}</p>
</div>
</button>
);
})}
Expand Down
31 changes: 22 additions & 9 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 { FilePlus, FileText, Trash2, Upload } from "lucide-react";
import { Download, FilePlus, FileText, Trash2, Upload } from "lucide-react";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";

interface ProjectFile {
Expand Down Expand Up @@ -222,15 +222,28 @@ export function FilesPanel({
</button>
)}

{!file.isMain && (
<button
onClick={() => setPendingDelete(file)}
className="absolute right-1 top-1 hidden rounded-md bg-surface/90 p-1 text-text-muted shadow-soft transition hover:text-danger group-hover:block"
aria-label={`Delete ${file.name}`}
<div className="absolute right-1 top-1 hidden gap-1 group-hover:flex">
<a
href={`/api/projects/${projectId}/files/${encodeURIComponent(file.name)}`}
download={file.name}
onClick={(event) => event.stopPropagation()}
className="rounded-md bg-surface/90 p-1 text-text-muted shadow-soft transition hover:text-text"
aria-label={`Download ${file.name}`}
title="Download"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
<Download className="h-3.5 w-3.5" />
</a>
{!file.isMain && (
<button
onClick={() => setPendingDelete(file)}
className="rounded-md bg-surface/90 p-1 text-text-muted shadow-soft transition hover:text-danger"
aria-label={`Delete ${file.name}`}
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</li>
))}
</ul>
Expand Down
Loading