跳转至

Patterns — the catalogue of admin shapes

This is the vocabulary an agent composes from. Each pattern is a page shape (or a building block) with a fixed contract, a canonical example in the repo, and the exact files to copy. When adding to this app, find the closest pattern here, copy its example, and follow its invariants — don't invent a new structure.

Status: ✅ implemented & verified · 🚧 planned (spec below is the build target).

See ROADMAP.md for sequencing. Once a pattern ships, it is reachable through .claude/skills/: the operation skills (add-backend, …) have their own dir, and every UI shape is a component inside the single add-component skill (references/<name>.md + a flat templates/). The Skill column below names either the operation skill or the add-component component.


Catalogue

Pattern Status Use when Canonical example Skill
CRUD table List rows with server-side paginate/sort/search/filter + create/edit/delete + bulk select features/products, routes/_app/products.tsx add-backend
Detail / Show View one record (/<resource>/$id) + edit/delete entry routes/_app/products_.$id.tsx add-detail-page shape in add-component
Master-detail / nested List + record detail in a side panel (selection in URL) routes/_app/orders.tsx + orders.$id.tsx add-master-detail shape in add-component
Card / grid list Gallery of cards (same data plumbing as the table) routes/_app/posts.tsx + features/posts/cards.tsx add-card-list shape in add-component
Form page / dialog Create/edit via a shared keyed-remount dialog (or full-page route) features/products/ProductFormDialog.tsx add-form shape in add-component
Chart page Dashboard/analytics view from datasets routes/_app/index.tsx (chart components) add-chart-page shape in add-component

Building blocks (atoms every pattern uses)

Block Status Notes
DataTable src/infra/table/DataTable.tsx — generic controlled table; URL-synced via useTableSearch, debounced search
StatusChip, ActionMenu src/infra/ui/*
UI primitives (28) src/components/ui/* (shadcn-on-base-ui)
Form system (TanStack Form + zod + <FormField>) src/components/form/*TextField/NumberField/SelectField/TextareaField/SubmitButton/FormError
Toast sonner via src/lib/toast.ts; mutations report success/error
<ConfirmDialog> / useConfirm() src/components/ui/confirm-dialog.tsx — promise-based, replaces window.confirm
Chart components (Area/Bar/Pie/StatCard) src/components/charts/* — generic over row type, CSS-var themed
Date / range / combobox / upload 🚧 Phase 1+ as needed
app.config (brand/nav/theme) src/config/app.ts — single rebrand surface

Data access

Piece Status Notes
Repository<T, TInput> interface src/infra/data/repository.tslist/getOne/create/update/remove
drizzleRepository src/infra/data/drizzle-repository.ts — backs products/orders when DATABASE_URL is set
memoryRepository src/infra/data/memory-repository.ts — zero-config default; backs products/orders with no DB (features/*/demo-data.ts)
restRepository src/infra/data/rest-repository.ts — backs posts (jsonplaceholder)
graphqlRepository src/infra/data/graphql-repository.ts — shipped + unit-tested
AuthProvider seam src/lib/auth-provider.ts (server) + src/lib/auth-client.ts (browser) — auth backend is a swappable preset

See docs/data-adapters.md for data adapters and docs/backends.md for swapping the data/auth backend.


Business cases (composed verticals)

The sidebar's two business-case groups assemble the patterns above into complete back-offices — the closest thing to "a real app" in the repo, and the best worked examples of composition. products/orders are real Drizzle resources (with an in-memory fallback); the others are memory-backed (features/<name>/ binds memoryRepository(demoX, {…}) directly — zero-config, no Drizzle table) and independently removable.

Scenario Routes Composes
E-commerce /, /products, /orders, /customers, /refunds, /posts chart page (home) · CRUD table · detail · master-detail · card list (posts)
Sales (CRM) routes/_app/crm/* kanban (deals) · table (contacts) · card list + detail (companies) · charts

To persist a memory-backed scenario, add a Drizzle table and switch its server.ts to drizzleRepository — the queries / table / form / detail are unchanged.


Shared invariants (every pattern obeys these)

  1. List params are one shape everywhere: { page, pageSize, search?, sortBy?, sortDir?, filters? } → returns { rows, total }.
  2. Data only via server functions (createServerFn), each calling requireUser() (and later requireRole). Adapters/secrets never enter the client bundle.
  3. List/selection state lives in the URL (validateSearch), not local useState.
  4. Query keys: ["<resource>", "list", params] / ["<resource>", "detail", id]. Mutations invalidate ["<resource>"].
  5. Feedback: mutations show a toast; destructive actions go through useConfirm().
  6. States: every pattern handles loading (skeleton), empty, and error explicitly.
  7. A resource lives in features/<name>/; a route in routes/_app/<name>....

✅ CRUD table (reference, implemented)

Files - src/features/products/schema.ts (zod + statuses), server.ts (server fns with requireUser), queries.ts (TanStack Query hooks), columns.tsx, config.ts. - src/routes/_app/products.tsx — the page (DataTable + create/edit dialog). - src/infra/table/DataTable.tsx — the generic controlled table.

Contract — page owns list state and passes it to DataTable; DataTable renders toolbar (search/filter/refresh) + table (sortable headers) + pagination, all controlled.

Add onebun run create-resource <name> scaffolds the whole vertical, then bun run db:generate && bun run db:migrate. (Today: inline drizzle. After Phase 2: the generated server.ts binds a Repository adapter.)

Known gaps vs target — list state is local useState (→ URL, Phase 1); delete uses window.confirm (→ useConfirm, Phase 1); no bulk-select (Phase 3); inline drizzle (→ repository, Phase 2).


🚧 Detail / Show (build target)

Route routes/_app/<resource>/$id.tsxloader prefetches getOne(id) via ensureQueryData; notFound() when missing. Layout header (title + status + actions: Edit/Delete) → sections of <DescriptionList> (a new atom) → related lists (reuse DataTable/card-list scoped to the parent id). Edit opens the form (dialog or /$id/edit). Invariants: detail query key ["<resource>","detail",id]; breadcrumb to the list.

🚧 Master-detail / nested (build target)

Two flavours, both via nested routes: - Split: routes/_app/<resource>.tsx keeps the list; …/<resource>.$id.tsx renders a side panel <Outlet> beside it (list stays mounted, selection in URL). - Record tabs: …/$id.tsx is a layout with tabbed sub-routes (…/$id/index, …/$id/activity, …/$id/settings). Invariants: selection/active-tab in the URL; the list query stays cached while the panel loads its own detail query.

🚧 Card / grid list (build target)

Same data plumbing as the table — extract a shared useResourceList(repository, params) hook (list state from URL + query). CardList renders a responsive grid of a per-resource <Card> renderer (parallel to columns.tsx, e.g. cards.tsx), reusing the existing toolbar + pagination controls. Use for galleries, people, products-as-cards.


🚧 Atoms (build targets, condensed)

  • Form system<Form> (wraps TanStack Form's useForm with zod Standard-Schema validators) + <FormField name> bound to ui/field/ui/label; surfaces server validation errors. Replaces hand-rolled forms.
  • Toasttoast.success/error; mutations report through it.
  • ConfirmDialogconst ok = await confirm({title, description, destructive}).
  • Chartscomponents/charts/{AreaChart,BarChart,PieChart,StatCard}.tsx, themed via lib/color-theme, responsive, consistent tooltip/legend. Dashboard refactors onto them.
  • app.configsrc/config/app.ts: { name, logo, nav, theme }; consumed by the sidebar, root head, and auth pages. The single place to rebrand.

🚧 Data-source adapter (portability-critical)

// src/infra/data/repository.ts
export interface ListParams {
  page: number; pageSize: number;
  search?: string; sortBy?: string; sortDir?: "asc" | "desc";
  filters?: Record<string, string>;
}
export interface ListResult<T> { rows: T[]; total: number; }

export interface Repository<T, TInput> {
  list(params: ListParams): Promise<ListResult<T>>;
  getOne(id: string): Promise<T | null>;
  create(input: TInput): Promise<T>;
  update(id: string, input: Partial<TInput>): Promise<T>;
  remove(id: string): Promise<void>;
}

Adapters implement the same interface: - drizzleRepository(table, { searchColumns, sortColumns }) — Postgres via Drizzle. - restRepository<T>({ baseUrl, path, map }) — proxy to an existing REST API (called inside a server fn, so credentials stay server-side). - graphqlRepository(...) — same interface over a GraphQL endpoint.

A resource's server.ts picks an adapter; the rest of the vertical (queries, table, detail, card-list) is unchanged regardless of backend. This is what "port it into the shape I need" relies on — swap the adapter, keep the shapes.