@zerotal/admin 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +344 -0
  4. package/package.json +78 -0
  5. package/src/Cluster.ts +50 -0
  6. package/src/Panel.ts +288 -0
  7. package/src/PanelInstance.ts +644 -0
  8. package/src/Resource.ts +918 -0
  9. package/src/actions/Action.ts +607 -0
  10. package/src/actions/ImportRecordsJob.ts +108 -0
  11. package/src/actions/csv.ts +123 -0
  12. package/src/actions/index.ts +39 -0
  13. package/src/actions/render.tsx +181 -0
  14. package/src/actions/transfer.ts +307 -0
  15. package/src/actions/xlsx.ts +304 -0
  16. package/src/auth/AuthLayout.tsx +34 -0
  17. package/src/auth/index.ts +13 -0
  18. package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
  19. package/src/auth/pages/LoginPage.tsx +121 -0
  20. package/src/auth/pages/ProfilePage.tsx +216 -0
  21. package/src/auth/pages/ResetPasswordPage.tsx +103 -0
  22. package/src/auth/pages/VerifyEmailPage.tsx +68 -0
  23. package/src/auth/register.ts +44 -0
  24. package/src/authRoles.ts +141 -0
  25. package/src/commands/MakeAdminResourceCommand.ts +181 -0
  26. package/src/config.ts +128 -0
  27. package/src/dashboardLayout.ts +101 -0
  28. package/src/databaseMedia.ts +148 -0
  29. package/src/databaseNotifications.ts +169 -0
  30. package/src/form/Field.ts +928 -0
  31. package/src/form/ResourceForm.ts +48 -0
  32. package/src/form/Section.ts +364 -0
  33. package/src/form/editors.ts +43 -0
  34. package/src/form/index.ts +59 -0
  35. package/src/history.ts +151 -0
  36. package/src/impersonation.ts +126 -0
  37. package/src/index.ts +380 -0
  38. package/src/infolist/Entry.ts +537 -0
  39. package/src/infolist/Section.ts +99 -0
  40. package/src/infolist/index.ts +38 -0
  41. package/src/media.ts +297 -0
  42. package/src/notifications.ts +65 -0
  43. package/src/pages/AdminPage.ts +100 -0
  44. package/src/pages/ConsolePage.tsx +324 -0
  45. package/src/pages/DashboardPage.tsx +264 -0
  46. package/src/pages/MediaPage.tsx +346 -0
  47. package/src/pages/NotificationsPage.tsx +155 -0
  48. package/src/pages/RecordViewPage.tsx +951 -0
  49. package/src/pages/ResourceFormPage.tsx +1856 -0
  50. package/src/pages/ResourceListPage.tsx +2552 -0
  51. package/src/pages/RolesPage.tsx +325 -0
  52. package/src/pages/SearchPage.tsx +169 -0
  53. package/src/plugin.ts +283 -0
  54. package/src/provider/AdminAbilityMiddleware.ts +25 -0
  55. package/src/provider/AdminGuardMiddleware.ts +29 -0
  56. package/src/provider/AdminProvider.ts +334 -0
  57. package/src/relations/RelationManager.ts +114 -0
  58. package/src/renderHooks.ts +86 -0
  59. package/src/roles.ts +175 -0
  60. package/src/savedViews.ts +79 -0
  61. package/src/support/ability.ts +73 -0
  62. package/src/support/authorize.ts +105 -0
  63. package/src/support/countCache.ts +37 -0
  64. package/src/support/hostPage.ts +30 -0
  65. package/src/table/Column.ts +353 -0
  66. package/src/table/Constraint.ts +238 -0
  67. package/src/table/Filter.ts +275 -0
  68. package/src/table/Group.ts +73 -0
  69. package/src/table/Tab.ts +77 -0
  70. package/src/testing.ts +121 -0
  71. package/src/theme.ts +70 -0
  72. package/src/ui/AdminLayout.tsx +355 -0
  73. package/src/ui/Breadcrumbs.tsx +84 -0
  74. package/src/ui/environmentIndicator.tsx +63 -0
  75. package/src/ui/icons.tsx +124 -0
  76. package/src/widgets/Widget.ts +251 -0
  77. package/src/widgets/render.tsx +154 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,69 @@
1
+ # Changelog — @zerotal/admin
2
+
3
+ All notable changes to this package are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/); this package
5
+ follows the Zerotal monorepo's unified versioning.
6
+
7
+ **Maturity: `experimental`**
8
+
9
+ ## [Unreleased]
10
+
11
+ ### Added
12
+
13
+ #### Panel structure
14
+
15
+ - `Cluster` — a shared URL segment and one sidebar entry for a group of resources. Members opt in with `static cluster = ShopCluster`, and a cluster's `ability` gates every route inside it.
16
+ - Nested resources — `static parent = { resource: () => PostResource, foreignKey: "post_id" }` moves a resource under its parent's records (`/admin/posts/7/comments`). Every list is scoped to the parent before any tab or filter, and new records inherit the foreign key from the URL rather than from a form field.
17
+ - Singular resources — `static singular = true` collapses list, view and edit into one route for a one-row resource. The row is resolved on first visit and created from the form's defaults if absent.
18
+ - Multiple panels — `Panel.make(id, config)` creates an additional panel with its own resources, pages, widgets, guard, branding and URL prefix. `Panel` remains a facade over the panel owning the current request, so single-panel apps are unaffected.
19
+ - Resources build their own URLs: `routePath()`, `indexUrl()`, `recordUrl()`, `createUrl()`, `editUrl()`. Linking through these lets a resource move into a cluster or under a parent without any caller changing.
20
+
21
+ #### Tables
22
+
23
+ - `queryBuilder(key)` — a build-your-own filter with nested AND/OR rule groups, backed by `textConstraint` / `numberConstraint` / `dateConstraint` / `booleanConstraint` / `selectConstraint`. The whole tree is wrapped in one group so an inner `OR` cannot widen a tab, parent or soft-delete scope, and a rule naming an undeclared constraint is dropped.
24
+ - Active-filter indicators — a chip per filter narrowing the list, each its own undo.
25
+ - `filterLayout` — `"inline"` (default), `"panel"` or `"drawer"`.
26
+ - `tableLayout: "grid"`, `striped`, `stickyHeader`, `density`. The grid derives its cards from the columns already declared.
27
+ - `Resource.emptyState()` — a heading, description, icon and actions in place of a blank table. A narrowed view that matches nothing gets a different, automatic message.
28
+ - `Column.exportable(false)` keeps a column out of CSV exports.
29
+
30
+ #### Actions
31
+
32
+ - `exportAction()` / `bulkExportAction()` — CSV of the current list, matching its search, filters, tab and sort exactly.
33
+ - `importAction()` — CSV import through a two-step modal: pick a file, then map each column to a field. Mapping is seeded by matching headers to field names and labels. Rows are validated through the resource's own fields; failures are reported by line number and skipped.
34
+ - `importAction({ queue: true })` dispatches `ImportRecordsJob`, lifting the inline row cap. `@zerotal/queue` is resolved lazily and stays optional; with no queue configured the import runs inline.
35
+ - `actionGroup([...])` collapses several actions into one dropdown.
36
+ - `replicateAction()` copies a record and opens the copy, with `.excludeAttributes()` and `.beforeReplicaSaved()`.
37
+ - `Action.formUsing(fn)` builds a modal's fields from what it currently holds.
38
+
39
+ #### Infolists and forms
40
+
41
+ - `imageEntry`, `colorEntry`, `codeEntry`, `keyValueEntry`, `repeatableEntry` — the last renders a nested schema once per array item, the read side of `repeater`.
42
+ - `customField(key).render(fn)`, plus `.render()` on `Column` and `Entry`, for controls and cells the catalogue lacks. The renderer owns only the markup; label, validation, sorting and binding still come from the declaration.
43
+
44
+ #### Extensibility
45
+
46
+ - Render hooks — 14 named positions in the panel's chrome (`table.start`, `page.header.end`, `sidebar.end`, …). A hook returning `null` renders nothing; one that throws is logged and skipped. Available to packages through the `admin.panel` binding.
47
+ - `Resource.data()` — back a resource with an API, a file or a computation instead of a model. The panel filters, sorts and paginates in memory, so search, tabs and summaries keep working.
48
+ - `Resource.widgets()` — widgets above a resource's table, using the same builders as the dashboard.
49
+ - `databaseNotifications()` — a ready-made notification provider over `@zerotal/notifications`' `DatabaseChannel`, failing soft to an empty bell at every step.
50
+
51
+ #### Other
52
+
53
+ - Breadcrumbs derived from panel → cluster → parent record → resource → record.
54
+ - `.poll(interval)` on every widget kind; the dashboard refreshes at the shortest interval any of its widgets asked for.
55
+ - `bun zt make:admin-resource <Model>` with `--cluster`, `--parent`, `--foreign-key` and `--singular`.
56
+ - An `admin` template for `bun create zerotal`.
57
+
58
+ - `AdminProvider` also loads `app/admin/index.ts`, so a panel large enough to need a directory can have one. `app/admin.ts` still works and is tried first.
59
+ - `AdminProvider` now boots in the `console` environment as well, solely to register `make:admin-resource`; it mounts no routes there.
60
+ - `recordActions()`, `headerActions()` and `bulkActions()` return `ActionItem[]` — an action or an `ActionGroup`.
61
+ - `registerResourceForm()` is keyed by panel and slug, so two panels can each register the same resource slug.
62
+
63
+ ## [1.0.0] — 2026-08-05
64
+
65
+ _First public release._
66
+
67
+ ### Notes
68
+
69
+ - Conforms to the Zerotal package conventions (provider in `src/provider/`, PascalCase config factory, `ZerotalError`-based errors, test coverage).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zerotal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,344 @@
1
+ # @zerotal/admin
2
+
3
+ > A Filament-style, server-driven admin panel for Zerotal — built on
4
+ > [`@zerotal/flow`](../flow) (reactivity) and [`@zerotal/flow-ui`](../flow-ui) (components).
5
+
6
+ You declare a **Resource** per model; the panel renders the navigation, the data
7
+ table, search, sorting, and pagination for you. Pages are Flow components, so
8
+ everything runs on the server and streams over WebSocket — no API layer, no
9
+ client store.
10
+
11
+ The default UI ships with **light + dark mode out of the box**. Styling currently
12
+ loads Tailwind via the Play CDN (configured in `theme.ts`); swapping to a real
13
+ Tailwind build later only touches that one file.
14
+
15
+ > **Status:** experimental. You get navigation, list pages (search / sort /
16
+ > paginate), a read-only **View** page (infolists), reactive **Create / Edit**
17
+ > forms with validation, a **Delete** row action, and a configurable auth
18
+ > **guard**. The previous config-driven admin has been retired; it remains
19
+ > available in pre-1.1 releases (and on the `admin-legacy` git tag).
20
+
21
+ ## Setup
22
+
23
+ ```ts
24
+ // bootstrap/providers.ts
25
+ import { FlowProvider } from "@zerotal/flow";
26
+ import { AdminProvider } from "@zerotal/admin"; // after FlowProvider
27
+
28
+ export default [FlowProvider, AdminProvider];
29
+ ```
30
+
31
+ ```ts
32
+ // app/admin.ts (auto-loaded on boot)
33
+ import { Panel, Resource, text } from "@zerotal/admin";
34
+ import { User } from "./models/User.ts";
35
+
36
+ Panel.configure({ brand: "Acme", path: "/admin" });
37
+
38
+ class UserResource extends Resource {
39
+ static model = User;
40
+ static navigationIcon = "users";
41
+ static navigationGroup = "Access";
42
+
43
+ static columns() {
44
+ return [
45
+ text("id").sortable(),
46
+ text("name").searchable().sortable(),
47
+ text("email").searchable(),
48
+ text("role").badge((v) => (v === "admin" ? "primary" : "muted")),
49
+ text("created_at").label("Joined").sortable(),
50
+ ];
51
+ }
52
+ }
53
+
54
+ Panel.register(UserResource);
55
+ ```
56
+
57
+ Visit `/admin`. Each resource gets a list page (`/admin/users`), a read-only
58
+ detail page (`/admin/users/:id`), and a Delete action on every row.
59
+
60
+ ### Guarding the panel
61
+
62
+ The panel is public by default — fine locally, not in production. Pass
63
+ `middleware` to gate every route (it runs on the list, view, and dashboard):
64
+
65
+ ```ts
66
+ import { AuthMiddleware } from "@zerotal/auth";
67
+
68
+ Panel.configure({
69
+ path: "/admin",
70
+ middleware: [AuthMiddleware.with({ mustVerifyEmail: true })],
71
+ });
72
+ ```
73
+
74
+ ### View page (infolists)
75
+
76
+ The detail page renders a Filament-style **infolist** — read-only entries grouped
77
+ into sections. Define `infolist()` to customize it; omit it and the page falls
78
+ back to one section derived from `columns()`.
79
+
80
+ ```ts
81
+ import { Resource, section, textEntry, iconEntry } from "@zerotal/admin";
82
+
83
+ class UserResource extends Resource {
84
+ static infolist() {
85
+ return [
86
+ section("Profile")
87
+ .description("Identity and sign-in details")
88
+ .icon("users")
89
+ .columns(2)
90
+ .schema([
91
+ textEntry("name").weight("semibold").size("lg"),
92
+ textEntry("email").icon("mail").copyable(),
93
+ iconEntry("email_verified_at").label("Email verified"), // ✓ / ✗ boolean
94
+ textEntry("role")
95
+ .badge()
96
+ .color((v) => (v === "admin" ? "primary" : "muted")),
97
+ ]),
98
+ section("Activity")
99
+ .columns(2)
100
+ .schema([
101
+ textEntry("created_at").label("Joined").dateTime(),
102
+ textEntry("created_at").label("Member for").since(),
103
+ ]),
104
+ ];
105
+ }
106
+ }
107
+ ```
108
+
109
+ Entry API: `.label()`, `.state(fn)`, `.default()`, `.placeholder()`, `.format(fn)`,
110
+ `.badge()`, `.color()`, `.icon()`, `.copyable()`, `.url(fn)`, `.weight()`, `.size()`,
111
+ `.tooltip()`, `.columnSpan()`, `.limit()`, `.date()` / `.dateTime()` / `.since()`,
112
+ `.money()`. `iconEntry(...)` renders a boolean check/cross. Section API:
113
+ `.heading()`, `.description()`, `.icon()`, `.columns()`, `.collapsible()`.
114
+
115
+ ### Create / Edit forms
116
+
117
+ Declare `form()` to get reactive Create (`/admin/users/create`) and Edit
118
+ (`/admin/users/:id/edit`) pages. Fields bind two-way over Flow's WebSocket
119
+ runtime and validate on save — no API layer, no client store.
120
+
121
+ ```ts
122
+ import { Resource, textInput, textarea, select } from "@zerotal/admin";
123
+ import { Hash } from "@zerotal/auth";
124
+
125
+ class UserResource extends Resource {
126
+ static form() {
127
+ return [
128
+ textInput("name").required().minLength(2).maxLength(120),
129
+ textInput("email").email().required(),
130
+ select("role").options({ admin: "Admin", member: "Member" }).required(),
131
+ // Hash on save; only ask for it when creating.
132
+ textInput("password")
133
+ .password()
134
+ .required()
135
+ .minLength(8)
136
+ .visibleOn("create")
137
+ .mutate((v) => Hash.make(String(v))),
138
+ ];
139
+ }
140
+ }
141
+ ```
142
+
143
+ Field API: `textInput` / `textarea` / `select` / `checkbox`; type modifiers
144
+ `.email()`, `.password()`, `.numeric()`, `.url()`, `.tel()`; `.label()`,
145
+ `.placeholder()`, `.helperText()`, `.default()`, `.required()`, `.minLength()` /
146
+ `.maxLength()` / `.min()` / `.max()`, `.confirmed()`, `.options()`, `.rows()`,
147
+ `.columnSpan()`, `.disabled()`, `.rule(fn)`, `.mutate(fn)` (transform before
148
+ save, e.g. hashing), and `.visibleOn()` / `.hiddenOn()` to vary a field by page.
149
+
150
+ ## Column API (Phase 1)
151
+
152
+ ```ts
153
+ text("name")
154
+ .label("Full name") // header text (defaults to a title-cased key)
155
+ .sortable() // clickable, URL-driven sort header
156
+ .searchable() // included in the list search box
157
+ .align("end") // "start" | "center" | "end"
158
+ .format((v) => …) // custom value formatter
159
+ .badge((v) => "success" | "primary" | "muted" | "destructive" | null)
160
+ ```
161
+
162
+ ## Theming / dark mode
163
+
164
+ `theme.ts` defines the shadcn-style design tokens (the same ones flow-ui
165
+ components consume) for both `:root` and `.dark`, plus a no-flash init script.
166
+ The toggle in the top bar persists the choice to `localStorage`. To rebrand,
167
+ edit the `--primary` (and friends) HSL values in `theme.ts`.
168
+
169
+ ## Listing: tabs + default sort
170
+
171
+ Add filter **tabs** (Filament's `getTabs()`) above the table, each scoping the
172
+ query with an optional count badge, and set a **default sort**:
173
+
174
+ ```ts
175
+ import { Resource, tab } from "@zerotal/admin";
176
+
177
+ class UserResource extends Resource {
178
+ static defaultSort = { column: "created_at", direction: "desc" as const };
179
+
180
+ static tabs() {
181
+ return [
182
+ tab("all").label("All").badge(),
183
+ tab("verified")
184
+ .label("Verified")
185
+ .badge()
186
+ .badgeColor("success")
187
+ .modifyQuery((q) => q.whereNotNull!("email_verified_at")),
188
+ tab("unverified")
189
+ .label("Unverified")
190
+ .badge()
191
+ .modifyQuery((q) => q.whereNull!("email_verified_at")),
192
+ ];
193
+ }
194
+ }
195
+ ```
196
+
197
+ `tab(key)` API: `.label()`, `.icon()`, `.badge(value?)` (no arg = live count),
198
+ `.badgeColor(tone)`, `.modifyQuery(q => q)`. The active tab lives in the URL
199
+ (`?tab=…`) and composes with search, sort, and pagination.
200
+
201
+ Badge **counts are cached** per resource and invalidated automatically whenever
202
+ one of its records is created / updated / deleted (via the ORM's `ModelChanged`
203
+ event), so the `COUNT(*)` per tab runs only after a write — not on every list
204
+ view. Caching is best-effort: with no cache driver bound, counts are computed
205
+ each render.
206
+
207
+ ## Relationships
208
+
209
+ Three pieces, mirroring Filament's relationship support:
210
+
211
+ ```ts
212
+ class PostResource extends Resource {
213
+ static model = Post;
214
+ static eager = ["author"]; // eager-load relations for the table/view
215
+
216
+ static columns() {
217
+ return [
218
+ text("title").searchable().sortable(),
219
+ // BelongsTo column — read the loaded relation
220
+ text("author")
221
+ .label("Author")
222
+ .format((author) => author?.name ?? "—"),
223
+ ];
224
+ }
225
+
226
+ static form() {
227
+ return [
228
+ textInput("title").required(),
229
+ // BelongsTo <select> — options loaded from the related model
230
+ select("userId")
231
+ .label("Author")
232
+ .required()
233
+ .optionsUsing(async () =>
234
+ (await User.all()).map((u) => ({ value: String(u.id), label: u.name })),
235
+ ),
236
+ ];
237
+ }
238
+ }
239
+
240
+ class UserResource extends Resource {
241
+ static model = User;
242
+ // HasMany relation manager — the user's posts, as a linked table on the View page
243
+ static relations() {
244
+ return [hasMany(PostResource, "user_id").title("Posts")];
245
+ }
246
+ }
247
+ ```
248
+
249
+ - `static eager: string[]` — relations to eager-load for list + view (so a column
250
+ or entry can read `row.author.name`).
251
+ - `select(key).optionsUsing(async () => …)` — async option source for a BelongsTo
252
+ picker. FK columns should be cast (`@column("integer")`) so they match the
253
+ parent key type when the ORM resolves the relation.
254
+ - `relations()` + `hasMany(RelatedResource, foreignKey)` — renders the children as
255
+ a table on the parent's View page, linking into their own resource for full CRUD.
256
+
257
+ ## Custom pages
258
+
259
+ Anything that isn't a model to edit — a settings screen, a report, an ops console
260
+ — extends `AdminPage`. It's a Flow component with statics describing where it
261
+ belongs, so the panel mounts the route and adds the sidebar entry for you.
262
+
263
+ ```ts
264
+ import { AdminPage, Panel } from "@zerotal/admin";
265
+
266
+ class ReportsPage extends AdminPage {
267
+ static override slug = "reports";
268
+ static override title = "Reports";
269
+ static override navigationGroup = "Insights";
270
+ static override ability = "reports.view";
271
+
272
+ override async render() {
273
+ return <div>…</div>;
274
+ }
275
+ }
276
+
277
+ Panel.pages(ReportsPage);
278
+ ```
279
+
280
+ `ability` is checked twice: once to draw the sidebar entry, once in the route
281
+ guard. Both run the same resolver, so a link the user can't see is a URL they
282
+ can't open. Abilities resolve through `authorize` in `config/admin.ts`, then the
283
+ `gate` binding when `@zerotal/auth` is installed — and with neither configured
284
+ they're denied outside a development environment, so an unwired panel stays closed
285
+ in production.
286
+
287
+ ## Extending the panel from a package
288
+
289
+ The panel is a host. It publishes a contribution surface as the `admin.panel`
290
+ container binding and names no contributor, so another package can add pages,
291
+ widgets, navigation and search results without depending on this one:
292
+
293
+ ```ts
294
+ // In a contributing provider's onBooting()
295
+ const panel = app.container.tryMake("admin.panel") as AdminHost | undefined;
296
+ if (!panel?.enabled("queue")) return;
297
+ panel.page({ slug: "jobs", page: JobsPage, title: "Jobs", ability: "queue.view" });
298
+ ```
299
+
300
+ Contributors declare the host's shape locally rather than importing it, so nothing
301
+ links the two packages at build time. Contributions are automatic — installing
302
+ both providers is enough — and `plugins: { queue: false }` in `config/admin.ts`
303
+ switches one off without uninstalling it.
304
+
305
+ See [the admin guide](../../docs/admin/extending.md) for the full contribution surface.
306
+
307
+ ## Structuring a larger panel
308
+
309
+ Six resources need no structure. Past that:
310
+
311
+ - **Clusters** give a group of resources a shared URL segment and one sidebar
312
+ entry — `static cluster = ShopCluster` puts a resource at `/admin/shop/products`.
313
+ - **Nested resources** move a resource inside its parent's records:
314
+ `static parent = { resource: () => PostResource, foreignKey: "post_id" }` gives
315
+ `/admin/posts/7/comments`, scoped to that post.
316
+ - **Singular resources** back a single row — `static singular = true` collapses
317
+ list, view and edit into one route.
318
+ - **Multiple panels** serve a second audience: `Panel.make("console", { path: "/app" })`
319
+ gets its own registry, guard and branding.
320
+
321
+ See [Panel Structure](../../docs/admin/structure.md) for the details.
322
+
323
+ ## Scaffolding
324
+
325
+ ```bash
326
+ bun zt make:admin-resource Product
327
+ bun zt make:admin-resource Comment --parent=Post --foreign-key=post_id
328
+ bun zt make:admin-resource Setting --singular
329
+ ```
330
+
331
+ Or start a whole project from the panel: `bun create zerotal my-admin` and choose
332
+ the **Admin** template.
333
+
334
+ ## Not yet covered
335
+
336
+ Multi-tenancy and the 2FA challenge step.
337
+
338
+ > **Design note:** the List and View pages are generated as one subclass per
339
+ > resource and are **URL-driven** — search, sort, pagination, and tabs live in
340
+ > `@url` props that re-seed from the query string on every navigation, so they
341
+ > work for any number of resources. The Create/Edit page is different: it binds a
342
+ > client-reactive `@expose form` object over the WebSocket, which Flow's
343
+ > field-decorator registration ties to a single prototype, so it's a single
344
+ > shared class that resolves the resource from the route slug.
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@zerotal/admin",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "maturity": "experimental",
6
+ "private": false,
7
+ "type": "module",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./auth": "./src/auth/index.ts",
13
+ "./testing": "./src/testing.ts"
14
+ },
15
+ "files": [
16
+ "CHANGELOG.md",
17
+ "src",
18
+ "!src/**/*.test.ts",
19
+ "!src/**/*.test.tsx",
20
+ "!src/**/*.spec.ts",
21
+ "!src/**/__fixtures__/**"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "engines": {
27
+ "bun": ">=1.3.14"
28
+ },
29
+ "scripts": {
30
+ "test": "bun test",
31
+ "typecheck": "tsc --noEmit"
32
+ },
33
+ "dependencies": {
34
+ "@zerotal/cache": "1.0.0",
35
+ "@zerotal/core": "1.0.0",
36
+ "@zerotal/flow": "1.0.0",
37
+ "@zerotal/flow-ui": "1.0.0",
38
+ "@zerotal/validator": "1.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@zerotal/orm": "1.0.0",
42
+ "@zerotal/auth": "1.0.0",
43
+ "@zerotal/queue": "1.0.0"
44
+ },
45
+ "peerDependencies": {
46
+ "@zerotal/orm": "^1.0.0",
47
+ "@zerotal/auth": "^1.0.0",
48
+ "@zerotal/queue": "^1.0.0"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "@zerotal/orm": {
52
+ "optional": true
53
+ },
54
+ "@zerotal/auth": {
55
+ "optional": true
56
+ },
57
+ "@zerotal/queue": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "description": "Auto-generated admin panel (resources, tables, forms, widgets) for Zerotal apps.",
62
+ "keywords": [
63
+ "zerotal",
64
+ "bun",
65
+ "typescript",
66
+ "framework",
67
+ "admin",
68
+ "crud",
69
+ "panel"
70
+ ],
71
+ "repository": {
72
+ "type": "git",
73
+ "url": "git+https://github.com/zerotaldev/zerotal.git",
74
+ "directory": "packages/admin"
75
+ },
76
+ "homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/admin#readme",
77
+ "bugs": "https://github.com/zerotaldev/zerotal/issues"
78
+ }
package/src/Cluster.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Cluster — a named section of the panel that owns a group of resources and
3
+ * pages, giving them a shared URL segment and a single sidebar entry.
4
+ *
5
+ * A panel with thirty resources spread across three business areas is easier to
6
+ * navigate as three clusters than as one long list. Members declare which
7
+ * cluster they belong to; the panel does the rest:
8
+ *
9
+ * export class ShopCluster extends Cluster {
10
+ * static override slug = "shop";
11
+ * static override title = "Shop";
12
+ * static override navigationIcon = "collection";
13
+ * }
14
+ *
15
+ * export class ProductResource extends Resource {
16
+ * static override cluster = ShopCluster; // → /admin/shop/products
17
+ * }
18
+ *
19
+ * The cluster's own `ability` gates the whole section: deny it and every member
20
+ * disappears from the sidebar and refuses its route, without each member having
21
+ * to repeat the check.
22
+ */
23
+ export abstract class Cluster {
24
+ /** URL segment the cluster's members live under. */
25
+ static slug: string;
26
+ /** Heading shown for the cluster in navigation and breadcrumbs. */
27
+ static title: string;
28
+ /** Sidebar label, when it should differ from the title. */
29
+ static navigationLabel?: string;
30
+ /** Navigation icon key (see `ui/icons.ts`). */
31
+ static navigationIcon = "layout-grid";
32
+ /** Optional sidebar group the cluster itself sits in. */
33
+ static navigationGroup?: string;
34
+ /** Sort order within the sidebar (lower = higher). */
35
+ static navigationSort = 0;
36
+ /** Ability gating the whole cluster, checked for the entry and every route. */
37
+ static ability?: string;
38
+
39
+ static getNavigationLabel(): string {
40
+ return this.navigationLabel ?? this.title;
41
+ }
42
+
43
+ /** The cluster's URL prefix under a panel base. */
44
+ static url(base: string): string {
45
+ return `${base}/${this.slug}`;
46
+ }
47
+ }
48
+
49
+ /** A Cluster subclass (used by its static surface — never instantiated). */
50
+ export type ClusterClass = typeof Cluster;