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
7 changes: 5 additions & 2 deletions src/app/api/projects/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getProjectWithSource,
renameProject,
saveSource,
setPinned,
touchProject,
} from "@/lib/projects";

Expand All @@ -27,16 +28,18 @@ 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 },
);
}

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 });
Expand Down
6 changes: 3 additions & 3 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,8 +37,8 @@ export default function DashboardPage() {
</section>

<section className="space-y-3">
<h2 className="text-sm font-medium text-text-muted">Recent</h2>
<RecentsList projects={projects} />
<h2 className="text-sm font-medium text-text-muted">My projects</h2>
<ProjectsGallery initialProjects={projects} />
</section>
</main>
</div>
Expand Down
84 changes: 84 additions & 0 deletions src/components/dashboard/ProjectCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="group relative">
<Link
href={`/editor/${project.id}`}
className="block overflow-hidden rounded-xl border border-border bg-surface shadow-soft transition hover:-translate-y-0.5 hover:shadow-lift"
>
<ProjectThumbnail projectId={project.id} />

<div className="space-y-1 p-3">
<div className="flex items-center gap-1.5">
{project.pinned && (
<Pin className="h-3 w-3 shrink-0 fill-accent text-accent" />
)}
<span className="truncate font-medium">{project.name}</span>
</div>
<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
data-engine={project.engine}
className="rounded bg-accent/12 px-1.5 py-0.5 text-[10px] font-medium text-accent"
>
{engines[project.engine].name}
</span>
</div>
</div>
</Link>

{/* actions sit over the card; preventDefault keeps the link from firing */}
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition group-hover:opacity-100">
<button
onClick={(event) => {
event.preventDefault();
onTogglePin(project);
}}
className={clsx(
"flex h-7 w-7 items-center justify-center rounded-md bg-surface/90 shadow-soft backdrop-blur transition hover:bg-surface",
project.pinned ? "text-accent" : "text-text-muted hover:text-text",
)}
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-accent")} />
</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 shadow-soft backdrop-blur transition hover:bg-surface hover:text-danger"
aria-label="Delete"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
76 changes: 76 additions & 0 deletions src/components/dashboard/ProjectThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLCanvasElement>(null);
const [state, setState] = useState<ThumbnailState>("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 (
<div className="relative h-44 overflow-hidden border-b border-border bg-white">
<canvas ref={canvasRef} className={state === "ready" ? "block" : "hidden"} />
{state !== "ready" && (
<div className="absolute inset-0 flex items-center justify-center bg-surface-2">
{state === "loading" ? (
<span className="h-5 w-5 animate-spin rounded-full border-2 border-accent border-t-transparent" />
) : (
<FileText className="h-8 w-8 text-text-muted/40" />
)}
</div>
)}
</div>
);
}
96 changes: 96 additions & 0 deletions src/components/dashboard/ProjectsGallery.tsx
Original file line number Diff line number Diff line change
@@ -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<Project | null>(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 (
<div className="rounded-xl border border-dashed border-border bg-surface/50 p-10 text-center">
<p className="text-text-muted">
No documents yet. Start with a blank document or pick a template above.
</p>
</div>
);
}

const visible = showAll ? projects : projects.slice(0, PAGE_SIZE);

return (
<>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{visible.map((project) => (
<ProjectCard
key={project.id}
project={project}
onTogglePin={togglePin}
onDelete={setPendingDelete}
/>
))}
</div>

{projects.length > PAGE_SIZE && (
<button
onClick={() => setShowAll((open) => !open)}
className="mt-4 text-sm font-medium text-accent transition hover:brightness-110"
>
{showAll ? "Show less" : `Show all ${projects.length}`}
</button>
)}

{pendingDelete && (
<ConfirmDialog
title="Delete document"
message={`"${pendingDelete.name}" and its files will be permanently removed. This can't be undone.`}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
)}
</>
);
}
55 changes: 0 additions & 55 deletions src/components/dashboard/RecentsList.tsx

This file was deleted.

3 changes: 2 additions & 1 deletion src/components/ui/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement> {
Expand All @@ -18,6 +18,7 @@ const variants: Record<Variant, string> = {
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<Size, string> = {
Expand Down
Loading
Loading