All components

Team Projects

Team Projects is a React and Tailwind CSS component from HextaUI, licensed MIT. Copy it and paste it into your project.

HextaUI MIT other

What it is

Team Projects is a React and Tailwind CSS 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.

Open in the catalogue

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.

Team Projects

registry/new-york/blocks/team/team-projects.tsx
"use client";

import {
  Folder,
  FolderOpen,
  Loader2,
  MoreVertical,
  Plus,
  Search,
  Settings,
  Trash2,
} from "lucide-react";
import { 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 {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/registry/new-york/ui/dialog";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/registry/new-york/ui/dropdown-menu";
import {
  Empty,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/registry/new-york/ui/empty";
import { Field, FieldContent, FieldLabel } from "@/registry/new-york/ui/field";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/registry/new-york/ui/input-group";
import { Textarea } from "@/registry/new-york/ui/textarea";

export interface TeamProject {
  id: string;
  name: string;
  description?: string;
  color?: string;
  icon?: string;
  members: {
    id: string;
    name: string;
    avatar?: string;
  }[];
  defaultModel?: string;
  aiUsage?: {
    tokens: number;
    sessions: number;
  };
  createdAt: Date;
  updatedAt: Date;
}

export interface TeamProjectsProps {
  projects?: TeamProject[];
  currentUserId?: string;
  onCreate?: (data: {
    name: string;
    description?: string;
    color?: string;
    defaultModel?: string;
  }) => Promise<TeamProject>;
  onUpdate?: (projectId: string, data: Partial<TeamProject>) => Promise<void>;
  onDelete?: (projectId: string) => Promise<void>;
  onSelect?: (projectId: string) => void;
  className?: string;
  showSearch?: boolean;
}

function formatDate(date: Date): string {
  return new Intl.DateTimeFormat("en-US", {
    month: "short",
    day: "numeric",
    year: "numeric",
  }).format(date);
}

function formatNumber(num: number): string {
  if (num >= 1_000_000) {
    return `${(num / 1_000_000).toFixed(1)}M`;
  }
  if (num >= 1000) {
    return `${(num / 1000).toFixed(1)}K`;
  }
  return num.toString();
}

function getInitials(name: string): string {
  return name
    .split(" ")
    .map((n) => n[0])
    .join("")
    .toUpperCase()
    .slice(0, 2);
}

export default function TeamProjects({
  projects = [],
  currentUserId,
  onCreate,
  onUpdate,
  onDelete,
  onSelect,
  className,
  showSearch = true,
}: TeamProjectsProps) {
  const [searchQuery, setSearchQuery] = useState("");
  const [createDialogOpen, setCreateDialogOpen] = useState(false);
  const [isCreating, setIsCreating] = useState(false);
  const [actionLoading, setActionLoading] = useState<string | null>(null);
  const [projectData, setProjectData] = useState({
    name: "",
    description: "",
    color: "#3b82f6",
    defaultModel: "",
  });

  const filteredProjects = projects.filter((project) => {
    if (!searchQuery.trim()) return true;
    const query = searchQuery.toLowerCase();
    return (
      project.name.toLowerCase().includes(query) ||
      project.description?.toLowerCase().includes(query)
    );
  });

  const handleCreate = async () => {
    if (!(projectData.name.trim() && onCreate)) return;

    setIsCreating(true);
    try {
      await onCreate(projectData);
      setProjectData({
        name: "",
        description: "",
        color: "#3b82f6",
        defaultModel: "",
      });
      setCreateDialogOpen(false);
    } finally {
      setIsCreating(false);
    }
  };

  const handleAction = async (
    action: () => Promise<void>,
    projectId: string
  ) => {
    setActionLoading(projectId);
    try {
      await action();
    } finally {
      setActionLoading(null);
    }
  };

  return (
    <Card className={cn("w-full shadow-xs", className)}>
      <CardHeader>
        <div className="flex flex-col gap-4">
          <div className="flex flex-col flex-wrap gap-3 md:flex-row md:items-start md:justify-between">
            <div className="flex min-w-0 flex-1 flex-col gap-2">
              <CardTitle>Projects</CardTitle>
              <CardDescription>
                {projects.length} project{projects.length !== 1 ? "s" : ""} in
                your team
              </CardDescription>
            </div>
            {onCreate && (
              <Dialog
                onOpenChange={setCreateDialogOpen}
                open={createDialogOpen}
              >
                <DialogTrigger asChild>
                  <Button className="w-full shrink-0 md:w-auto" type="button">
                    <Plus className="size-4" />
                    New Project
                  </Button>
                </DialogTrigger>
                <DialogContent className="md:max-w-md">
                  <DialogHeader>
                    <DialogTitle>Create Project</DialogTitle>
                    <DialogDescription>
                      Create a new team project workspace
                    </DialogDescription>
                  </DialogHeader>
                  <div className="flex flex-col gap-4">
                    <Field>
                      <FieldLabel htmlFor="project-name">Name</FieldLabel>
                      <FieldContent>
                        <InputGroup>
                          <InputGroupInput
                            id="project-name"
                            onChange={(
                              e: React.ChangeEvent<HTMLInputElement>
                            ) =>
                              setProjectData((prev) => ({
                                ...prev,
                                name: e.target.value,
                              }))
                            }
                            placeholder="Project name…"
                            type="text"
                            value={projectData.name}
                          />
                        </InputGroup>
                      </FieldContent>
                    </Field>
                    <Field>
                      <FieldLabel htmlFor="project-description">
                        Description
                      </FieldLabel>
                      <FieldContent>
                        <Textarea
                          id="project-description"
                          onChange={(
                            e: React.ChangeEvent<HTMLTextAreaElement>
                          ) =>
                            setProjectData((prev) => ({
                              ...prev,
                              description: e.target.value,
                            }))
                          }
                          placeholder="What is this project about?"
                          rows={3}
                          value={projectData.description}
                        />
                      </FieldContent>
                    </Field>
                    <Field>
                      <FieldLabel htmlFor="project-color">Color</FieldLabel>
                      <FieldContent>
                        <div className="flex items-center gap-3">
                          <input
                            className="size-10 rounded border"
                            id="project-color"
                            onChange={(
                              e: React.ChangeEvent<HTMLInputElement>
                            ) =>
                              setProjectData((prev) => ({
                                ...prev,
                                color: e.target.value,
                              }))
                            }
                            type="color"
                            value={projectData.color}
                          />
                          <InputGroup className="flex-1">
                            <InputGroupInput
                              onChange={(
                                e: React.ChangeEvent<HTMLInputElement>
                              ) =>
                                setProjectData((prev) => ({
                                  ...prev,
                                  color: e.target.value,
                                }))
                              }
                              placeholder="#3b82f6"
                              type="text"
                              value={projectData.color}
                            />
                          </InputGroup>
                        </div>
                      </FieldContent>
                    </Field>
                  </div>
                  <DialogFooter>
                    <Button
                      onClick={() => setCreateDialogOpen(false)}
                      type="button"
                      variant="outline"
                    >
                      Cancel
                    </Button>
                    <Button
                      aria-busy={isCreating}
                      data-loading={isCreating}
                      disabled={!projectData.name.trim()}
                      onClick={handleCreate}
                      type="button"
                    >
                      {isCreating ? (
                        <>
                          <Loader2 className="size-4 animate-spin" />
                          Creating…
                        </>
                      ) : (
                        "Create Project"
                      )}
                    </Button>
                  </DialogFooter>
                </DialogContent>
              </Dialog>
            )}
          </div>
          {showSearch && (
            <InputGroup>
              <InputGroupAddon>
                <Search className="size-4" />
              </InputGroupAddon>
              <InputGroupInput
                onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
                  setSearchQuery(e.target.value)
                }
                placeholder="Search projects…"
                type="search"
                value={searchQuery}
              />
            </InputGroup>
          )}
        </div>
      </CardHeader>
      <CardContent>
        {filteredProjects.length === 0 ? (
          <Empty>
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <Folder className="size-6" />
              </EmptyMedia>
              <EmptyTitle>
                {searchQuery ? "No projects found" : "No projects yet"}
              </EmptyTitle>
            </EmptyHeader>
          </Empty>
        ) : (
          <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
            {filteredProjects.map((project) => (
              <div
                className="group flex flex-col justify-between gap-3 rounded-lg border bg-card p-4 transition-colors hover:border-primary hover:shadow-sm"
                key={project.id}
              >
                <div className="flex items-start justify-between gap-2">
                  <div className="flex items-center gap-2">
                    <div
                      className="flex size-10 items-center justify-center rounded-lg"
                      style={{ backgroundColor: `${project.color}20` }}
                    >
                      {project.icon ? (
                        <span className="text-lg">{project.icon}</span>
                      ) : (
                        <Folder
                          className="size-5"
                          style={{ color: project.color }}
                        />
                      )}
                    </div>
                    <div className="flex min-w-0 flex-1 flex-col gap-2">
                      <h3 className="wrap-break-word font-semibold text-base leading-tight">
                        {project.name}
                      </h3>
                      {project.defaultModel && (
                        <Badge className="w-fit text-xs" variant="outline">
                          {project.defaultModel}
                        </Badge>
                      )}
                    </div>
                  </div>
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <Button
                        aria-label={`More options for ${project.name}`}
                        size="icon-sm"
                        type="button"
                        variant="ghost"
                      >
                        {actionLoading === project.id ? (
                          <Loader2 className="size-4 animate-spin" />
                        ) : (
                          <MoreVertical className="size-4" />
                        )}
                      </Button>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent
                      align="end"
                      collisionPadding={8}
                      sideOffset={4}
                    >
                      {onSelect && (
                        <DropdownMenuItem onSelect={() => onSelect(project.id)}>
                          <FolderOpen className="size-4" />
                          Open
                        </DropdownMenuItem>
                      )}
                      {onUpdate && (
                        <>
                          <DropdownMenuSeparator />
                          <DropdownMenuItem
                            onSelect={() => onUpdate(project.id, {})}
                          >
                            <Settings className="size-4" />
                            Settings
                          </DropdownMenuItem>
                        </>
                      )}
                      {onDelete && (
                        <>
                          <DropdownMenuSeparator />
                          <DropdownMenuItem
                            onSelect={() =>
                              handleAction(
                                () => onDelete(project.id),
                                project.id
                              )
                            }
                            variant="destructive"
                          >
                            <Trash2 className="size-4" />
                            Delete
                          </DropdownMenuItem>
                        </>
                      )}
                    </DropdownMenuContent>
                  </DropdownMenu>
                </div>
                {project.description && (
                  <p className="wrap-break-word line-clamp-2 text-muted-foreground text-sm">
                    {project.description}
                  </p>
                )}
                {project.aiUsage && (
                  <div className="flex items-center gap-2 text-muted-foreground text-xs">
                    <span>{formatNumber(project.aiUsage.tokens)} tokens</span>
                    <span aria-hidden="true">•</span>
                    <span>
                      {project.aiUsage.sessions} session
                      {project.aiUsage.sessions !== 1 ? "s" : ""}
                    </span>
                  </div>
                )}
                <div className="flex items-center justify-between">
                  <div className="flex -space-x-2">
                    {project.members.slice(0, 3).map((member) => (
                      <Avatar
                        className="size-6 border-2 border-background"
                        key={member.id}
                      >
                        <AvatarImage alt={member.name} src={member.avatar} />
                        <AvatarFallback className="text-xs">
                          {getInitials(member.name)}
                        </AvatarFallback>
                      </Avatar>
                    ))}
                    {project.members.length > 3 && (
                      <div className="flex size-6 items-center justify-center rounded-full border-2 border-background bg-muted">
                        <span className="text-muted-foreground text-xs">
                          +{project.members.length - 3}
                        </span>
                      </div>
                    )}
                  </div>
                  <span className="text-muted-foreground text-xs">
                    Updated {formatDate(project.updatedAt)}
                  </span>
                </div>
              </div>
            ))}
          </div>
        )}
      </CardContent>
    </Card>
  );
}

Similar components