UI Components for Projects
We have a placeholder page for the project list at /. Let’s build out this page and the components it needs.
Create the ProjectCard Component
Create src/components/project-card.tsx:
import { Link } from "@tanstack/react-router";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Doc } from "../../convex/_generated/dataModel";
type ProjectCardProps = {
project: Doc<"projects">;
};
function ProjectCard({ project }: ProjectCardProps) {
return (
<Link
to="/projects/$projectId"
params={{ projectId: project._id }}
className="block"
>
<Card className="transition-shadow hover:shadow-md">
<CardHeader>
<CardTitle className="text-base">{project.name}</CardTitle>
</CardHeader>
{project.description && (
<CardContent>
<p className="line-clamp-2 text-sm text-muted-foreground">
{project.description}
</p>
</CardContent>
)}
</Card>
</Link>
);
}
export default ProjectCard;
Each card is a link to the project’s detail page. The to="/projects/$projectId" tells TanStack Router to navigate to a dynamic route, and params={{ projectId: project._id }} fills in the $projectId segment with the actual document ID.
Create the CreateProjectDialog Component
Create src/components/create-project-dialog.tsx:
import { useState } from "react";
import { Plus } from "lucide-react";
import { useNavigate } from "@tanstack/react-router";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function CreateProjectDialog() {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const createProject = useMutation(api.projects.create);
const navigate = useNavigate();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
const projectId = await createProject({
name: name.trim(),
description: description.trim(),
});
setName("");
setDescription("");
setOpen(false);
navigate({ to: "/projects/$projectId", params: { projectId } });
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Project
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium">
Name
</label>
<Input
id="name"
placeholder="Enter project name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label htmlFor="description" className="text-sm font-medium">
Description
</label>
<Textarea
id="description"
placeholder="Enter project description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
<div className="flex justify-end">
<Button type="submit">Create Project</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
export default CreateProjectDialog;
Notice the handleSubmit function is async. After createProject returns the new project’s ID, we use navigate from TanStack Router to take the user directly to the new project’s kanban board. This is why we had the projects.create mutation return the project ID earlier.
Update the Project List Page
Update src/routes/index.tsx:
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import ProjectCard from "@/components/project-card";
import CreateProjectDialog from "@/components/create-project-dialog";
export const Route = createFileRoute("/")({
component: ProjectListPage,
});
function ProjectListPage() {
const projects = useQuery(api.projects.list);
if (projects === undefined) {
return <p className="text-muted-foreground">Loading projects...</p>;
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h2 className="text-xl font-semibold">Projects</h2>
<CreateProjectDialog />
</div>
{projects.length === 0 ? (
<p className="py-12 text-center text-muted-foreground">
No projects yet. Create one to get started!
</p>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<ProjectCard key={project._id} project={project} />
))}
</div>
)}
</div>
);
}
Checkpoint: Commit your progress.
git add .
git commit -m "planner-05: Add UI components for project list and project creation"
git push