list-page-01
A list page — search, a Status facet and Mine | Team over one set of records as a table or a grid of linked tiles, with Load more.
Last updated
Customers
Everyone you sell to, with their projects and status.
| Customer | Status | Projects | Updated | Actions |
|---|---|---|---|---|
| Skyline HotelsPortland | 2 | |||
| Cobalt StudiosBoston | 0 | |||
| Granite LawBoston | 5 | |||
| Delta WorkspaceSeattle | 8 | |||
| Lumen GalleriesMiami | 0 | |||
| Kestrel AirMiami | 12 | |||
| Horizon DentalDenver | 9 | |||
| Maple ClinicsMiami | 6 | |||
What it is
A list page — FilterBar search, a Status facet and Mine | Team over the same records as a table or a grid of linked tiles, with Load more and three empty tiers. It is a block: you pull it once with shadcn add, and then you own it —
nothing re-pulls it and no integrity hash tracks your edits, unlike the components it composes.
CustomerList in components/ holds the filters, both views and the empty tiers; sample-customers.ts is the stand-in data.
Install
npx shadcn@latest add @vegastack/list-page-01This installs the block's own files plus the components it composes: app-shell, badge, button, data-list, dropdown-menu, empty, filter-bar, item, load-more, page-header, relative-time, skeleton, tabs, view-toggle.
Preview
Customers
Everyone you sell to, with their projects and status.
| Customer | Status | Projects | Updated | Actions |
|---|---|---|---|---|
| Skyline HotelsPortland | 2 | |||
| Cobalt StudiosBoston | 0 | |||
| Granite LawBoston | 5 | |||
| Delta WorkspaceSeattle | 8 | |||
| Lumen GalleriesMiami | 0 | |||
| Kestrel AirMiami | 12 | |||
| Horizon DentalDenver | 9 | |||
| Maple ClinicsMiami | 6 | |||
Anatomy
AppShellPage›PageHeaderh1 "Customers" with a "New customer" link, and under the title itstabsrow: Mine | Team as default (pill)Tabs, with the Grid | ListViewToggleinview, pinned to the row's end.FilterBar searchPlacement="start"— the search first, then theFilterBarFacets "Status: Any" and "Industry".- List — a
DataListwithgetRowHref, so each row is a real link (the first cell, name over city, is the link; a click anywhere on the row follows it), a statusBadge, atabular-numscount, aRelativeTime, androwActionsColumnfor Edit and Archive. - Grid — the same records grouped by industry: an
h2in the section face over anItemGroupgrid (@sm:grid-cols-2 @4xl:grid-cols-3) of whole-tile links. - Both views page with Load more (
loadMoreonDataList,LoadMoreunder the grid).
Empty tiers
| Tier | When | Copy | Action |
|---|---|---|---|
| Nothing yet | the scope has no records at all | No customers yet (Mine: You have no customers yet) | New customer |
| No matches | filters hide every record | No matches | Clear filters |
| Couldn’t load | the first load failed | Couldn’t load customers | Try again |
Keep the view and filters
Keep the last view and filters per person for the session, so Back returns to the same list.
Storage can throw (a privacy mode, a full quota, a sandboxed frame) and can hold anything, so read it
in an effect rather than a useState initializer (the server has no sessionStorage, and an
initializer that reads it renders differently on the client), guard both directions, and accept only
the values the page knows — anything else falls back to the default:
const KEY = "customers:list";
const VIEWS = ["list", "grid"] as const;
const SCOPES = ["mine", "team"] as const;
const STATUSES = ["Active", "Prospect", "Paused"] as const;
const oneOf = <T extends string>(
allowed: readonly T[],
value: unknown,
): value is T =>
typeof value === "string" && (allowed as readonly string[]).includes(value);
const [view, setView] = React.useState<(typeof VIEWS)[number]>("list");
const [scope, setScope] = React.useState<(typeof SCOPES)[number]>("team");
const [status, setStatus] = React.useState<(typeof STATUSES)[number] | null>(
null,
);
const [restored, setRestored] = React.useState(false);
React.useEffect(() => {
try {
const saved: unknown = JSON.parse(sessionStorage.getItem(KEY) ?? "null");
if (saved && typeof saved === "object") {
const { view, scope, status } = saved as Record<string, unknown>;
if (oneOf(VIEWS, view)) setView(view);
if (oneOf(SCOPES, scope)) setScope(scope);
if (oneOf(STATUSES, status)) setStatus(status);
}
} catch {
// Unreadable or malformed: keep the defaults.
}
setRestored(true);
}, []);
React.useEffect(() => {
if (!restored) return; // never overwrite the saved state with the defaults
try {
sessionStorage.setItem(KEY, JSON.stringify({ view, scope, status }));
} catch {
// Storage unavailable: the page still works, it just forgets.
}
}, [restored, view, scope, status]);A preset route is a path segment, never query parameters: /customers/mine renders the same
page with its initial scope set to Mine. Make each preset its own static route, so there is nothing
to parse or validate and it cannot collide with the /customers/[id] record route beside it (a
static segment wins over a dynamic one):
// app/customers/mine/page.tsx
export default function Page() {
return <CustomerList initialScope="mine" />;
}initialScope is a prop you add to your copy of CustomerList, typed as the same "mine" | "team"
union. The link someone followed wins over what the session remembered, so skip restoring scope
when initialScope is set; their changes after that are saved as above.
Files
| File | Registry type | Target |
|---|---|---|
list-page-01/components/customer-list.tsx | registry:component | app/list-page-01/components/customer-list.tsx |
list-page-01/components/sample-customers.ts | registry:component | app/list-page-01/components/sample-customers.ts |
list-page-01/page.tsx | registry:page | app/list-page-01/page.tsx |