Reusable React components for shadcn-style admin dashboards. Drop-in PageHeader, UserCell, Pagination, SortableHeader, TableCard, EmptyState, MaskedInput, and friends — pulls together the canonical "list page chrome" pattern that's been tuned across multiple @arraypress apps.
This is the first cut (v0.1). Components ship as TypeScript source — your bundler (Vite) compiles them against your project's shadcn primitives.
If you've built two shadcn-based admin dashboards, you've written PageHeader, Pagination, and EmptyState twice. They drift out of sync the moment one project adds a feature. This package locks the canonical versions into one place.
What's deliberately NOT in here:
- Domain components (CustomerEditor, OrderDetails, etc.) — those belong in the consumer app.
- shadcn primitives (Button, Avatar, Card) — generated into your project by
npx shadcn add. The kit imports them from@/components/ui/*. - Pre-bundled JS — components are TSX source; your bundler picks them up.
npm install @arraypress/admin-kitPeer deps (you should already have these in any shadcn admin app): react ≥ 18, react-dom ≥ 18, lucide-react.
This is the bit that requires up-front setup. The components import from your shadcn primitives via the @/ alias — your bundler must resolve that alias for files inside node_modules/@arraypress/admin-kit/ too.
The kit imports from:
@/components/ui/avatar—Avatar,AvatarFallback,AvatarImage@/components/ui/button—Button@/components/ui/card—Card,CardContent@/components/ui/dropdown-menu—DropdownMenu,DropdownMenuTrigger,DropdownMenuContent,DropdownMenuCheckboxItem,DropdownMenuLabel,DropdownMenuSeparator@/components/ui/empty—Empty,EmptyHeader,EmptyMedia,EmptyTitle,EmptyDescription@/components/ui/input—Input@/components/ui/select—Select,SelectContent,SelectItem,SelectTrigger,SelectValue@/components/ui/separator—Separator@/components/ui/sidebar—SidebarTrigger@/components/ui/skeleton—Skeleton
If a primitive is missing, install it: npx shadcn add avatar button card ....
Standard shadcn helper that merges Tailwind class strings:
// src/lib/utils.ts
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: (string | undefined | null | boolean)[]) {
return twMerge(clsx(inputs));
}// vite.config.ts
import path from 'node:path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
});Vite's resolve.alias is global — it applies to imports from anywhere, including node_modules. The kit's source files will resolve @/components/ui/avatar against your project's src/components/ui/avatar.
Standard shadcn install — needs --background, --foreground, --muted-foreground, --card, --border, --accent, --ring defined as CSS variables. If you ran npx shadcn init you have them.
This step is easy to miss and has a subtle symptom. Tailwind v4 auto-detects content from your project root but does NOT cross into node_modules (or file: symlinks). Without telling it to scan admin-kit's TSX source, arbitrary-value classes like sm:max-w-[560px] on <Flyout> width presets land in the DOM but never compile into a CSS rule — so flyouts inherit the shadcn w-3/4 default with no max-width cap and take 75% of the viewport.
Add an @source directive to your main CSS file:
/* src/index.css */
@import "tailwindcss";
@import "tw-animate-css";
@source "../node_modules/@arraypress/admin-kit/src/**/*.tsx";If you're consuming admin-kit via a file: path in a monorepo, point @source at the actual folder:
@source "../../js-libraries/admin-kit/src/**/*.tsx";Rebuild your CSS (Vite does this automatically on next start). Verify by checking that max-width:560px exists in your compiled CSS output.
Symptom checklist: if a component from admin-kit uses any arbitrary-value class (sm:max-w-[…], size-[…], text-[…], h-[…]), it won't apply and you'll see the component "work" with just its non-arbitrary classes falling back. The fix is always this @source line — not a lib bug.
import {
PageHeader, UserCell, SortableHeader, Pagination, TableCard,
EmptyState, MaskedInput, MASKED_SENTINEL,
} from '@arraypress/admin-kit';
import { Users, Plus } from 'lucide-react';
import { Table, TableHead, TableBody, TableCell, TableRow, TableHeader } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
function CustomersPage() {
// ... useDataTable() etc. ...
return (
<>
<PageHeader
title="Customers"
actions={
<Button size="sm"><Plus /> New customer</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<TableCard>
{table.items.length === 0 ? (
<EmptyState
title="No customers yet"
description="Customers appear here after a CSV import."
icon={Users}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>
<SortableHeader
column="email"
currentSort={table.sort}
currentOrder={table.order}
onSort={table.handleSort}
>
Customer
</SortableHeader>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{table.items.map((c) => (
<TableRow key={c.id}>
<TableCell><UserCell email={c.email} name={c.name} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</TableCard>
<Pagination
page={table.page}
pages={table.pages}
total={table.total}
limit={table.limit}
onPageChange={table.handlePageChange}
onLimitChange={table.handleLimitChange}
storageKey="customers"
/>
</div>
</>
);
}Each is documented in its own file's JSDoc — quick summaries here.
| Component | What it does |
|---|---|
PageHeader |
Sticky single-strip header with sidebar trigger + title + actions slot. Replaces per-page <header> boilerplate. |
| Component | What it does |
|---|---|
TableCard |
Outlined card wrapper with optional muted header bar (title left, actions right). |
SortableHeader |
Click-to-sort table column header with direction chevron. Pairs with useDataTable. |
Pagination |
Footer with row-count summary, page-size selector, and first/prev/next/last buttons. |
ColumnVisibility |
"Columns" dropdown with checkboxes for showing/hiding table columns. |
EmptyState |
Zero-state placeholder with icon + title + description + optional CTA slot. |
TableSkeleton |
Loading-state grid of skeleton bars sized to your column count. |
| Component | What it does |
|---|---|
UserCell |
Compact gravatar + name/email stack. Collapses three columns into one dense cell. |
| Component | What it does |
|---|---|
MaskedInput |
Password-style input with "saved — type to replace" UX for sensitive settings. Exports MASKED_SENTINEL constant for matching the server-side check. |
ImageDropZone |
Drop-to-upload image field with single/multi mode + optional media-library picker injection. Returns media IDs, not URLs. |
Three interaction modes collapsed into one component: empty (drop or click to upload), filled-single (preview tile with Change/Remove hover), filled-multi (chip grid + trailing drop zone, first image flagged "Main"). The upload fn and URL builder are injected so the library stays untangled from specific endpoints, auth headers, or toast libraries.
import { ImageDropZone } from '@arraypress/admin-kit';
import { MediaLibraryDialog } from '@/components/MediaLibraryDialog';
async function uploadToMedia(file: File): Promise<number | null> {
// Owns CSRF headers, the endpoint, and error-toast surfacing.
const fd = new FormData(); fd.append('file', file);
const res = await fetch('/admin/api/media/upload', {
method: 'POST', body: fd,
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok) return null;
return (await res.json()).id;
}
<ImageDropZone
value={form.coverImageId}
onChange={(v) => setForm({ ...form, coverImageId: v as number | null })}
upload={uploadToMedia}
mediaUrl={(id) => `/api/media/${id}`}
MediaLibraryDialog={MediaLibraryDialog} // optional — omit to hide the picker button
/>The MediaLibraryDialog prop is optional. When omitted, the "Media library" button is hidden and the zone is upload-only. When present, the dialog must satisfy ImageDropZoneDialogProps — minimum contract is { open, onClose, onSelect(items: { id: number }[]), accept?, multiple? }, so apps can reuse their full-featured dialog or ship a minimal picker.
| Component | What it does |
|---|---|
StatCard |
Big-number tile with muted label and optional icon. Drop into a grid for headline numbers. |
Sheet-based slide-overs with the canonical sticky-header / scrollable-body / sticky-footer layout. Every create / edit / detail flyout in the Sugar* product family shares this chrome.
| Component | What it does |
|---|---|
Flyout |
Root wrapper with width presets (sm/md/lg/xl) and optional dirty guard. When dirty is set, close attempts trigger a confirm dialog — the thing every hand-rolled flyout forgets. |
FlyoutHeader |
Sticky top strip. Two layouts: simple (title + subtitle) or identity (avatar + title/subtitle + badges for detail views). |
FlyoutBody |
Scrollable middle region. flush disables default padding, column turns on a flex-column with gap for sectioned content. |
FlyoutFooter |
Sticky bottom action strip. Default is right-aligned buttons; pass className to override for 2×2 grids etc. |
FlyoutStatStrip / FlyoutStat |
2/3/4-column tile band for detail-view headline numbers (download counts, sizes, timestamps). |
FlyoutSection |
Titled content block with the canonical text-[10px] uppercase tracking-widest eyebrow + optional icon + actions slot. |
FlyoutLoading / FlyoutEmpty |
Centered placeholder states — drop into a body while data loads or when a detail is missing. |
<Flyout open={open} onClose={onClose} width="lg" dirty={hasChanges}>
<FlyoutHeader avatar={<Avatar />} title={customer.name} subtitle={customer.email}
badges={<Badge>Active</Badge>} />
<FlyoutBody flush>
<FlyoutStatStrip>
<FlyoutStat label="Transactions" value={42} />
<FlyoutStat label="First seen" value={formatDate(customer.first_transaction_at)} />
<FlyoutStat label="Last seen" value={formatDate(customer.last_transaction_at)} />
</FlyoutStatStrip>
<FlyoutSection title="Details" divided>…</FlyoutSection>
<FlyoutSection title="Downloads" icon={Download} divided>…</FlyoutSection>
</FlyoutBody>
<FlyoutFooter>
<Button variant="outline">Cancel</Button>
<Button>Save</Button>
</FlyoutFooter>
</Flyout>| Component | What it does |
|---|---|
FileIcon |
Lucide icon picked from a MIME type via @arraypress/mime-types. Audio, video, image, archive, document, or generic download. |
FileUploader |
Drag-drop/click upload zone backed by @arraypress/multipart-upload. Handles all 7 upload states (idle/hashing/duplicate/uploading/paused/complete/error). onUseDuplicate closure for "reuse existing file" flows. |
MediaLibraryDialog |
Browse/search/select/upload dialog for a media library. Optional alt-text + caption editor on the detail panel. Transport via fetchList / mediaUrl / upload closures. |
FileBrowserDialog |
Same shape as MediaLibraryDialog but for file downloads. Grid/list view toggle, colored MIME-category icons, no detail-panel editing. |
BucketImportDialog |
Walks a storage bucket and surfaces objects not yet tracked in the files table. Bulk-register orphan rows without re-uploading. Cursor-paginated via scanBucket closure. |
MediaGallery |
Sortable grid of files/images/mixed media with drag-to-reorder (@dnd-kit), per-item remove, positional "Main" badge, upload + library-pick entry points, optional max cap. |
<FileUploader
uploadBase="/admin/api/upload"
hashCheckUrl="/admin/api/files/check-hash"
headers={{ 'X-Requested-With': 'XMLHttpRequest' }}
metadata={{ productId }}
onComplete={handleUploaded}
onError={toast.error}
onPickFromLibrary={() => setBrowserOpen(true)}
/>
<MediaGallery
value={product.images}
onChange={(next) => setProduct({ ...product, images: next })}
upload={async (file) => {
const id = await uploadToMedia(file);
return id ? { id, kind: 'image', name: file.name } : null;
}}
mediaUrl={(id) => `/api/media/${id}`}
MediaLibraryDialog={MediaLibraryDialog}
max={10}
size="md"
/>| Component | What it does |
|---|---|
PasskeyManager |
Self-service passkey panel — list, register (WebAuthn ceremony handled internally), rename, revoke. Transport-agnostic via a 5-closure api prop; notifications via onError / onSuccess callbacks. Uses @arraypress/passkey/browser internally (optional peer dep). |
<PasskeyManager
defaultDeviceName={`${user.name}'s device`}
formatDate={formatDate}
onError={toast.error}
onSuccess={toast.success}
api={{
list: () => api.passkeys.list(),
registerOptions: () => api.passkeys.registerOptions(),
registerVerify: (d) => api.passkeys.registerVerify(d),
rename: (id, d) => api.passkeys.rename(id, d),
delete: (id) => api.passkeys.delete(id),
}}
/>| Component | What it does |
|---|---|
ConfirmProvider |
Mounts a single reusable Dialog and exposes the useConfirm() hook to descendants. Drop one at the app root. |
useConfirm() |
Returns (opts) => Promise<boolean>. Replaces window.confirm() with a styled modal. Used internally by <Flyout dirty> and PasskeyManager's revoke flow — both gracefully fall back to window.confirm when no provider is mounted. |
// Root:
<ConfirmProvider>
<App />
</ConfirmProvider>
// Anywhere inside:
const confirm = useConfirm();
const ok = await confirm({
title: 'Delete this customer?',
description: 'Their transactions and downloads will also be removed.',
confirmText: 'Delete',
destructive: true,
});
if (!ok) return;
await api.customers.delete(id);For sensitive settings (API keys, OAuth secrets), the standard pattern is:
- Server GET returns
'••••••••'(the sentinel) for any non-empty stored secret. The actual value never leaves the server. - Client renders the field via
<MaskedInput value={...} />. When the value equals the sentinel, the input shows "Saved — type to replace" instead of dots. - User edits the field — focus auto-clears the sentinel, typing replaces it with the new value.
- Server PUT receives the new value. If the incoming value is still the sentinel, the server skips the write (unchanged).
This means an admin can edit other settings on the same page without accidentally re-submitting the masked secret. Match the constant on both ends:
// shared
import { MASKED_SENTINEL } from '@arraypress/admin-kit';Two reasons:
- shadcn primitives are project-specific — the kit can't bundle them because every consumer has slightly different versions (themed colors, custom variants). Resolving
@/components/ui/*at the consumer's bundler step is the only way to get the right primitive. - Tailwind needs to see the class strings — if we shipped pre-built JS with the class names baked in, Tailwind wouldn't pick them up unless you added the lib's dist files to your
contentglob. TSX source means Tailwind's content scan finds them naturally.
The cost: your bundler needs to compile TS in node_modules. Vite + @vitejs/plugin-react handle this transparently. If you're on a different stack (esbuild-only, Webpack with strict module rules), you may need to whitelist the package.
MIT