Team Member List
Team Member List é um componente de dados para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.
HextaUI
MIT
data
O que é
Team Member List é um componente de dados para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Cinco cópias grátis por mês. Sem cartão.
Não precisa de pacotes além do que o projeto já tem
Licença
Este componente vem de HextaUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.
Team Member List
"use client";
import {
Crown,
Loader2,
Mail,
MoreVertical,
Shield,
Trash2,
User,
UserCheck,
} 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/registry/new-york/ui/dropdown-menu";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/registry/new-york/ui/empty";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/registry/new-york/ui/input-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/new-york/ui/select";
import { Separator } from "@/registry/new-york/ui/separator";
export interface TeamMember {
id: string;
name: string;
email: string;
avatar?: string;
role: "owner" | "admin" | "member" | "viewer";
status: "active" | "invited" | "suspended";
lastActive?: Date;
joinedAt: Date;
aiUsage?: {
tokens: number;
sessions: number;
};
}
export interface TeamMemberListProps {
members?: TeamMember[];
currentUserId?: string;
onPromote?: (memberId: string) => Promise<void>;
onDemote?: (memberId: string) => Promise<void>;
onRemove?: (memberId: string) => Promise<void>;
onResendInvite?: (memberId: string) => Promise<void>;
className?: string;
showSearch?: boolean;
showFilters?: boolean;
showUsage?: boolean;
}
function formatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(date);
}
function formatLastActive(date?: Date): string {
if (!date) return "Never";
const now = Date.now();
const diff = now - date.getTime();
const minutes = Math.floor(diff / 60_000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return formatDate(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 getRoleIcon(role: TeamMember["role"]) {
switch (role) {
case "owner":
return Crown;
case "admin":
return Shield;
case "member":
return UserCheck;
case "viewer":
return User;
default:
return User;
}
}
function getRoleBadgeVariant(role: TeamMember["role"]) {
switch (role) {
case "owner":
return "default";
case "admin":
return "secondary";
default:
return "outline";
}
}
function getInitials(name: string): string {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
export default function TeamMemberList({
members = [],
currentUserId,
onPromote,
onDemote,
onRemove,
onResendInvite,
className,
showSearch = true,
showFilters = true,
showUsage = false,
}: TeamMemberListProps) {
const [searchQuery, setSearchQuery] = useState("");
const [roleFilter, setRoleFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [actionLoading, setActionLoading] = useState<string | null>(null);
const filteredMembers = members.filter((member) => {
const matchesSearch =
!searchQuery.trim() ||
member.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
member.email.toLowerCase().includes(searchQuery.toLowerCase());
const matchesRole = roleFilter === "all" || member.role === roleFilter;
const matchesStatus =
statusFilter === "all" || member.status === statusFilter;
return matchesSearch && matchesRole && matchesStatus;
});
const handleAction = async (
action: () => Promise<void>,
memberId: string
) => {
setActionLoading(memberId);
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 gap-1">
<CardTitle>Team Members</CardTitle>
<CardDescription>
{members.length} member{members.length !== 1 ? "s" : ""} in your
team
</CardDescription>
</div>
{showSearch && (
<InputGroup>
<InputGroupAddon>
<svg
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</InputGroupAddon>
<InputGroupInput
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearchQuery(e.target.value)
}
placeholder="Search members…"
type="search"
value={searchQuery}
/>
</InputGroup>
)}
{showFilters && (
<div className="flex flex-wrap gap-2">
<Select onValueChange={setRoleFilter} value={roleFilter}>
<SelectTrigger className="w-full md:w-[140px]">
<SelectValue placeholder="All roles" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All roles</SelectItem>
<SelectItem value="owner">Owner</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
<Select onValueChange={setStatusFilter} value={statusFilter}>
<SelectTrigger className="w-full md:w-[140px]">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="invited">Invited</SelectItem>
<SelectItem value="suspended">Suspended</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
</CardHeader>
<CardContent>
{filteredMembers.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<User className="size-6" />
</EmptyMedia>
<EmptyTitle>
{searchQuery || roleFilter !== "all" || statusFilter !== "all"
? "No members found"
: "No members yet"}
</EmptyTitle>
<EmptyDescription>
{searchQuery || roleFilter !== "all" || statusFilter !== "all"
? "Try adjusting your filters"
: "Invite members to get started"}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="flex flex-col gap-0 overflow-hidden rounded-lg border">
{filteredMembers.map((member, idx) => {
const RoleIcon = getRoleIcon(member.role);
const isCurrentUser = member.id === currentUserId;
const canModify = !isCurrentUser && member.role !== "owner";
return (
<div key={member.id}>
<div className="flex items-center gap-4 p-4 transition-colors hover:bg-muted/50">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-center gap-2">
<span className="flex items-center gap-2 font-medium text-sm">
{" "}
<Avatar className="size-8">
<AvatarImage
alt={member.name}
src={member.avatar}
/>
<AvatarFallback>
{getInitials(member.name)}
</AvatarFallback>
</Avatar>
{member.name}
</span>
{isCurrentUser && (
<Badge className="text-xs" variant="secondary">
You
</Badge>
)}
<Badge
className="text-xs"
variant={getRoleBadgeVariant(member.role)}
>
<RoleIcon className="mr-1 size-3" />
{member.role}
</Badge>
{member.status === "invited" && (
<Badge className="text-xs" variant="outline">
Invited
</Badge>
)}
{member.status === "suspended" && (
<Badge className="text-xs" variant="destructive">
Suspended
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm">
{member.email}
</p>
<div className="flex flex-wrap items-center gap-1 text-muted-foreground text-xs">
<span>Joined {formatDate(member.joinedAt)}</span>
{member.lastActive && (
<>
<span aria-hidden="true">•</span>
<span>
Active {formatLastActive(member.lastActive)}
</span>
</>
)}
{showUsage && member.aiUsage && (
<>
<span aria-hidden="true">•</span>
<span>
{formatNumber(member.aiUsage.tokens)} tokens
</span>
<span aria-hidden="true">•</span>
<span>
{member.aiUsage.sessions} session
{member.aiUsage.sessions !== 1 ? "s" : ""}
</span>
</>
)}
</div>
</div>
{canModify && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={`More options for ${member.name}`}
size="icon"
type="button"
variant="ghost"
>
{actionLoading === member.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<MoreVertical className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
collisionPadding={8}
sideOffset={4}
>
{member.role === "member" && onPromote && (
<DropdownMenuItem
onSelect={() =>
handleAction(
() => onPromote(member.id),
member.id
)
}
>
<Shield className="size-4" />
Promote to Admin
</DropdownMenuItem>
)}
{member.role === "admin" && onDemote && (
<DropdownMenuItem
onSelect={() =>
handleAction(
() => onDemote(member.id),
member.id
)
}
>
<UserCheck className="size-4" />
Demote to Member
</DropdownMenuItem>
)}
{member.status === "invited" && onResendInvite && (
<DropdownMenuItem
onSelect={() =>
handleAction(
() => onResendInvite(member.id),
member.id
)
}
>
<Mail className="size-4" />
Resend Invitation
</DropdownMenuItem>
)}
{member.role !== "viewer" && (
<DropdownMenuSeparator />
)}
{onRemove && (
<>
<DropdownMenuItem
onSelect={() =>
handleAction(
() => onRemove(member.id),
member.id
)
}
variant="destructive"
>
<Trash2 className="size-4" />
Remove Member
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{idx < filteredMembers.length - 1 && <Separator />}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}