From 994f9a6bf21c032c86cbe844a51bdb869ddfb258 Mon Sep 17 00:00:00 2001 From: Leonardo Salas Date: Mon, 15 Jun 2026 21:45:39 -0600 Subject: [PATCH] feat: replace recents with a projects gallery The dashboard now has a "My projects" gallery instead of a plain recents list. - Each card shows a real preview: the first page of the compiled PDF rendered with pdf.js, like a Word thumbnail, with an icon fallback before a project's first compile. - Cards show when a project was last edited and when it was created. - Pin a project to keep it at the top; pinned ones sort above the rest. - Delete from the card, behind a confirmation dialog, which removes the project and its files. - The gallery shows the first eight and expands to all on demand. Adds a pinned column (with a migration for existing databases), a setPinned repository call and PATCH support, a reusable confirmation dialog, and a danger button variant. --- src/app/api/projects/[id]/route.ts | 7 +- src/app/page.tsx | 6 +- src/components/dashboard/ProjectCard.tsx | 84 ++++++++++++++++ src/components/dashboard/ProjectThumbnail.tsx | 76 +++++++++++++++ src/components/dashboard/ProjectsGallery.tsx | 96 +++++++++++++++++++ src/components/dashboard/RecentsList.tsx | 55 ----------- src/components/ui/Button.tsx | 3 +- src/components/ui/ConfirmDialog.tsx | 54 +++++++++++ src/lib/db.ts | 12 +++ src/lib/format.ts | 5 + src/lib/projects.ts | 17 +++- 11 files changed, 353 insertions(+), 62 deletions(-) create mode 100644 src/components/dashboard/ProjectCard.tsx create mode 100644 src/components/dashboard/ProjectThumbnail.tsx create mode 100644 src/components/dashboard/ProjectsGallery.tsx delete mode 100644 src/components/dashboard/RecentsList.tsx create mode 100644 src/components/ui/ConfirmDialog.tsx 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()