diff --git a/src/app/api/projects/[id]/route.ts b/src/app/api/projects/[id]/route.ts
index 4cd4f73..42549f4 100644
--- a/src/app/api/projects/[id]/route.ts
+++ b/src/app/api/projects/[id]/route.ts
@@ -4,6 +4,7 @@ import {
getProjectWithSource,
renameProject,
saveSource,
+ setPinned,
touchProject,
} from "@/lib/projects";
@@ -27,9 +28,10 @@ export async function PATCH(request: Request, { params }: Params) {
const hasSource = typeof body.source === "string";
const hasName = typeof body.name === "string";
- if (!hasSource && !hasName) {
+ const hasPinned = typeof body.pinned === "boolean";
+ if (!hasSource && !hasName && !hasPinned) {
return NextResponse.json(
- { error: "Provide a name or source to update" },
+ { error: "Provide a name, source, or pinned flag to update" },
{ status: 400 },
);
}
@@ -37,6 +39,7 @@ export async function PATCH(request: Request, { params }: Params) {
let project = null;
if (hasSource) project = saveSource(params.id, body.source);
if (hasName) project = renameProject(params.id, body.name);
+ if (hasPinned) project = setPinned(params.id, body.pinned);
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
diff --git a/src/app/page.tsx b/src/app/page.tsx
index ff16e16..4d4bca2 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -2,7 +2,7 @@ import { listProjects } from "@/lib/projects";
import { templates } from "@/lib/templates";
import { NewProjectButton } from "@/components/dashboard/NewProjectButton";
import { TemplateGrid } from "@/components/dashboard/TemplateGrid";
-import { RecentsList } from "@/components/dashboard/RecentsList";
+import { ProjectsGallery } from "@/components/dashboard/ProjectsGallery";
// the dashboard reads straight from the data layer on the server, so there's no
// client-side loading spinner on first paint
@@ -37,8 +37,8 @@ export default function DashboardPage() {
- Recent
-
+ My projects
+
diff --git a/src/components/dashboard/ProjectCard.tsx b/src/components/dashboard/ProjectCard.tsx
new file mode 100644
index 0000000..052090b
--- /dev/null
+++ b/src/components/dashboard/ProjectCard.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import Link from "next/link";
+import { Pin, Trash2 } from "lucide-react";
+import { clsx } from "clsx";
+import { engines } from "@/lib/engines";
+import { formatDate, formatRelativeTime } from "@/lib/format";
+import type { Project } from "@/lib/projects";
+import { ProjectThumbnail } from "./ProjectThumbnail";
+
+interface ProjectCardProps {
+ project: Project;
+ onTogglePin: (project: Project) => void;
+ onDelete: (project: Project) => void;
+}
+
+export function ProjectCard({
+ project,
+ onTogglePin,
+ onDelete,
+}: ProjectCardProps) {
+ return (
+
+
+
+
+
+
+ {project.pinned && (
+
+ )}
+
{project.name}
+
+
+ Edited {formatRelativeTime(project.updatedAt)}
+
+
+
+ Created {formatDate(project.createdAt)}
+
+
+ {engines[project.engine].name}
+
+
+
+
+
+ {/* actions sit over the card; preventDefault keeps the link from firing */}
+
+
+
+
+
+ );
+}
diff --git a/src/components/dashboard/ProjectThumbnail.tsx b/src/components/dashboard/ProjectThumbnail.tsx
new file mode 100644
index 0000000..fed5241
--- /dev/null
+++ b/src/components/dashboard/ProjectThumbnail.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { FileText } from "lucide-react";
+
+type ThumbnailState = "loading" | "ready" | "none";
+
+// Renders the first page of a project's compiled PDF as a small preview, the
+// way Word shows a document thumbnail. Falls back to an icon when the project
+// hasn't been compiled yet.
+export function ProjectThumbnail({ projectId }: { projectId: string }) {
+ const canvasRef = useRef(null);
+ const [state, setState] = useState("loading");
+
+ useEffect(() => {
+ let cancelled = false;
+ let task: { destroy: () => void } | null = null;
+
+ async function render() {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ try {
+ const pdfjs = await import("pdfjs-dist");
+ pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
+
+ const loadingTask = pdfjs.getDocument(`/api/projects/${projectId}/pdf`);
+ task = loadingTask;
+ const doc = await loadingTask.promise;
+ if (cancelled) return;
+
+ const page = await doc.getPage(1);
+ if (cancelled) return;
+
+ const width = canvas.parentElement?.clientWidth ?? 240;
+ const unscaled = page.getViewport({ scale: 1 });
+ const viewport = page.getViewport({ scale: width / unscaled.width });
+ const dpr = window.devicePixelRatio || 1;
+
+ canvas.width = Math.floor(viewport.width * dpr);
+ canvas.height = Math.floor(viewport.height * dpr);
+ canvas.style.width = "100%";
+
+ const context = canvas.getContext("2d");
+ if (!context) return;
+ context.scale(dpr, dpr);
+
+ await page.render({ canvasContext: context, viewport }).promise;
+ if (!cancelled) setState("ready");
+ } catch {
+ if (!cancelled) setState("none");
+ }
+ }
+
+ render();
+ return () => {
+ cancelled = true;
+ task?.destroy();
+ };
+ }, [projectId]);
+
+ return (
+
+
+ {state !== "ready" && (
+
+ {state === "loading" ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/dashboard/ProjectsGallery.tsx b/src/components/dashboard/ProjectsGallery.tsx
new file mode 100644
index 0000000..d9f4faa
--- /dev/null
+++ b/src/components/dashboard/ProjectsGallery.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { useState } from "react";
+import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
+import type { Project } from "@/lib/projects";
+import { ProjectCard } from "./ProjectCard";
+
+const PAGE_SIZE = 8;
+
+// pinned first, then most recently opened or edited
+function sortProjects(list: Project[]): Project[] {
+ return [...list].sort((a, b) => {
+ if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
+ return (
+ (b.lastOpenedAt ?? b.updatedAt) - (a.lastOpenedAt ?? a.updatedAt)
+ );
+ });
+}
+
+export function ProjectsGallery({
+ initialProjects,
+}: {
+ initialProjects: Project[];
+}) {
+ const [projects, setProjects] = useState(initialProjects);
+ const [showAll, setShowAll] = useState(false);
+ const [pendingDelete, setPendingDelete] = useState(null);
+
+ async function togglePin(project: Project) {
+ const pinned = !project.pinned;
+ setProjects((prev) =>
+ sortProjects(
+ prev.map((p) => (p.id === project.id ? { ...p, pinned } : p)),
+ ),
+ );
+ await fetch(`/api/projects/${project.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ pinned }),
+ });
+ }
+
+ async function confirmDelete() {
+ const target = pendingDelete;
+ if (!target) return;
+ setPendingDelete(null);
+ setProjects((prev) => prev.filter((p) => p.id !== target.id));
+ await fetch(`/api/projects/${target.id}`, { method: "DELETE" });
+ }
+
+ if (projects.length === 0) {
+ return (
+
+
+ No documents yet. Start with a blank document or pick a template above.
+
+
+ );
+ }
+
+ const visible = showAll ? projects : projects.slice(0, PAGE_SIZE);
+
+ return (
+ <>
+
+ {visible.map((project) => (
+
+ ))}
+
+
+ {projects.length > PAGE_SIZE && (
+
+ )}
+
+ {pendingDelete && (
+ setPendingDelete(null)}
+ />
+ )}
+ >
+ );
+}
diff --git a/src/components/dashboard/RecentsList.tsx b/src/components/dashboard/RecentsList.tsx
deleted file mode 100644
index 9602a60..0000000
--- a/src/components/dashboard/RecentsList.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { FileText } from "lucide-react";
-import { engines } from "@/lib/engines";
-import { formatRelativeTime } from "@/lib/format";
-import type { Project } from "@/lib/projects";
-
-export function RecentsList({ projects }: { projects: Project[] }) {
- if (projects.length === 0) {
- return (
-
-
- No documents yet. Start with a blank document or pick a template above.
-
-
- );
- }
-
- return (
-
- {projects.map((project) => (
- -
-
-
-
-
-
-
-
- {project.name}
-
-
- Edited {formatRelativeTime(project.updatedAt)}
-
-
-
-
- {engines[project.engine].name}
-
-
-
- ))}
-
- );
-}
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
index 2229a37..60fc75c 100644
--- a/src/components/ui/Button.tsx
+++ b/src/components/ui/Button.tsx
@@ -4,7 +4,7 @@ import { clsx } from "clsx";
import type { ButtonHTMLAttributes } from "react";
import type { LucideIcon } from "lucide-react";
-type Variant = "primary" | "secondary" | "ghost";
+type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md" | "lg";
interface ButtonProps extends ButtonHTMLAttributes {
@@ -18,6 +18,7 @@ const variants: Record = {
primary: "bg-accent text-accent-fg hover:brightness-105 active:brightness-95 shadow-soft",
secondary: "bg-surface text-text border border-border hover:bg-surface-2",
ghost: "text-text hover:bg-surface-2",
+ danger: "bg-danger text-white hover:brightness-105 active:brightness-95 shadow-soft",
};
const sizes: Record = {
diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx
new file mode 100644
index 0000000..e05dab8
--- /dev/null
+++ b/src/components/ui/ConfirmDialog.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useEffect } from "react";
+import { Button } from "./Button";
+
+interface ConfirmDialogProps {
+ title: string;
+ message: string;
+ confirmLabel?: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+export function ConfirmDialog({
+ title,
+ message,
+ confirmLabel = "Delete",
+ onConfirm,
+ onCancel,
+}: ConfirmDialogProps) {
+ // Escape closes the dialog, like you'd expect
+ useEffect(() => {
+ function onKey(event: KeyboardEvent) {
+ if (event.key === "Escape") onCancel();
+ }
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onCancel]);
+
+ return (
+
+
event.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ >
+
{title}
+
{message}
+
+
+
+
+
+
+ );
+}
diff --git a/src/lib/db.ts b/src/lib/db.ts
index a533ee5..e425c4b 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -23,15 +23,27 @@ function init(): Database.Database {
name TEXT NOT NULL,
engine TEXT NOT NULL DEFAULT 'latex',
template TEXT,
+ pinned INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_opened_at INTEGER
);
`);
+ migrate(db);
return db;
}
+// small forward-only migrations for databases created by earlier versions
+function migrate(db: Database.Database): void {
+ const columns = db.prepare(`PRAGMA table_info(projects)`).all() as {
+ name: string;
+ }[];
+ if (!columns.some((column) => column.name === "pinned")) {
+ db.exec(`ALTER TABLE projects ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`);
+ }
+}
+
export function getDb(): Database.Database {
if (!globalThis.__texsetDb) {
globalThis.__texsetDb = init();
diff --git a/src/lib/format.ts b/src/lib/format.ts
index b1f801c..fb4e53c 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -17,6 +17,11 @@ export function formatRelativeTime(timestamp: number): string {
const weeks = Math.round(days / 7);
if (weeks < 5) return `${weeks} week${weeks === 1 ? "" : "s"} ago`;
+ return formatDate(timestamp);
+}
+
+// a plain "Jun 15, 2025" date
+export function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
diff --git a/src/lib/projects.ts b/src/lib/projects.ts
index 72b4cf2..465c321 100644
--- a/src/lib/projects.ts
+++ b/src/lib/projects.ts
@@ -19,6 +19,7 @@ export interface Project {
name: string;
engine: EngineId;
template: string | null;
+ pinned: boolean;
createdAt: number;
updatedAt: number;
lastOpenedAt: number | null;
@@ -34,6 +35,7 @@ interface ProjectRow {
name: string;
engine: string;
template: string | null;
+ pinned: number;
created_at: number;
updated_at: number;
last_opened_at: number | null;
@@ -45,6 +47,7 @@ function toProject(row: ProjectRow): Project {
name: row.name,
engine: isEngineId(row.engine) ? row.engine : DEFAULT_ENGINE,
template: row.template,
+ pinned: row.pinned === 1,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastOpenedAt: row.last_opened_at,
@@ -89,6 +92,7 @@ export function createProject(input: CreateProjectInput = {}): Project {
name,
engine: engineId,
template: template?.id ?? null,
+ pinned: 0,
created_at: now,
updated_at: now,
last_opened_at: null,
@@ -96,9 +100,11 @@ export function createProject(input: CreateProjectInput = {}): Project {
}
export function listProjects(): Project[] {
+ // pinned projects float to the top, then most recently opened or edited first
const rows = getDb()
.prepare(
- `SELECT * FROM projects ORDER BY COALESCE(last_opened_at, updated_at) DESC`,
+ `SELECT * FROM projects
+ ORDER BY pinned DESC, COALESCE(last_opened_at, updated_at) DESC`,
)
.all() as ProjectRow[];
return rows.map(toProject);
@@ -136,6 +142,15 @@ export function saveSource(id: string, content: string): Project | null {
return getProject(id);
}
+export function setPinned(id: string, pinned: boolean): Project | null {
+ const project = getProject(id);
+ if (!project) return null;
+ getDb()
+ .prepare(`UPDATE projects SET pinned = ? WHERE id = ?`)
+ .run(pinned ? 1 : 0, id);
+ return getProject(id);
+}
+
// bump last_opened_at so the dashboard can surface recently opened projects
export function touchProject(id: string): void {
getDb()