BlockProduct
Table page
API keys page with search, filtering, usage information, row actions, and empty states.
$ npx context-ds add table-pageUsage
Use it for
API keys, members, records.
Dependencies: app-shell, data-toolbar, empty-state, page, button, dialog, dropdown-menu, field, input, secret-field, select, table, glyph-tile, lucide-react
Example
examples/table-page.tsx
import { TablePage } from "@/components/blocks/table-page";export function Block() { return <TablePage />;}export function Paid() { return <TablePage plan="paid" />;}export function Empty() { return <TablePage keys={[]} />;}Source
components/blocks/table-page.tsx
"use client";import * as React from "react";import { ArrowRight, ArrowUpRight, KeyRound, MoreVertical, Pause, Pencil, Play, Plus, Trash2 } from "lucide-react";import { AppShell } from "@/components/blocks/app-shell";import { DataToolbar, DataToolbarActions, DataToolbarSearch } from "@/components/product/data-toolbar";import { EmptyState, EmptyStateRow } from "@/components/product/empty-state";import { PageColumn, PageHeader } from "@/components/product/page";import { Button } from "@/components/ui/button";import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";import { DropdownMenu, DropdownMenuContent, DropdownMenuHoldItem, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";import { Field } from "@/components/ui/field";import { Input } from "@/components/ui/input";import { SecretField } from "@/components/ui/secret-field";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";import { GlyphTile } from "@/components/visuals/glyph/glyph-tile";import { cn } from "@/lib/utils";export type ApiKey = { id: string; name: string; secret: string; created: string; permissions: string; enabled: boolean; credits?: number };const SAMPLE_KEYS: ApiKey[] = [ { id: "1", name: "Production server", secret: "ctx_live_9f2c41b7e0a84d6c2b17", created: "Oct 2, 2026", permissions: "Full access", enabled: true, credits: 41880 }, { id: "2", name: "Staging", secret: "ctx_live_1b8d03e94c2a7f5530ae", created: "Sep 14, 2026", permissions: "2 permissions", enabled: true, credits: 1204.5 }, { id: "3", name: "Default", secret: "ctx_live_77aa20cd5e1b9f03c4d8", created: "Mar 3, 2026", permissions: "Full access", enabled: false, credits: 0 },];// The secret shows its first 14 characters before the mask, enough to tell keys apart.const VISIBLE_SECRET_CHARS = 14;const FREE_KEY_LIMIT = 2;const PAID_KEY_LIMIT = 50;const credits = new Intl.NumberFormat("en-US", { maximumFractionDigits: 6 });// The API keys page: the centered header with the one brand action, the free plan's key allowance on a card, the// search and a status filter, then the keys on a card: name with when it was made, the masked key, permissions,// status, 30-day usage and a row menu whose disable and delete ask for a hold. With no keys at all the page is a// centered empty state instead; a search that matches nothing keeps the table and says so in one row.export function TablePage({ keys: initialKeys = SAMPLE_KEYS, plan = "free" }: { keys?: ApiKey[]; plan?: "free" | "paid" }) { const [keys, setKeys] = React.useState(initialKeys); const [editing, setEditing] = React.useState<ApiKey | null>(null); const [permissions, setPermissions] = React.useState("Full access"); const [editedName, setEditedName] = React.useState(""); const [upgraded, setUpgraded] = React.useState(false); const [query, setQuery] = React.useState(""); const [status, setStatus] = React.useState("all"); const [createOpen, setCreateOpen] = React.useState(false); const isPaid = upgraded || plan === "paid"; const limit = isPaid ? PAID_KEY_LIMIT : FREE_KEY_LIMIT; const isAtLimit = keys.length >= limit; const q = query.trim().toLowerCase(); const rows = keys.filter((k) => (!q || k.name.toLowerCase().includes(q) || k.secret.toLowerCase().includes(q)) && (status === "all" || (status === "active") === k.enabled)); const createButton = ( <Button variant="brand" size="sm" disabled={isAtLimit} onClick={() => setCreateOpen(true)}> <Plus className="size-3.5" strokeWidth={1.75} /> Create API key </Button> ); return ( <AppShell active="API keys"> {keys.length === 0 ? ( <PageColumn className="flex-1"> <EmptyState fill as="h1" glyph={<GlyphTile name="apiKeys" className="size-16 p-2" />} title="No API keys yet" description="Use these keys to authenticate requests to the API. Keep them secret." actions={createButton} /> </PageColumn> ) : ( <PageColumn className="gap-6"> <PageHeader title="API Keys" description="Use these keys to authenticate requests to the API." action={isAtLimit ? <span title={isPaid ? `Your plan includes ${limit} keys.` : "Upgrade your plan to create more keys"}>{createButton}</span> : createButton} /> {!isPaid && ( <section className="flex items-center gap-6 rounded-card bg-surface px-5 py-4 shadow-card"> <div className="min-w-0 flex-1"> <p className="text-body-sm text-fg"> You’ve used {keys.length} of {limit} API keys on the Free plan. </p> <p className="mt-1 text-body-sm text-fg-muted"> {keys.length > limit ? "Existing keys keep working, but creating another key requires upgrading." : `Upgrade to create up to ${PAID_KEY_LIMIT} named keys, rotate them anytime, and scope each one to the APIs it needs.`} </p> </div> <Button variant="outline" size="sm" onClick={() => setUpgraded(true)}> Try paid plan <ArrowRight className="size-3.5" strokeWidth={1.5} /> </Button> </section> )} <DataToolbar> <DataToolbarSearch value={query} onValueChange={setQuery} placeholder="Search keys" label="Search API keys" /> <DataToolbarActions> <Select value={status} onValueChange={setStatus}> <SelectTrigger appearance="ghost" aria-label="Status"> <SelectValue /> </SelectTrigger> <SelectContent align="end"> <SelectItem value="all">All statuses</SelectItem> {keys.some((k) => k.enabled) && <SelectItem value="active">Active</SelectItem>} {keys.some((k) => !k.enabled) && <SelectItem value="disabled">Disabled</SelectItem>} </SelectContent> </Select> </DataToolbarActions> </DataToolbar> {/* On a phone only the name, key and actions show, in fixed columns so the key truncates inside the card. */} <Table aria-label="API keys" className="max-sm:table-fixed"> <TableHeader> <TableRow className="hover:bg-transparent"> <TableHead className="px-3">Name</TableHead> <TableHead className="px-3">Key</TableHead> <TableHead className="hidden px-3 xl:table-cell">Permissions</TableHead> <TableHead className="hidden px-3 sm:table-cell">Status</TableHead> <TableHead className="hidden px-3 lg:table-cell">Usage</TableHead> <TableHead className="w-12 px-3"> <span className="sr-only">Actions</span> </TableHead> </TableRow> </TableHeader> <TableBody> {rows.length === 0 && <EmptyStateRow colSpan={6}>No keys match these filters.</EmptyStateRow>} {rows.map((k) => ( <ApiKeyRow key={k.id} apiKey={k} canDelete={keys.length > 1} canRename={isPaid} onEdit={() => { setEditing(k); setEditedName(k.name); setPermissions(k.permissions); }} onToggle={() => setKeys((all) => all.map((key) => key.id === k.id ? { ...key, enabled: !key.enabled } : key))} onDelete={() => setKeys((all) => all.filter((key) => key.id !== k.id))} /> ))} </TableBody> </Table> </PageColumn> )} <CreateKeyDialog open={createOpen} onOpenChange={setCreateOpen} onCreate={(name) => { const id = crypto.randomUUID(); setKeys((all) => [...all, { id, name, secret: `ctx_example_${id.replaceAll("-", "")}`, created: "Just now", permissions: "Full access", enabled: true, credits: 0 }]); }} /> <Dialog open={editing !== null} onOpenChange={(open) => { if (!open) setEditing(null); }}><DialogContent> <form onSubmit={(event) => { event.preventDefault(); setKeys((all) => all.map((key) => key.id === editing?.id ? { ...key, name: isPaid ? editedName.trim() : key.name, permissions } : key)); setEditing(null); }}> <DialogHeader><DialogTitle>Edit key</DialogTitle><DialogDescription>Update this key's name and access.</DialogDescription></DialogHeader> <DialogBody className="grid gap-4"><Field label="Name"><Input value={editedName} disabled={!isPaid} onChange={(event) => setEditedName(event.target.value)} required /></Field><Field label="Permissions"><select className="h-10 rounded-control border border-line bg-surface px-3 text-body-sm" value={permissions} onChange={(event) => setPermissions(event.target.value)}><option>Full access</option><option>Scrape only</option><option>Read only</option></select></Field></DialogBody> <DialogFooter><Button variant="outline" onClick={() => setEditing(null)}>Cancel</Button><Button type="submit" variant="brand" disabled={!editedName.trim()}>Save</Button></DialogFooter> </form> </DialogContent></Dialog> </AppShell> );}function ApiKeyRow({ apiKey: k, canDelete, canRename, onEdit, onToggle, onDelete }: { apiKey: ApiKey; canDelete: boolean; canRename: boolean; onEdit: () => void; onToggle: () => void; onDelete: () => void }) { return ( <TableRow className="hover:bg-transparent"> <TableCell className="px-3"> <span className="flex items-center gap-2.5"> <KeyRound className={cn("size-4 shrink-0", k.enabled ? "text-brand" : "text-fg-subtle")} strokeWidth={1.5} /> <span className="min-w-0"> <span className="block truncate" title={k.name}> {k.name} </span> <span className="block text-caption whitespace-nowrap text-fg-subtle">Created {k.created}</span> </span> </span> </TableCell> <TableCell className="px-3"> <SecretField secret={k.secret} visibleChars={VISIBLE_SECRET_CHARS} label={`${k.name} key`} className="w-full max-w-75" /> </TableCell> <TableCell className="hidden px-3 xl:table-cell"> <button type="button" onClick={onEdit} aria-label={`Edit permissions for ${k.name}`} className="whitespace-nowrap text-fg-muted underline-offset-4 outline-none transition-colors duration-150 hover:text-fg hover:underline focus-visible:ring-2 focus-visible:ring-focus-ring"> {k.permissions} </button> </TableCell> <TableCell className="hidden px-3 sm:table-cell"> <span className={cn("flex items-center gap-1.5", k.enabled ? "text-success" : "text-fg-subtle")}> <span className={cn("size-1.5 rounded-pill", k.enabled ? "bg-success" : "bg-neutral-30")} aria-hidden /> {k.enabled ? "Active" : "Disabled"} </span> </TableCell> <TableCell className="hidden px-3 lg:table-cell"> <a href="/usage" title="Credits consumed in the last 30 days" className="group flex items-center gap-1 whitespace-nowrap text-fg-subtle no-underline outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"> {k.credits === undefined ? ( <span className="text-fg-muted">Usage unavailable</span> ) : ( <span className="tabular-nums"> <span className="text-fg decoration-fg/25 underline-offset-4 group-hover:underline">{credits.format(k.credits)}</span> credits · 30d </span> )} <ArrowUpRight aria-hidden className="size-3.5 shrink-0 opacity-0 transition-opacity duration-150 ease-out group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none" strokeWidth={1.5} /> </a> </TableCell> <TableCell className="px-3 py-1.5"> <DropdownMenu> <DropdownMenuTrigger aria-label={`Actions for ${k.name}`} className="grid size-8 place-items-center rounded-control text-fg-subtle outline-none transition-colors duration-150 hover:bg-wash-hover hover:text-fg focus-visible:ring-2 focus-visible:ring-focus-ring data-[state=open]:bg-wash-hover data-[state=open]:text-fg" > <MoreVertical className="size-4" strokeWidth={1.5} /> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onSelect={onEdit}> <Pencil strokeWidth={1.5} /> {canRename ? "Edit name and permissions" : "Edit permissions"} </DropdownMenuItem> {k.enabled ? ( <DropdownMenuHoldItem onConfirm={onToggle} className="text-fg-muted data-[highlighted]:bg-wash-hover data-[highlighted]:text-fg"> <Pause strokeWidth={1.5} /> Disable key </DropdownMenuHoldItem> ) : ( <DropdownMenuItem onSelect={onToggle}> <Play strokeWidth={1.5} /> Enable key </DropdownMenuItem> )} {canDelete && ( <DropdownMenuHoldItem onConfirm={onDelete}> <Trash2 strokeWidth={1.5} /> Delete key </DropdownMenuHoldItem> )} </DropdownMenuContent> </DropdownMenu> </TableCell> </TableRow> );}function CreateKeyDialog({ open, onOpenChange, onCreate }: { open: boolean; onOpenChange: (open: boolean) => void; onCreate: (name: string) => void }) { const [name, setName] = React.useState(""); const [tried, setTried] = React.useState(false); const error = tried && !name.trim() ? "Give the key a name so you can tell it apart." : undefined; return ( <Dialog open={open} onOpenChange={(next) => { onOpenChange(next); if (!next) { setName(""); setTried(false); } }} > <DialogContent> <DialogHeader> <DialogTitle>Create API key</DialogTitle> <DialogDescription>Name the key after where it’ll run, and limit it to the APIs that app needs.</DialogDescription> </DialogHeader> <form id="create-key" onSubmit={(e) => { e.preventDefault(); setTried(true); if (name.trim()) { onCreate(name.trim()); setName(""); setTried(false); onOpenChange(false); } }} > <DialogBody> <Field label="Name" htmlFor="key-name" error={error}> <Input id="key-name" autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="Production server" aria-invalid={error ? true : undefined} /> </Field> </DialogBody> </form> <DialogFooter> <Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}> Cancel </Button> <Button variant="brand" size="sm" type="submit" form="create-key"> Create key </Button> </DialogFooter> </DialogContent> </Dialog> );}Reference: context-dashboard c0ddf7a src/components/api-keys/api-keys-page.tsx.