Project List
Project List is a React and Tailwind CSS data component from HextaUI, licensed MIT. Copy it and paste it into your project.
HextaUI
MIT
data
What it is
Project List is a React and Tailwind CSS data component from HextaUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
No packages beyond the project baseline
Licence
This component comes from HextaUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Project List
"use client";
import {
Archive,
Calendar,
MoreVertical,
Plus,
Search,
Trash2,
User,
X,
} from "lucide-react";
import Image from "next/image";
import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/registry/new-york/ui/avatar";
import { Badge } from "@/registry/new-york/ui/badge";
import { Button } from "@/registry/new-york/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/new-york/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/registry/new-york/ui/dropdown-menu";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/registry/new-york/ui/input-group";
import { Progress } from "@/registry/new-york/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/new-york/ui/select";
export type ProjectStatus = "active" | "archived" | "completed";
export interface ProjectMember {
id: string;
name: string;
avatar?: string;
}
export interface Project {
id: string;
name: string;
description?: string;
status: ProjectStatus;
progress: number;
thumbnail?: string;
color?: string;
members: ProjectMember[];
taskCount?: number;
completedTaskCount?: number;
createdAt: Date;
updatedAt: Date;
}
export interface ProjectListProps {
projects?: Project[];
onProjectSelect?: (projectId: string) => void;
onProjectCreate?: () => void;
onProjectArchive?: (projectId: string) => Promise<void>;
onProjectDelete?: (projectId: string) => Promise<void>;
className?: string;
showSearch?: boolean;
showFilters?: boolean;
layout?: "grid" | "list";
}
function formatDate(date: Date): string {
const now = new Date();
const month = date.toLocaleString("en-US", { month: "short" });
const day = date.getDate();
const year = date.getFullYear();
const currentYear = now.getFullYear();
if (year === currentYear) {
return `${month} ${day}`;
}
return `${month} ${day}, ${year}`;
}
function getStatusBadgeVariant(status: ProjectStatus) {
switch (status) {
case "active":
return "default";
case "completed":
return "secondary";
case "archived":
return "outline";
}
}
function getStatusLabel(status: ProjectStatus): string {
switch (status) {
case "active":
return "Active";
case "completed":
return "Completed";
case "archived":
return "Archived";
}
}
interface ProjectCardProps {
project: Project;
layout: "grid" | "list";
onProjectClick: (projectId: string) => void;
onArchive: (projectId: string) => void;
onDelete: (projectId: string) => void;
}
function ProjectCard({
project,
layout,
onProjectClick,
onArchive,
onDelete,
}: ProjectCardProps) {
if (layout === "list") {
return (
<div
className="group flex items-center gap-4 rounded-lg border bg-card p-4 transition-colors hover:bg-accent"
onClick={() => onProjectClick(project.id)}
role="button"
tabIndex={0}
>
{project.thumbnail ? (
<Image
alt={project.name}
className="size-12 rounded-lg object-cover"
height={48}
src={project.thumbnail}
width={48}
/>
) : (
<div
className="flex size-12 items-center justify-center rounded-lg font-semibold text-lg text-white"
style={{
backgroundColor: project.color || "hsl(var(--primary))",
}}
>
{project.name.charAt(0).toUpperCase()}
</div>
)}
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-center gap-2">
<h3 className="wrap-break-word font-semibold text-base">
{project.name}
</h3>
<Badge variant={getStatusBadgeVariant(project.status)}>
{getStatusLabel(project.status)}
</Badge>
</div>
{project.description && (
<p className="line-clamp-1 text-muted-foreground text-sm">
{project.description}
</p>
)}
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<User className="size-3 text-muted-foreground" />
<span className="text-muted-foreground text-xs">
{project.members.length} member
{project.members.length !== 1 ? "s" : ""}
</span>
</div>
{project.taskCount !== undefined && (
<span className="text-muted-foreground text-xs">
{project.completedTaskCount || 0} / {project.taskCount} tasks
</span>
)}
<span className="text-muted-foreground text-xs">
Updated {formatDate(project.updatedAt)}
</span>
</div>
<Progress className="h-1.5" value={project.progress} />
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={`More options for ${project.name}`}
className="opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
size="icon"
type="button"
variant="ghost"
>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onArchive(project.id)}>
<Archive className="size-4" />
Archive
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(project.id)}
variant="destructive"
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
return (
<div
className="group flex flex-col gap-3 rounded-lg border bg-card p-4 transition-all hover:shadow-md"
onClick={() => onProjectClick(project.id)}
role="button"
tabIndex={0}
>
{project.thumbnail ? (
<Image
alt={project.name}
className="h-32 w-full rounded-lg object-cover"
height={128}
src={project.thumbnail}
width={256}
/>
) : (
<div
className="flex h-32 items-center justify-center rounded-lg font-semibold text-2xl text-white"
style={{
backgroundColor: project.color || "hsl(var(--primary))",
}}
>
{project.name.charAt(0).toUpperCase()}
</div>
)}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="wrap-break-word font-semibold text-base">
{project.name}
</h3>
<Badge variant={getStatusBadgeVariant(project.status)}>
{getStatusLabel(project.status)}
</Badge>
</div>
{project.description && (
<p className="line-clamp-2 text-muted-foreground text-sm">
{project.description}
</p>
)}
<div className="flex items-center justify-between">
<div className="flex -space-x-2">
{project.members.slice(0, 4).map((member) => (
<Avatar
className="size-6 border-2 border-background"
key={member.id}
>
<AvatarImage alt={member.name} src={member.avatar} />
<AvatarFallback className="text-xs">
{member.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
))}
{project.members.length > 4 && (
<div className="flex size-6 items-center justify-center rounded-full border-2 border-background bg-muted text-muted-foreground text-xs">
+{project.members.length - 4}
</div>
)}
</div>
{project.taskCount !== undefined && (
<span className="text-muted-foreground text-xs">
{project.completedTaskCount || 0} / {project.taskCount}
</span>
)}
</div>
<Progress className="h-1.5" value={project.progress} />
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{project.progress}% complete
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={`More options for ${project.name}`}
className="opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
size="icon"
type="button"
variant="ghost"
>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onArchive(project.id)}>
<Archive className="size-4" />
Archive
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(project.id)}
variant="destructive"
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
);
}
export default function ProjectList({
projects = [],
onProjectSelect,
onProjectCreate,
onProjectArchive,
onProjectDelete,
className,
showSearch = true,
showFilters = true,
layout = "grid",
}: ProjectListProps) {
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const filteredProjects = useMemo(() => {
let filtered = [...projects];
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(project) =>
project.name.toLowerCase().includes(query) ||
project.description?.toLowerCase().includes(query)
);
}
if (statusFilter !== "all") {
filtered = filtered.filter((project) => project.status === statusFilter);
}
return filtered.sort(
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
);
}, [projects, searchQuery, statusFilter]);
const handleArchive = async (projectId: string) => {
await onProjectArchive?.(projectId);
};
const handleDelete = async (projectId: string) => {
await onProjectDelete?.(projectId);
};
return (
<Card className={cn("w-full shadow-xs", className)}>
<CardHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<CardTitle>Projects</CardTitle>
<CardDescription>
{filteredProjects.length} project
{filteredProjects.length !== 1 ? "s" : ""}
</CardDescription>
</div>
{onProjectCreate && (
<Button onClick={onProjectCreate} type="button">
<Plus className="size-4" />
New Project
</Button>
)}
</div>
{showSearch && (
<InputGroup>
<InputGroupAddon>
<Search className="size-4" />
</InputGroupAddon>
<InputGroupInput
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search projects…"
type="search"
value={searchQuery}
/>
{searchQuery && (
<Button
aria-label="Clear search"
className="absolute top-1/2 right-2 -translate-y-1/2"
onClick={() => setSearchQuery("")}
size="icon"
type="button"
variant="ghost"
>
<X className="size-4" />
</Button>
)}
</InputGroup>
)}
{showFilters && (
<Select onValueChange={setStatusFilter} value={statusFilter}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
)}
</div>
</CardHeader>
<CardContent>
{filteredProjects.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Calendar className="size-6 text-muted-foreground" />
</div>
<div className="flex flex-col gap-2">
<p className="font-medium text-sm">
{searchQuery || statusFilter !== "all"
? "No projects match your filters"
: "No projects yet"}
</p>
<p className="text-muted-foreground text-sm">
{onProjectCreate
? "Create your first project to get started"
: "Projects will appear here"}
</p>
</div>
{onProjectCreate && (
<Button onClick={onProjectCreate} type="button" variant="outline">
<Plus className="size-4" />
Create Project
</Button>
)}
</div>
) : (
<div
className={cn(
"flex gap-4",
layout === "grid"
? "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
: "flex-col"
)}
>
{filteredProjects.map((project) => (
<ProjectCard
key={project.id}
layout={layout}
onArchive={handleArchive}
onDelete={handleDelete}
onProjectClick={onProjectSelect || (() => {})}
project={project}
/>
))}
</div>
)}
</CardContent>
</Card>
);
}