Grid
7 blocksGrid 01
Preview
npx nateui@latest add EmployeeDirectoryGridDark
import { useMemo, useState } from "react";
import Avatar from "@/components/ui/Avatar";
import Button from "@/components/ui/Button";
import Card from "@/components/ui/Card";
import Dropdown from "@/components/ui/Dropdown";
import Input from "@/components/ui/Input";
import Pagination from "@/components/ui/Pagination";
import Select from "@/components/ui/Select";
import {
PiArrowsDownUp,
PiBriefcase,
PiCaretDown,
PiDotsThreeVerticalBold,
PiEnvelopeSimple,
PiMagnifyingGlass,
PiPhone,
PiSliders,
} from "react-icons/pi";
const assetBase = "https://statics.nateui.com/img";
type Employee = {
id: string;
name: string;
role: string;
email: string;
phone: string;
avatar: string;
};
const employees: Employee[] = [
{
id: "e1",
name: "Priya Chandran",
role: "Product Designer",
email: "priya.chandran@corvexlabs.com",
phone: "+1 (415) 555-0148",
avatar: `${assetBase}/avatars/thumb-1.jpg`,
},
{
id: "e2",
name: "Oliver Bennett",
role: "Software Engineer",
email: "oliver.bennett@corvexlabs.com",
phone: "+44 20 7946 0958",
avatar: `${assetBase}/avatars/thumb-2.jpg`,
},
{
id: "e3",
name: "Naledi Khumalo",
role: "HR Business Partner",
email: "naledi.khumalo@corvexlabs.com",
phone: "+27 11 234 5678",
avatar: `${assetBase}/avatars/thumb-3.jpg`,
},
{
id: "e4",
name: "Diego Fuentes",
role: "Marketing Manager",
email: "diego.fuentes@corvexlabs.com",
phone: "+1 (312) 555-0199",
avatar: `${assetBase}/avatars/thumb-4.jpg`,
},
{
id: "e5",
name: "Hana Kobayashi",
role: "Financial Analyst",
email: "hana.kobayashi@corvexlabs.com",
phone: "+81 3 4567 8901",
avatar: `${assetBase}/avatars/thumb-5.jpg`,
},
{
id: "e6",
name: "Liam O'Connor",
role: "Sales Representative",
email: "liam.oconnor@corvexlabs.com",
phone: "+1 (646) 555-0173",
avatar: `${assetBase}/avatars/thumb-6.jpg`,
},
{
id: "e7",
name: "Fatima Al-Sayed",
role: "Operations Lead",
email: "fatima.alsayed@corvexlabs.com",
phone: "+49 30 9876 5432",
avatar: `${assetBase}/avatars/thumb-7.jpg`,
},
{
id: "e8",
name: "Marcus Delgado",
role: "DevOps Engineer",
email: "marcus.delgado@corvexlabs.com",
phone: "+1 (503) 555-0166",
avatar: `${assetBase}/avatars/thumb-8.jpg`,
},
{
id: "e9",
name: "Ingrid Sorensen",
role: "Content Strategist",
email: "ingrid.sorensen@corvexlabs.com",
phone: "+46 8 123 456 78",
avatar: `${assetBase}/avatars/thumb-9.jpg`,
},
{
id: "e10",
name: "Tariq Osei",
role: "Account Executive",
email: "tariq.osei@corvexlabs.com",
phone: "+233 30 222 5566",
avatar: `${assetBase}/avatars/thumb-10.jpg`,
},
{
id: "e11",
name: "Chloe Bergstrom",
role: "Data Analyst",
email: "chloe.bergstrom@corvexlabs.com",
phone: "+1 (415) 555-0122",
avatar: `${assetBase}/avatars/thumb-11.jpg`,
},
{
id: "e12",
name: "Rafael Nunes",
role: "Support Specialist",
email: "rafael.nunes@corvexlabs.com",
phone: "+34 91 234 5678",
avatar: `${assetBase}/avatars/thumb-12.jpg`,
},
{
id: "e13",
name: "Sienna Marlowe",
role: "QA Engineer",
email: "sienna.marlowe@corvexlabs.com",
phone: "+1 (773) 555-0142",
avatar: `${assetBase}/avatars/thumb-13.jpg`,
},
{
id: "e14",
name: "Kwame Asante",
role: "Talent Acquisition Lead",
email: "kwame.asante@corvexlabs.com",
phone: "+971 4 123 4567",
avatar: `${assetBase}/avatars/thumb-14.jpg`,
},
{
id: "e15",
name: "Noor Haddad",
role: "Finance Manager",
email: "noor.haddad@corvexlabs.com",
phone: "+1 (212) 555-0187",
avatar: `${assetBase}/avatars/thumb-15.jpg`,
},
{
id: "e16",
name: "Theo Whitfield",
role: "Growth Marketer",
email: "theo.whitfield@corvexlabs.com",
phone: "+61 2 8123 4567",
avatar: `${assetBase}/avatars/thumb-16.jpg`,
},
];
const employmentTypeOptions = ["Full-time", "Part-time", "Contract", "Intern"];
const departmentOptions = [
"Engineering",
"Design",
"People",
"Marketing",
"Finance",
"Sales",
"Operations",
];
const pageSizeOptions = [
{ value: 8, label: "8 / page" },
{ value: 16, label: "16 / page" },
];
export default function EmployeeDirectoryGrid() {
const [search, setSearch] = useState("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [pageSize, setPageSize] = useState(8);
const [page, setPage] = useState(1);
const filteredEmployees = useMemo(() => {
const query = search.trim().toLowerCase();
const matched = query
? employees.filter(
(employee) =>
employee.name.toLowerCase().includes(query) ||
employee.role.toLowerCase().includes(query) ||
employee.email.toLowerCase().includes(query),
)
: employees;
return [...matched].sort((a, b) =>
sortOrder === "asc"
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name),
);
}, [search, sortOrder]);
const pageCount = Math.max(
1,
Math.ceil(filteredEmployees.length / pageSize),
);
const currentPage = Math.min(page, pageCount);
const pageItems = filteredEmployees.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const currentPageSizeOption =
pageSizeOptions.find((option) => option.value === pageSize) ??
pageSizeOptions[0];
return (
<section aria-label="Employee directory" className="w-full space-y-4">
<div>
<h4 className="text-xl font-semibold text-foreground">
Employees
</h4>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 flex-wrap items-center gap-2">
<Input
prefix={<PiMagnifyingGlass />}
placeholder="Search employees..."
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
className="w-full sm:w-64"
/>
<Dropdown
placement="bottom-start"
renderTitle={
<Button variant="default" icon={<PiSliders />}>
Employment Type
</Button>
}
>
{employmentTypeOptions.map((option) => (
<Dropdown.Item key={option} eventKey={option}>
{option}
</Dropdown.Item>
))}
</Dropdown>
<Dropdown
placement="bottom-start"
renderTitle={
<Button variant="default" icon={<PiSliders />}>
Department
</Button>
}
>
{departmentOptions.map((option) => (
<Dropdown.Item key={option} eventKey={option}>
{option}
</Dropdown.Item>
))}
</Dropdown>
</div>
<div className="flex flex-wrap items-center gap-2">
<Dropdown
placement="bottom-end"
activeKey={sortOrder}
onSelect={(eventKey) => {
setSortOrder(eventKey as "asc" | "desc");
setPage(1);
}}
renderTitle={
<Button variant="default" icon={<PiArrowsDownUp />}>
Sort
</Button>
}
>
<Dropdown.Item eventKey="asc">Name (A–Z)</Dropdown.Item>
<Dropdown.Item eventKey="desc">
Name (Z–A)
</Dropdown.Item>
</Dropdown>
<Dropdown
placement="bottom-end"
renderTitle={
<Button
variant="solid"
icon={<PiCaretDown />}
iconAlignment="end"
>
Add Employee
</Button>
}
>
<Dropdown.Item eventKey="manual">
Add manually
</Dropdown.Item>
<Dropdown.Item eventKey="import">
Import from CSV
</Dropdown.Item>
</Dropdown>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{pageItems.map((employee) => (
<Card key={employee.id} className="group relative">
<div className="flex items-center gap-3">
<Avatar
size="md"
src={employee.avatar}
alt={employee.name}
/>
<span className="min-w-0 flex-1 truncate font-medium text-card-foreground">
{employee.name}
</span>
<Dropdown
placement="bottom-end"
renderTitle={
<Button
variant="ghost"
size="sm"
icon={<PiDotsThreeVerticalBold />}
aria-label={`More actions for ${employee.name}`}
className="opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100"
/>
}
>
<Dropdown.Item eventKey="view">
View profile
</Dropdown.Item>
<Dropdown.Item eventKey="edit">
Edit details
</Dropdown.Item>
<Dropdown.Item eventKey="remove">
Remove
</Dropdown.Item>
</Dropdown>
</div>
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2">
<PiBriefcase className="text-base" />
<span className="truncate">
{employee.role}
</span>
</div>
<div className="flex items-center gap-2">
<PiEnvelopeSimple className="text-base" />
<span className="truncate">
{employee.email}
</span>
</div>
<div className="flex items-center gap-2">
<PiPhone className="text-base" />
<span className="truncate">
{employee.phone}
</span>
</div>
</div>
</Card>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-4">
<Pagination
total={filteredEmployees.length}
pageSize={pageSize}
currentPage={currentPage}
onChange={(pageNumber) => setPage(pageNumber)}
/>
<Select
className="w-32"
value={currentPageSizeOption}
options={pageSizeOptions}
onChange={(option) => {
setPageSize(option.value);
setPage(1);
}}
/>
</div>
</section>
);
}
Grid 02
Preview
npx nateui@latest add IntegrationSettingsGridDark
import { useMemo, useState } from 'react';
import Avatar from '@/components/ui/Avatar';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Switcher from '@/components/ui/Switcher';
const assetBase = 'https://statics.nateui.com/img';
type IntegrationType = 'automation' | 'storageCollaboration';
type Integration = {
id: string;
name: string;
desc: string;
logo: string;
type: IntegrationType;
active: boolean;
};
const initialIntegrations: Integration[] = [
{ id: 'google-drive', name: 'Google Drive', desc: 'Upload your files to Google Drive', logo: `${assetBase}/thumbs/brands/google-drive.png`, type: 'storageCollaboration', active: true },
{ id: 'github', name: 'Github', desc: 'Exchange files with a GitHub repository', logo: `${assetBase}/thumbs/brands/github.png`, type: 'storageCollaboration', active: true },
{ id: 'zapier', name: 'Zapier', desc: 'Integrate with hundreds of services.', logo: `${assetBase}/thumbs/brands/zapier.png`, type: 'automation', active: false },
{ id: 'make', name: 'Make (Integromat)', desc: 'Visually automate your workflows with Make', logo: `${assetBase}/thumbs/brands/make.png`, type: 'automation', active: false },
{ id: 'pabbly', name: 'Pabbly Connect', desc: 'Affordable automation for SaaS and CRM tools', logo: `${assetBase}/thumbs/brands/pabbly.png`, type: 'automation', active: false },
{ id: 'slack', name: 'Slack', desc: 'Post to a Slack channel', logo: `${assetBase}/thumbs/brands/slack.png`, type: 'storageCollaboration', active: false },
{ id: 'notion', name: 'Notion', desc: 'Retrieve notion note to your project', logo: `${assetBase}/thumbs/brands/notion.png`, type: 'storageCollaboration', active: false },
{ id: 'dropbox', name: 'Dropbox', desc: 'Exchange data with Dropbox', logo: `${assetBase}/thumbs/brands/dropbox.png`, type: 'storageCollaboration', active: false },
];
const sectionCopy: Record<IntegrationType, { title: string; desc: string }> = {
automation: {
title: 'Automation',
desc: 'Automate tasks and connect multiple apps to streamline your workflow without manual effort.',
},
storageCollaboration: {
title: 'Storage & Collaboration',
desc: 'Tools that help you manage files, documents, and collaborate seamlessly across your team.',
},
};
function IntegrationCard({ integration, onToggle }: { integration: Integration; onToggle: (id: string) => void }) {
return (
<Card
bodyClass="flex flex-col gap-4 p-4"
footer={{ className: 'flex items-center justify-between gap-2', content: (
<>
<Button>{integration.active ? 'Manage' : 'Learn More'}</Button>
<Switcher checked={integration.active} onChange={() => onToggle(integration.id)} />
</>
) }}
>
<Avatar shape="round" size={30} src={integration.logo} className="border-0 bg-transparent" />
<div>
<div className="font-semibold text-card-foreground">{integration.name}</div>
<p className="text-muted-foreground">{integration.desc}</p>
</div>
</Card>
);
}
export default function IntegrationSettingsGrid() {
const [integrations, setIntegrations] = useState(initialIntegrations);
const handleToggle = (id: string) => {
setIntegrations((prev) => prev.map((item) => (item.id === id ? { ...item, active: !item.active } : item)));
};
const connected = useMemo(() => integrations.filter((item) => item.active), [integrations]);
const grouped = useMemo(
() => ({
automation: integrations.filter((item) => !item.active && item.type === 'automation'),
storageCollaboration: integrations.filter((item) => !item.active && item.type === 'storageCollaboration'),
}),
[integrations],
);
return (
<section aria-label="Integrations" className="w-full space-y-8">
<div className="border-b pb-4">
<h4 className="text-xl font-semibold text-foreground">Integrations</h4>
</div>
{connected.length > 0 && (
<div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{connected.map((integration) => (
<IntegrationCard key={integration.id} integration={integration} onToggle={handleToggle} />
))}
</div>
</div>
)}
{(Object.keys(sectionCopy) as IntegrationType[]).map(
(type) =>
grouped[type].length > 0 && (
<div key={type}>
<div className="mb-4">
<h5 className="text-lg font-semibold text-foreground">{sectionCopy[type].title}</h5>
<p className="text-muted-foreground">{sectionCopy[type].desc}</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{grouped[type].map((integration) => (
<IntegrationCard key={integration.id} integration={integration} onToggle={handleToggle} />
))}
</div>
</div>
),
)}
</section>
);
}
Grid 03
Preview
npx nateui@latest add FileManagerGridDark
import { useState } from 'react';
import Avatar from '@/components/ui/Avatar';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Dropdown from '@/components/ui/Dropdown';
import Table from '@/components/ui/Table';
import FileIcon from '@/components/composites/FileIcon';
import { PiDotsThreeVerticalBold, PiPlusCircle } from 'react-icons/pi';
const assetBase = 'https://statics.nateui.com/img';
type Folder = {
id: string;
name: string;
files: number;
size: string;
};
const folders: Folder[] = [
{ id: 'health-report', name: 'Health Report', files: 80, size: '168 MB' },
{ id: 'medical-information', name: 'Medical Information', files: 8, size: '56 MB' },
{ id: 'prescriptions', name: 'Prescriptions', files: 20, size: '11 MB' },
{ id: 'archived', name: 'Archived', files: 99, size: '267 MB' },
];
type RecentFile = {
id: string;
name: string;
date: string;
size: string;
fileType: string;
};
const recentFiles: RecentFile[] = [
{ id: 'recent-health-data', name: 'Health data', date: '14.02.2026', size: '56 MB', fileType: 'pdf' },
{ id: 'recent-medical-report', name: 'Medical report', date: '12.02.2026', size: '41 MB', fileType: 'docx' },
{ id: 'recent-prescriptions', name: 'Prescriptions', date: '09.02.2026', size: '18 MB', fileType: 'pdf' },
];
type FileRow = {
id: string;
name: string;
date: string;
fileType: string;
uploaderName: string;
uploaderAvatar: string;
};
const files: FileRow[] = [
{ id: 'file-1', name: 'Health Data', date: '14.02.2026', fileType: 'pdf', uploaderName: 'Amara Solis', uploaderAvatar: `${assetBase}/avatars/thumb-17.jpg` },
{ id: 'file-2', name: 'Medical Reports', date: '12.02.2026', fileType: 'docx', uploaderName: 'Dev Patel', uploaderAvatar: `${assetBase}/avatars/thumb-18.jpg` },
{ id: 'file-3', name: 'Prescription Log', date: '09.02.2026', fileType: 'pdf', uploaderName: 'Wren Castellano', uploaderAvatar: `${assetBase}/avatars/thumb-19.jpg` },
{ id: 'file-4', name: 'Lab Results', date: '05.02.2026', fileType: 'xlsx', uploaderName: 'Lucia Ferreira', uploaderAvatar: `${assetBase}/avatars/thumb-20.jpg` },
];
export default function FileManagerGrid() {
const [selectedRecentFileId, setSelectedRecentFileId] = useState<string | null>(null);
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
return (
<section aria-label="Documents" className="w-full space-y-8">
<div>
<div className="border-b pb-4">
<h4 className="text-xl font-semibold text-foreground">Documents</h4>
</div>
<div className="mt-4">
<Button variant="solid" icon={<PiPlusCircle />}>
New file / Folder
</Button>
</div>
</div>
<div>
<h5 className="mb-4 text-lg font-semibold text-foreground">Folders</h5>
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{folders.map((folder) => (
<button
key={folder.id}
type="button"
className="flex w-full flex-col gap-2 rounded-card border p-4 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<FileIcon type="directory" size={40} />
<div>
<div className="font-semibold text-foreground">{folder.name}</div>
<p className="text-muted-foreground">
{folder.files} Files • {folder.size}
</p>
</div>
</button>
))}
</div>
</div>
<div>
<h5 className="mb-4 text-lg font-semibold text-foreground">Recent files</h5>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{recentFiles.map((file) => {
const isSelected = file.id === selectedRecentFileId;
return (
<Card
key={file.id}
clickable
role="button"
tabIndex={0}
onClick={() => setSelectedRecentFileId(file.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelectedRecentFileId(file.id);
}
}}
aria-selected={isSelected}
className={`transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
isSelected ? 'bg-primary-soft' : 'hover:bg-accent'
}`}
bodyClass="flex items-center gap-2 p-4"
>
<FileIcon type={file.fileType} size={40} />
<div className="min-w-0">
<div className="truncate font-medium text-card-foreground">{file.name}</div>
<p className="truncate text-muted-foreground">
{file.date} • {file.size}
</p>
</div>
</Card>
);
})}
</div>
</div>
<div>
<h5 className="mb-4 text-lg font-semibold text-foreground">All Files</h5>
<div className="overflow-hidden rounded-card border bg-card">
<Table>
<Table.THead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Uploaded Date</Table.Th>
<Table.Th>Uploaded By</Table.Th>
<Table.Th>More Actions</Table.Th>
</Table.Tr>
</Table.THead>
<Table.TBody>
{files.map((file) => {
const isSelected = file.id === selectedFileId;
return (
<Table.Tr
key={file.id}
tabIndex={0}
onClick={() => setSelectedFileId(file.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelectedFileId(file.id);
}
}}
aria-selected={isSelected}
className={`cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${
isSelected ? 'bg-primary-soft hover:bg-primary-soft' : ''
}`}
>
<Table.Td>
<div className="flex items-center gap-2">
<FileIcon type={file.fileType} size={28} />
<span className="font-medium text-foreground">{file.name}</span>
</div>
</Table.Td>
<Table.Td className="text-muted-foreground">{file.date}</Table.Td>
<Table.Td>
<div className="flex items-center gap-2">
<Avatar size={24} src={file.uploaderAvatar} alt={file.uploaderName} />
<span className="text-foreground">{file.uploaderName}</span>
</div>
</Table.Td>
<Table.Td>
<div className="flex justify-end" onClick={(event) => event.stopPropagation()}>
<Dropdown
placement="bottom-end"
renderTitle={
<Button
variant="ghost"
shape="circle"
size="sm"
icon={<PiDotsThreeVerticalBold />}
aria-label={`More actions for ${file.name}`}
/>
}
>
<Dropdown.Item eventKey="open">Open</Dropdown.Item>
<Dropdown.Item eventKey="rename">Rename</Dropdown.Item>
<Dropdown.Item eventKey="delete">Delete</Dropdown.Item>
</Dropdown>
</div>
</Table.Td>
</Table.Tr>
);
})}
</Table.TBody>
</Table>
</div>
</div>
</section>
);
}
Grid 04
Preview
npx nateui@latest add ProductGridDark
import { useMemo, useState } from 'react';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Dropdown from '@/components/ui/Dropdown';
import Input from '@/components/ui/Input';
import Pagination from '@/components/ui/Pagination';
import Segment from '@/components/ui/Segment';
import Select from '@/components/ui/Select';
import {
PiDotsThreeVerticalBold,
PiFunnel,
PiMagnifyingGlass,
PiPlus,
PiStarFill,
} from 'react-icons/pi';
const assetBase = 'https://statics.nateui.com/img';
type Product = {
id: string;
name: string;
price: number;
rating: number;
status: 'active' | 'inactive';
image: string;
};
const products: Product[] = [
{ id: 'p1', name: '16-inch Pro Laptop', price: 1999, rating: 4.8, status: 'active', image: `${assetBase}/thumbs/products/product-1.jpg` },
{ id: 'p2', name: 'True Wireless Earbuds', price: 129, rating: 4.5, status: 'active', image: `${assetBase}/thumbs/products/product-9.jpg` },
{ id: 'p3', name: 'Sport Mini Backpack', price: 68, rating: 4.3, status: 'active', image: `${assetBase}/thumbs/products/product-3.jpg` },
{ id: 'p4', name: 'Court Classic Sneaker', price: 95, rating: 4.6, status: 'active', image: `${assetBase}/thumbs/products/product-6.jpg` },
{ id: 'p5', name: 'Dive Chronograph Watch', price: 310, rating: 4.9, status: 'inactive', image: `${assetBase}/thumbs/products/product-10.jpg` },
{ id: 'p6', name: 'Crossbody Camera Bag', price: 145, rating: 4.4, status: 'active', image: `${assetBase}/thumbs/products/product-14.jpg` },
{ id: 'p7', name: 'Hydrating Glow Serum', price: 42, rating: 4.2, status: 'inactive', image: `${assetBase}/thumbs/products/product-15.jpg` },
{ id: 'p8', name: 'Over-Ear Studio Headphones', price: 89, rating: 4.5, status: 'active', image: `${assetBase}/thumbs/products/product-18.jpg` },
{ id: 'p9', name: 'Leather Wallet & Keyfob Set', price: 76, rating: 4.7, status: 'active', image: `${assetBase}/thumbs/products/product-19.jpg` },
{ id: 'p10', name: 'Fleece Pullover Hoodie', price: 58, rating: 4.3, status: 'inactive', image: `${assetBase}/thumbs/products/product-20.jpg` },
{ id: 'p11', name: 'Aluminum Smartwatch', price: 399, rating: 4.6, status: 'active', image: `${assetBase}/thumbs/products/product-2.jpg` },
{ id: 'p12', name: 'Cotton Overshirt Jacket', price: 84, rating: 4.4, status: 'active', image: `${assetBase}/thumbs/products/product-11.jpg` },
];
const filterOptions = ['Category', 'Price', 'Rating', 'Stock'];
const pageSizeOptions = [
{ value: 8, label: '8 / page' },
{ value: 12, label: '12 / page' },
];
export default function ProductGrid() {
const [statusFilter, setStatusFilter] = useState('all');
const [search, setSearch] = useState('');
const [pageSize, setPageSize] = useState(8);
const [page, setPage] = useState(1);
const filteredProducts = useMemo(() => {
const query = search.trim().toLowerCase();
return products.filter((product) => {
const matchesStatus = statusFilter === 'all' || product.status === statusFilter;
const matchesQuery = !query || product.name.toLowerCase().includes(query);
return matchesStatus && matchesQuery;
});
}, [search, statusFilter]);
const pageCount = Math.max(1, Math.ceil(filteredProducts.length / pageSize));
const currentPage = Math.min(page, pageCount);
const pageItems = filteredProducts.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const currentPageSizeOption = pageSizeOptions.find((option) => option.value === pageSize) ?? pageSizeOptions[0];
return (
<section aria-label="Products" className="w-full space-y-4">
<div className="border-b pb-4">
<h4 className="text-xl font-semibold text-foreground">Products</h4>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Segment
value={statusFilter}
onChange={(value) => {
setStatusFilter(value as string);
setPage(1);
}}
>
<Segment.Item value="all">All</Segment.Item>
<Segment.Item value="active">Active</Segment.Item>
<Segment.Item value="inactive">Non Active</Segment.Item>
</Segment>
<div className="flex flex-wrap items-center gap-2">
<Input
prefix={<PiMagnifyingGlass />}
placeholder="Search product"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
className="w-full sm:w-56"
/>
<Dropdown
placement="bottom-end"
renderTitle={
<Button variant="default" icon={<PiFunnel />}>
Filter
</Button>
}
>
{filterOptions.map((option) => (
<Dropdown.Item key={option} eventKey={option}>
{option}
</Dropdown.Item>
))}
</Dropdown>
<Button variant="solid" icon={<PiPlus />}>
New Product
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{pageItems.map((product) => (
<Card key={product.id}>
<div className="relative aspect-square overflow-hidden rounded-card">
<div className="flex h-full items-center justify-center">
<img src={product.image} alt={product.name} className="max-h-full max-w-full object-contain" />
</div>
<Dropdown
placement="bottom-end"
toggleClassName="absolute right-2 top-2"
renderTitle={
<Button
shape="circle"
size="sm"
icon={<PiDotsThreeVerticalBold />}
aria-label={`More actions for ${product.name}`}
/>
}
>
<Dropdown.Item eventKey="edit">Edit</Dropdown.Item>
<Dropdown.Item eventKey="duplicate">Duplicate</Dropdown.Item>
<Dropdown.Item eventKey="delete">Delete</Dropdown.Item>
</Dropdown>
</div>
<div className="mt-2">
<div className="truncate font-medium text-card-foreground">{product.name}</div>
<div className="mt-1 flex items-center justify-between">
<span className="text-foreground">${product.price.toFixed(2)}</span>
<span className="flex items-center gap-1 text-muted-foreground">
<PiStarFill className="text-palette-yellow" />
{product.rating}
</span>
</div>
</div>
</Card>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-4">
<Pagination
total={filteredProducts.length}
pageSize={pageSize}
currentPage={currentPage}
onChange={(pageNumber) => setPage(pageNumber)}
/>
<Select
className="w-32"
value={currentPageSizeOption}
options={pageSizeOptions}
onChange={(option) => {
setPageSize(option.value);
setPage(1);
}}
/>
</div>
</section>
);
}
Grid 05
Preview
npx nateui@latest add MediaLibraryGridDark
import { useState } from 'react';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Menu from '@/components/ui/Menu';
import FileIcon from '@/components/composites/FileIcon';
import {
PiCheckBold,
PiFolder,
PiFolderFill,
PiMagnifyingGlass,
PiPlus,
PiUploadSimple,
} from 'react-icons/pi';
const assetBase = 'https://statics.nateui.com/img';
type Folder = {
id: string;
name: string;
};
const folders: Folder[] = [
{ id: 'recent', name: 'Recent' },
{ id: 'product-shots', name: 'Product Shots' },
{ id: 'illustrations', name: 'Illustrations' },
{ id: 'archive', name: 'Archive' },
];
type MediaItem =
| { id: string; kind: 'image'; src: string }
| { id: string; kind: 'file'; name: string; fileType: string };
const mediaItems: MediaItem[] = [
{ id: 'm1', kind: 'image', src: `${assetBase}/thumbs/misc/img-1.png` },
{ id: 'm2', kind: 'image', src: `${assetBase}/thumbs/misc/img-2.png` },
{ id: 'm3', kind: 'image', src: `${assetBase}/thumbs/misc/img-3.png` },
{ id: 'm4', kind: 'image', src: `${assetBase}/thumbs/misc/img-4.png` },
{ id: 'm5', kind: 'file', name: 'Report.doc', fileType: 'doc' },
{ id: 'm6', kind: 'image', src: `${assetBase}/thumbs/misc/img-5.png` },
{ id: 'm7', kind: 'image', src: `${assetBase}/thumbs/misc/img-6.png` },
{ id: 'm8', kind: 'file', name: 'Icons.zip', fileType: 'zip' },
{ id: 'm9', kind: 'file', name: 'Poster Layout.ai', fileType: 'ai' },
{ id: 'm10', kind: 'file', name: 'Logo Concepts.ai', fileType: 'ai' },
{ id: 'm11', kind: 'image', src: `${assetBase}/thumbs/misc/img-12.png` },
{ id: 'm12', kind: 'image', src: `${assetBase}/thumbs/misc/img-13.png` },
];
export default function MediaLibraryGrid() {
const [activeFolder, setActiveFolder] = useState('illustrations');
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
function toggleSelected(id: string) {
setSelectedIds((previous) =>
previous.includes(id) ? previous.filter((existing) => existing !== id) : [...previous, id],
);
}
return (
<section aria-label="Media library" className="w-full space-y-4 px-2">
<div className="border-b pb-4">
<h4 className="text-xl font-semibold text-foreground">Media Library</h4>
</div>
<div className="flex flex-col gap-8 lg:flex-row lg:items-stretch">
<div className="flex w-full flex-col lg:w-[220px] lg:flex-shrink-0">
<div>
<h6 className="mb-2 px-2 text-base font-medium text-foreground">Folders</h6>
<nav className="pb-2 border-b mb-2">
<Menu variant="subtle">
{folders.map((folder) => {
const isActive = activeFolder === folder.id;
return (
<Menu.MenuItem
key={folder.id}
eventKey={folder.id}
isActive={isActive}
role="button"
tabIndex={0}
aria-current={isActive ? 'true' : undefined}
onClick={() => setActiveFolder(folder.id)}
onKeyDown={(event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setActiveFolder(folder.id);
}
}}
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
isActive ? 'font-medium' : ''
}`}
>
{isActive ? <PiFolderFill className="text-lg" /> : <PiFolder className="text-lg" />}
<span className="min-w-0 truncate">{folder.name}</span>
</Menu.MenuItem>
);
})}
</Menu>
</nav>
<Button variant="ghost" className="text-start w-full justify-start px-2">
<span className="flex items-center gap-1">
<PiPlus />
New Folder
</span>
</Button>
</div>
<div className="mt-auto pt-8">
<Button icon={<PiUploadSimple />} className="w-full">
Upload File
</Button>
</div>
</div>
<div className="min-w-0 flex-1 space-y-4">
<div className="flex justify-end">
<Input
prefix={<PiMagnifyingGlass />}
placeholder="Search media"
value={search}
onChange={(event) => setSearch(event.target.value)}
className="w-full sm:w-64"
/>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{mediaItems.map((item) => {
const isSelected = selectedIds.includes(item.id);
const label = item.kind === 'image' ? 'image' : item.name;
return (
<button
key={item.id}
type="button"
onClick={() => toggleSelected(item.id)}
aria-pressed={isSelected}
aria-label={isSelected ? `Deselect ${label}` : `Select ${label}`}
className={`group relative aspect-square overflow-hidden rounded-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
isSelected ? 'ring-2 ring-primary' : ''
}`}
>
{item.kind === 'image' ? (
<>
<img src={item.src} alt="" className="h-full w-full object-cover" />
<span className="absolute inset-0 bg-foreground/0 transition-colors group-hover:bg-foreground/5" />
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 border bg-card p-4 text-center transition-colors group-hover:bg-accent">
<FileIcon type={item.fileType} size={40} />
<p className="w-full truncate text-sm font-medium text-card-foreground">{item.name}</p>
</div>
)}
{isSelected ? (
<span className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background">
<PiCheckBold className="text-sm" />
</span>
) : null}
</button>
);
})}
</div>
<div className="flex justify-end border-t pt-4">
<Button variant="solid" disabled={selectedIds.length === 0}>
Import Selected
</Button>
</div>
</div>
</div>
</section>
);
}
Grid 06
Preview
npx nateui@latest add ProjectPortfolioGridDark
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Dropdown from '@/components/ui/Dropdown';
import Tag from '@/components/ui/Tag';
import UsersAvatarGroup from '@/components/composites/UsersAvatarGroup';
import {
PiCheckCircle,
PiDotsThreeVerticalBold,
PiListChecks,
PiPackage,
PiPlus,
PiProhibit,
PiSpinner,
PiStar,
PiStarFill,
} from 'react-icons/pi';
const assetBase = 'https://statics.nateui.com/img';
type Priority = 'Low' | 'Medium' | 'High';
type Status = 'progress' | 'blocked' | 'done' | 'cancelled';
type Project = {
id: string;
name: string;
description: string;
image: string;
status: Status;
tasksDone: number;
tasksTotal: number;
priority: Priority;
starred: boolean;
members: { name: string; img: string }[];
};
const statusMap = {
progress: { className: 'text-info', icon: <PiSpinner aria-hidden="true" /> },
blocked: { className: 'text-destructive', icon: <PiProhibit aria-hidden="true" /> },
done: { className: 'text-success', icon: <PiCheckCircle aria-hidden="true" /> },
cancelled: { className: 'text-warning', icon: <PiPackage aria-hidden="true" /> },
};
const priorityIntentMap: Record<Priority, string> = {
Low: 'bg-success',
Medium: 'bg-warning',
High: 'bg-destructive',
};
const projects: Project[] = [
{
id: 'p1',
name: 'Onboarding Flow',
description: 'Streamlining the new-hire signup flow to cut drop-off in the first session.',
image: `${assetBase}/thumbs/projects/img-1.jpg`,
status: 'progress',
tasksDone: 9,
tasksTotal: 14,
priority: 'High',
starred: true,
members: [
{ name: 'Ava Brennan', img: `${assetBase}/avatars/thumb-1.jpg` },
{ name: 'Leo Marchetti', img: `${assetBase}/avatars/thumb-4.jpg` },
],
},
{
id: 'p2',
name: 'Contract Review',
description: 'Paused while legal finalizes updated procurement terms.',
image: `${assetBase}/thumbs/projects/img-2.jpg`,
status: 'blocked',
tasksDone: 2,
tasksTotal: 8,
priority: 'Medium',
starred: false,
members: [{ name: 'Priya Shah', img: `${assetBase}/avatars/thumb-3.jpg` }],
},
{
id: 'p3',
name: 'iOS Companion',
description: 'Shipped the first release with push notifications and offline sync.',
image: `${assetBase}/thumbs/projects/img-3.jpg`,
status: 'done',
tasksDone: 18,
tasksTotal: 18,
priority: 'High',
starred: true,
members: [
{ name: 'Naomi Osei', img: `${assetBase}/avatars/thumb-5.jpg` },
{ name: 'Theo Lindqvist', img: `${assetBase}/avatars/thumb-6.jpg` },
],
},
{
id: 'p4',
name: 'Data Migration',
description: 'Shelved after the team standardized on a different storage provider.',
image: `${assetBase}/thumbs/projects/img-4.jpg`,
status: 'cancelled',
tasksDone: 3,
tasksTotal: 12,
priority: 'Low',
starred: false,
members: [{ name: 'Owen Bright', img: `${assetBase}/avatars/thumb-2.jpg` }],
},
{
id: 'p5',
name: 'Billing Sync',
description: 'Automating invoice matching against payment processor records.',
image: `${assetBase}/thumbs/projects/img-5.jpg`,
status: 'progress',
tasksDone: 7,
tasksTotal: 15,
priority: 'Medium',
starred: true,
members: [
{ name: 'Maya Chen', img: `${assetBase}/avatars/thumb-7.jpg` },
{ name: 'Marcus Webb', img: `${assetBase}/avatars/thumb-8.jpg` },
],
},
{
id: 'p6',
name: 'Portal Refresh',
description: 'Rebuilding the self-serve account pages for faster load times.',
image: `${assetBase}/thumbs/projects/img-6.jpg`,
status: 'progress',
tasksDone: 13,
tasksTotal: 19,
priority: 'High',
starred: true,
members: [{ name: 'Ivy Chen', img: `${assetBase}/avatars/thumb-9.jpg` }],
},
{
id: 'p7',
name: 'Inventory Sync',
description: 'Connecting store locations to a single real-time stock feed.',
image: `${assetBase}/thumbs/projects/img-7.jpg`,
status: 'progress',
tasksDone: 6,
tasksTotal: 9,
priority: 'Medium',
starred: false,
members: [
{ name: 'Dana Ruiz', img: `${assetBase}/avatars/thumb-10.jpg` },
{ name: 'Tomas Reyes', img: `${assetBase}/avatars/thumb-11.jpg` },
],
},
{
id: 'p8',
name: 'Design Tokens',
description: 'Finalized the shared color, spacing, and type scale for every product surface.',
image: `${assetBase}/thumbs/projects/img-8.jpg`,
status: 'done',
tasksDone: 15,
tasksTotal: 15,
priority: 'Low',
starred: true,
members: [{ name: 'Grace Liu', img: `${assetBase}/avatars/thumb-12.jpg` }],
},
{
id: 'p9',
name: 'Voice Assistant',
description: 'On hold while the team gathers more call-transcript training data.',
image: `${assetBase}/thumbs/projects/img-9.jpg`,
status: 'blocked',
tasksDone: 5,
tasksTotal: 11,
priority: 'High',
starred: true,
members: [
{ name: 'Felix Bauer', img: `${assetBase}/avatars/thumb-13.jpg` },
{ name: 'Priya Nair', img: `${assetBase}/avatars/thumb-14.jpg` },
],
},
{
id: 'p10',
name: 'Auth Retirement',
description: 'Deprioritized after the identity provider migration covered the same need.',
image: `${assetBase}/thumbs/projects/img-10.jpg`,
status: 'cancelled',
tasksDone: 4,
tasksTotal: 10,
priority: 'Medium',
starred: false,
members: [{ name: 'Owen Blake', img: `${assetBase}/avatars/thumb-15.jpg` }],
},
];
export default function ProjectPortfolioGrid() {
return (
<section aria-label="Projects" className="w-full space-y-4">
<div className="flex flex-col gap-4 pb-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 className="text-xl font-semibold text-foreground">Projects</h4>
<p className="mt-1 max-w-xl text-muted-foreground">
Track every active initiative, review priority and progress, and jump into the ones that need
attention.
</p>
</div>
<Button variant="solid" icon={<PiPlus />} className="shrink-0">
New Project
</Button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => {
const status = statusMap[project.status];
return (
<Card key={project.id}
footer={{ className: 'flex items-center justify-between gap-2', content: (
<>
<div className="flex flex-wrap items-center gap-2">
<span className={`text-base ${status.className}`} aria-label={project.status}>
{status.icon}
</span>
<span className="flex items-center gap-1 text-muted-foreground">
<PiListChecks className="text-base" />
{project.tasksDone}/{project.tasksTotal}
</span>
<Tag
className="gap-1 bg-transparent"
prefix={<span className={`h-2.5 w-2.5 rounded-xs ${priorityIntentMap[project.priority]}`} />}
>
{project.priority}
</Tag>
</div>
<UsersAvatarGroup
users={project.members}
avatarProps={{ size: 24 }}
/>
</>
) }}
>
<div className="flex justify-between">
<div className="flex items-center justify-between gap-2">
<img src={project.image} alt="" className="h-6 w-6 shrink-0 rounded-lg object-cover" />
<div className="min-w-0 flex-1">
<h6 className="truncate font-semibold text-card-foreground">{project.name}</h6>
</div>
</div>
<div className="flex items-center">
<button
className="p-2 text-base"
aria-label={project.starred ? `Unstar ${project.name}` : `Star ${project.name}`}
>
{project.starred ? <PiStarFill className="text-palette-yellow" /> : <PiStar />}
</button>
<Dropdown
placement="bottom-end"
renderTitle={
<Button
size="sm"
variant="ghost"
icon={<PiDotsThreeVerticalBold />}
aria-label={`More actions for ${project.name}`}
/>
}
>
<Dropdown.Item eventKey="view">View Details</Dropdown.Item>
<Dropdown.Item eventKey="duplicate">Duplicate</Dropdown.Item>
<Dropdown.Item eventKey="archive">Archive</Dropdown.Item>
<Dropdown.Item variant="divider" />
<Dropdown.Item eventKey="delete" className="text-destructive">
Delete
</Dropdown.Item>
</Dropdown>
</div>
</div>
<p className="mt-4 line-clamp-2 text-muted-foreground">{project.description}</p>
</Card>
);
})}
</div>
</section>
);
}
Grid 07
Preview
npx nateui@latest add ServerInstanceGridDark
import { useState } from 'react';
import Avatar from '@/components/ui/Avatar';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Dropdown from '@/components/ui/Dropdown';
import Input from '@/components/ui/Input';
import Tag from '@/components/ui/Tag';
import IconFrame from '@/components/composites/IconFrame';
import {
PiCheck,
PiCopySimple,
PiDotsThreeVerticalBold,
PiGlobe,
PiMagnifyingGlass,
PiMapPin,
PiPauseFill,
PiPlayFill,
} from 'react-icons/pi';
const assetBase = 'https://statics.nateui.com/img';
type ServerStatus = 'running' | 'stopped';
type ServerInstance = {
id: string;
name: string;
specs: string;
location: string;
ip: string;
status: ServerStatus;
tags: string[];
createdDaysAgo: number;
logo: string;
};
const initialServers: ServerInstance[] = [
{
id: 'srv-1',
name: 'Fedora-40',
specs: '2GB DDR4 / 64GB SSD',
location: 'ap-southeast-1',
ip: '192.0.2.11',
status: 'stopped',
tags: [],
createdDaysAgo: 5,
logo: `${assetBase}/thumbs/brands/fedora.png`,
},
{
id: 'srv-2',
name: 'Ubuntu-22.04',
specs: '4GB DDR4 / 80GB SSD',
location: 'eu-west-1',
ip: '192.0.2.24',
status: 'running',
tags: ['staging'],
createdDaysAgo: 12,
logo: `${assetBase}/thumbs/brands/ubuntu.png`,
},
{
id: 'srv-3',
name: 'RHEL-9',
specs: '2GB DDR4 / 48GB SSD',
location: 'us-east-1',
ip: '192.0.2.37',
status: 'running',
tags: [],
createdDaysAgo: 30,
logo: `${assetBase}/thumbs/brands/red-hat.png`,
},
{
id: 'srv-4',
name: 'Fedora-38',
specs: '8GB DDR4 / 160GB SSD',
location: 'ap-southeast-2',
ip: '192.0.2.48',
status: 'stopped',
tags: ['production'],
createdDaysAgo: 2,
logo: `${assetBase}/thumbs/brands/fedora.png`,
},
{
id: 'srv-5',
name: 'Ubuntu-20.04',
specs: '2GB DDR4 / 64GB SSD',
location: 'eu-north-1',
ip: '192.0.2.53',
status: 'running',
tags: [],
createdDaysAgo: 45,
logo: `${assetBase}/thumbs/brands/ubuntu.png`,
},
];
export default function ServerInstanceGrid() {
const [servers, setServers] = useState(initialServers);
const [search, setSearch] = useState('');
const [copiedId, setCopiedId] = useState<string | null>(null);
function toggleStatus(id: string) {
setServers((previous) =>
previous.map((server) =>
server.id === id
? { ...server, status: server.status === 'running' ? 'stopped' : 'running' }
: server,
),
);
}
function copyIp(id: string, ip: string) {
navigator.clipboard?.writeText(ip);
setCopiedId(id);
setTimeout(() => setCopiedId((current) => (current === id ? null : current)), 1500);
}
const query = search.trim().toLowerCase();
const visibleServers = query
? servers.filter((server) => server.name.toLowerCase().includes(query))
: servers;
return (
<section aria-label="Servers" className="w-full space-y-4">
<div className="flex flex-col gap-4 border-b pb-4 sm:flex-row sm:items-center sm:justify-between">
<h4 className="text-xl font-semibold text-foreground">Servers</h4>
<Input
prefix={<PiMagnifyingGlass />}
placeholder="Search..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="w-full sm:w-64"
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{visibleServers.map((server) => {
const isRunning = server.status === 'running';
return (
<Card
key={server.id}
footer={{
className: 'flex items-center justify-between gap-2',
content: (
<>
<div className="flex flex-wrap items-center gap-1.5">
{server.tags.length === 0 ? (
<Tag className="bg-muted text-muted-foreground">Untagged</Tag>
) : (
server.tags.map((tag) => (
<Tag key={tag}>
{tag}
</Tag>
))
)}
</div>
<span className="shrink-0 text-muted-foreground">Created {server.createdDaysAgo} days ago</span>
</>
),
}}
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-3">
<IconFrame size={40} className="shrink-0">
<Avatar src={server.logo} alt={server.name} size={24} shape="square" className="border-0 bg-transparent" />
</IconFrame>
<div className="min-w-0">
<div className="truncate font-semibold text-card-foreground">{server.name}</div>
<p className="truncate text-xs text-muted-foreground">{server.specs}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
size="sm"
variant="subtle"
className={
isRunning
? 'text-destructive hover:text-destructive focus-visible:ring-destructive'
: 'text-success hover:text-success focus-visible:ring-success'
}
icon={isRunning ? <PiPauseFill /> : <PiPlayFill />}
onClick={() => toggleStatus(server.id)}
aria-label={isRunning ? `Stop ${server.name}` : `Start ${server.name}`}
/>
<Dropdown
placement="bottom-end"
renderTitle={
<Button
size="sm"
variant="ghost"
icon={<PiDotsThreeVerticalBold />}
aria-label={`More actions for ${server.name}`}
/>
}
>
<Dropdown.Item eventKey="restart">Restart</Dropdown.Item>
<Dropdown.Item eventKey="reinstall">Reinstall</Dropdown.Item>
<Dropdown.Item eventKey="details">View Details</Dropdown.Item>
<Dropdown.Item variant="divider" />
<Dropdown.Item eventKey="delete" className="text-destructive">
Delete
</Dropdown.Item>
</Dropdown>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1">
<span className="flex items-center gap-1">
<PiMapPin className="text-base" />
{server.location}
</span>
<span className="flex items-center gap-1">
<PiGlobe className="text-base" />
{server.ip}
<button
type="button"
onClick={() => copyIp(server.id, server.ip)}
aria-label={`Copy IP address for ${server.name}`}
className="text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{copiedId === server.id ? <PiCheck className="text-base" /> : <PiCopySimple className="text-base" />}
</button>
</span>
</div>
</Card>
);
})}
</div>
</section>
);
}