@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
@@ -0,0 +1,324 @@
1
+ /** @jsxImportSource @zerotal/flow */
2
+ // Renders a ConsoleContribution: tabbed tables with row and header actions.
3
+ // The active tab lives in an `@url` prop so a console is linkable and survives a
4
+ // refresh; actions re-resolve the rows afterwards so the table reflects what the
5
+ // action just did without a full navigation.
6
+
7
+ import { Component, url, expose } from "@zerotal/flow";
8
+ import type { HtmlNode } from "@zerotal/flow";
9
+ import { Table } from "@zerotal/flow-ui";
10
+ import type { TableColumn } from "@zerotal/flow-ui";
11
+ import { AdminLayout, makeAdminLayout } from "../ui/AdminLayout.tsx";
12
+ import { Icon } from "../ui/icons.tsx";
13
+ import { Panel } from "../Panel.ts";
14
+ import type { PanelInstance } from "../PanelInstance.ts";
15
+ import { AdminForbiddenError } from "../support/authorize.ts";
16
+ import type {
17
+ ConsoleColumn,
18
+ ConsoleContribution,
19
+ ConsoleHeaderAction,
20
+ ConsoleRow,
21
+ ConsoleTab,
22
+ } from "../plugin.ts";
23
+ import type { BadgeTone } from "../table/Column.ts";
24
+
25
+ const BADGE_TONE: Record<BadgeTone, string> = {
26
+ default: "bg-secondary text-secondary-foreground",
27
+ primary: "bg-primary/10 text-primary ring-1 ring-inset ring-primary/20",
28
+ success: "bg-success/10 text-success ring-1 ring-inset ring-success/20",
29
+ muted: "bg-muted text-muted-foreground",
30
+ destructive: "bg-destructive/10 text-destructive ring-1 ring-inset ring-destructive/20",
31
+ };
32
+
33
+ const ALIGN: Record<string, string> = {
34
+ start: "text-left",
35
+ center: "text-center",
36
+ end: "text-right tabular-nums",
37
+ };
38
+
39
+ export class ConsolePage extends Component {
40
+ static layout = AdminLayout;
41
+
42
+ /** Set on the per-console subclass by {@link makeConsolePage}. */
43
+ static console: ConsoleContribution;
44
+ /** The panel this console belongs to — set by {@link makeConsolePage}. */
45
+ static panel: PanelInstance;
46
+
47
+ @url tab = "";
48
+
49
+ /** Rows for the active tab, resolved on mount and after every action. */
50
+ @expose rows: ConsoleRow[] = [];
51
+ @expose tabBadges: Record<string, number> = {};
52
+
53
+ private get _console(): ConsoleContribution {
54
+ return (this.constructor as typeof ConsolePage).console;
55
+ }
56
+
57
+ private get _panel(): PanelInstance {
58
+ return (this.constructor as typeof ConsolePage).panel ?? Panel.current();
59
+ }
60
+
61
+ private _activeTab(): ConsoleTab {
62
+ const tabs = this._console.tabs;
63
+ return tabs.find((t) => t.key === this.tab) ?? (tabs[0] as ConsoleTab);
64
+ }
65
+
66
+ override async onMount(): Promise<void> {
67
+ await this._load();
68
+ }
69
+
70
+ private async _load(): Promise<void> {
71
+ const tab = this._activeTab();
72
+ this.rows = tab ? await tab.rows() : [];
73
+
74
+ const badges: Record<string, number> = {};
75
+ await Promise.all(
76
+ this._console.tabs.map(async (t) => {
77
+ if (!t.badge) return;
78
+ try {
79
+ const n = await t.badge();
80
+ if (n !== null && n !== undefined) badges[t.key] = n;
81
+ } catch {
82
+ /* a failing count must not take the console down */
83
+ }
84
+ }),
85
+ );
86
+ this.tabBadges = badges;
87
+ }
88
+
89
+ /**
90
+ * Re-read the active tab. Exposed so the refresh control and the client-side
91
+ * confirmation flow can both land here.
92
+ */
93
+ @expose async refresh(): Promise<void> {
94
+ await this._load();
95
+ }
96
+
97
+ @expose async switchTab(key: unknown): Promise<void> {
98
+ const tab = this._console.tabs.find((t) => t.key === key);
99
+ if (!tab) return;
100
+ this.tab = tab.key;
101
+ await this._load();
102
+ }
103
+
104
+ /**
105
+ * Run a row action.
106
+ *
107
+ * The ability is asserted here rather than trusted from the render pass: these
108
+ * are `@expose`d methods dispatched straight from a client frame, so a user who
109
+ * can load the page could otherwise call them with arguments of their choosing.
110
+ */
111
+ @expose async runRowAction(key: unknown, rowKey: unknown): Promise<void> {
112
+ await this._authorize();
113
+ const tab = this._activeTab();
114
+ const action = tab?.rowActions?.find((a) => a.key === key);
115
+ if (!action) return;
116
+
117
+ const idKey = tab.rowKey ?? "id";
118
+ const row = this.rows.find((r) => String(r[idKey]) === String(rowKey));
119
+ if (!row) return;
120
+
121
+ await this._run(() => action.run(row));
122
+ }
123
+
124
+ @expose async runHeaderAction(key: unknown): Promise<void> {
125
+ await this._authorize();
126
+ const action = this._activeTab()?.headerActions?.find((a) => a.key === key);
127
+ if (!action) return;
128
+ await this._run(() => action.run());
129
+ }
130
+
131
+ /** Re-check the console's ability on every dispatched action. */
132
+ private async _authorize(): Promise<void> {
133
+ if (!(await this._panel.can(this._console.ability))) {
134
+ throw new AdminForbiddenError(this._console.title);
135
+ }
136
+ }
137
+
138
+ /** Run an action, flash whatever it reports, and re-read the table either way. */
139
+ private async _run(fn: () => Promise<string | void> | string | void): Promise<void> {
140
+ try {
141
+ const message = await fn();
142
+ if (message) this.flash(message, "success");
143
+ } catch (err) {
144
+ this.flash(err instanceof Error ? err.message : String(err), "error");
145
+ }
146
+ await this._load();
147
+ }
148
+
149
+ override async render(): Promise<HtmlNode> {
150
+ const console = this._console;
151
+ const active = this._activeTab();
152
+ if (!active) return <div class="text-sm text-muted-foreground">This console has no tabs.</div>;
153
+
154
+ const columns: TableColumn[] = active.columns.map((c) => ({
155
+ key: c.key,
156
+ label: c.label,
157
+ class: cellClass(c),
158
+ render: (row: ConsoleRow) => cell(c, row),
159
+ }));
160
+
161
+ if (active.rowActions?.length) {
162
+ columns.push({
163
+ key: "__actions",
164
+ label: "",
165
+ class: "text-right",
166
+ render: (row: ConsoleRow) => this._rowActions(active, row),
167
+ });
168
+ }
169
+
170
+ return (
171
+ <div class="mx-auto w-full max-w-7xl space-y-6">
172
+ <div class="flex flex-wrap items-start justify-between gap-3">
173
+ <div>
174
+ <h1 class="text-2xl font-semibold tracking-tight">{console.title}</h1>
175
+ {active.description ? (
176
+ <p class="mt-1 text-sm text-muted-foreground">{active.description}</p>
177
+ ) : null}
178
+ </div>
179
+ <div class="flex items-center gap-2">
180
+ {(active.headerActions ?? []).map((a) => this._headerAction(a))}
181
+ <button
182
+ type="button"
183
+ onClick={this.refresh}
184
+ class="inline-flex h-9 items-center gap-1.5 rounded-lg border border-input bg-background px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"
185
+ >
186
+ <Icon name="undo" class="h-4 w-4" />
187
+ Refresh
188
+ </button>
189
+ </div>
190
+ </div>
191
+
192
+ {console.tabs.length > 1 ? (
193
+ <div class="flex flex-wrap items-center gap-1 border-b border-border">
194
+ {console.tabs.map((t) => {
195
+ const isActive = t.key === active.key;
196
+ const count = this.tabBadges[t.key];
197
+ return (
198
+ <button
199
+ type="button"
200
+ onClick={this.switchTab}
201
+ data-args={JSON.stringify([t.key])}
202
+ class={`-mb-px inline-flex items-center gap-2 border-b-2 px-3 py-2 text-sm font-medium transition-colors ${
203
+ isActive
204
+ ? "border-primary text-foreground"
205
+ : "border-transparent text-muted-foreground hover:text-foreground"
206
+ }`}
207
+ >
208
+ {t.label}
209
+ {count !== undefined ? (
210
+ <span class="inline-flex min-w-5 items-center justify-center rounded-full bg-muted px-1.5 py-0.5 text-[11px] font-semibold text-muted-foreground">
211
+ {String(count)}
212
+ </span>
213
+ ) : null}
214
+ </button>
215
+ );
216
+ })}
217
+ </div>
218
+ ) : null}
219
+
220
+ <div class="overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm">
221
+ <div class="overflow-x-auto p-1.5">
222
+ {this.rows.length > 0 ? (
223
+ <Table columns={columns} rows={this.rows} hover />
224
+ ) : (
225
+ <p class="px-4 py-12 text-center text-sm text-muted-foreground">
226
+ {active.empty ?? "Nothing here."}
227
+ </p>
228
+ )}
229
+ </div>
230
+ </div>
231
+ </div>
232
+ );
233
+ }
234
+
235
+ private _rowActions(tab: ConsoleTab, row: ConsoleRow): HtmlNode {
236
+ const idKey = tab.rowKey ?? "id";
237
+ const rowKey = String(row[idKey] ?? "");
238
+ return (
239
+ <div class="flex items-center justify-end gap-1">
240
+ {(tab.rowActions ?? []).map((a) => (
241
+ <button
242
+ type="button"
243
+ onClick={this.runRowAction}
244
+ data-args={JSON.stringify([a.key, rowKey])}
245
+ {...(a.confirm ? { confirm: a.confirm } : {})}
246
+ class={`inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition-colors ${
247
+ a.danger
248
+ ? "text-destructive hover:bg-destructive/10"
249
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
250
+ }`}
251
+ >
252
+ {a.icon ? <Icon name={a.icon} class="h-3.5 w-3.5" /> : null}
253
+ {a.label}
254
+ </button>
255
+ ))}
256
+ </div>
257
+ );
258
+ }
259
+
260
+ private _headerAction(a: ConsoleHeaderAction): HtmlNode {
261
+ return (
262
+ <button
263
+ type="button"
264
+ onClick={this.runHeaderAction}
265
+ data-args={JSON.stringify([a.key])}
266
+ {...(a.confirm ? { confirm: a.confirm } : {})}
267
+ class={`inline-flex h-9 items-center gap-1.5 rounded-lg px-3 text-sm font-medium transition-colors ${
268
+ a.danger
269
+ ? "border border-destructive/30 text-destructive hover:bg-destructive/10"
270
+ : "border border-input bg-background hover:bg-accent hover:text-accent-foreground"
271
+ }`}
272
+ >
273
+ {a.icon ? <Icon name={a.icon} class="h-4 w-4" /> : null}
274
+ {a.label}
275
+ </button>
276
+ );
277
+ }
278
+ }
279
+
280
+ function cellClass(c: ConsoleColumn): string {
281
+ return [ALIGN[c.align ?? "start"], c.mono ? "font-mono text-xs" : ""].filter(Boolean).join(" ");
282
+ }
283
+
284
+ function cell(c: ConsoleColumn, row: ConsoleRow): HtmlNode | string {
285
+ const raw = row[c.key];
286
+ const text = c.format
287
+ ? c.format(raw, row)
288
+ : raw === null || raw === undefined
289
+ ? "—"
290
+ : String(raw);
291
+ const tone = c.badge?.(raw, row) ?? null;
292
+ if (!tone) return text;
293
+ return (
294
+ <span
295
+ class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${BADGE_TONE[tone]}`}
296
+ >
297
+ {text}
298
+ </span>
299
+ );
300
+ }
301
+
302
+ /**
303
+ * Build the page class for one console. A subclass per console — rather than one
304
+ * shared class resolving itself from the route — keeps the console on a static
305
+ * and matches how resource list pages are generated. The explicit name matters:
306
+ * Flow's component registry is keyed by constructor name.
307
+ */
308
+ export function makeConsolePage(
309
+ console: ConsoleContribution,
310
+ panel: PanelInstance = Panel.default(),
311
+ ): typeof ConsolePage {
312
+ const Page = class extends ConsolePage {
313
+ static override console = console;
314
+ static override panel = panel;
315
+ static override layout = makeAdminLayout(panel);
316
+ };
317
+ const pascal = console.slug
318
+ .split(/[^a-zA-Z0-9]+/)
319
+ .filter(Boolean)
320
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
321
+ .join("");
322
+ Object.defineProperty(Page, "name", { value: `${pascal}ConsolePage` });
323
+ return Page;
324
+ }
@@ -0,0 +1,264 @@
1
+ /** @jsxImportSource @zerotal/flow */
2
+ // Admin home: a friendly overview with a card per registered resource.
3
+
4
+ import { Component, expose } from "@zerotal/flow";
5
+ import type { HtmlNode } from "@zerotal/flow";
6
+ import { AdminLayout, makeAdminLayout } from "../ui/AdminLayout.tsx";
7
+ import { Icon } from "../ui/icons.tsx";
8
+ import { Panel } from "../Panel.ts";
9
+ import type { PanelInstance } from "../PanelInstance.ts";
10
+ import { widgetPollInterval } from "../widgets/Widget.ts";
11
+ import { renderWidgets } from "../widgets/render.tsx";
12
+ import type { DashboardWidget } from "../widgets/Widget.ts";
13
+ import { applyLayout, moveKey, reconcile } from "../dashboardLayout.ts";
14
+
15
+ export class DashboardPage extends Component {
16
+ static layout = AdminLayout;
17
+ /** The panel this page belongs to — set by each generated subclass. */
18
+ static panel: PanelInstance;
19
+
20
+ private get _panel(): PanelInstance {
21
+ return (this.constructor as typeof DashboardPage).panel ?? Panel.current();
22
+ }
23
+
24
+ /** Whether the arrange controls are showing. */
25
+ @expose arranging = false;
26
+ /** Every widget on this dashboard, keyed — recomputed each render. */
27
+ private _keyed: { key: string; widget: DashboardWidget; title: string }[] = [];
28
+
29
+ @expose toggleArranging(): void {
30
+ this.arranging = !this.arranging;
31
+ }
32
+
33
+ /** The declared widgets, in declaration order, with their stored identities. */
34
+ private async _allWidgets(): Promise<{ key: string; widget: DashboardWidget; title: string }[]> {
35
+ const panel = this._panel;
36
+ const widgets = [...panel.dashboardWidgets(), ...(await panel.visibleWidgets())];
37
+ return widgets.map((widget, index) => ({
38
+ key: widget.widgetKey(index),
39
+ widget,
40
+ title: (widget as { _title?: string })._title ?? "Overview",
41
+ }));
42
+ }
43
+
44
+ /** Read the current layout, reconciled against the widgets that exist now. */
45
+ private async _layout(): Promise<{ order: string[]; hidden: string[] } | null> {
46
+ const store = this._panel.dashboardLayoutStore();
47
+ if (!store) return null;
48
+ const keys = (await this._allWidgets()).map((k) => k.key);
49
+ return reconcile(await store.load(), keys);
50
+ }
51
+
52
+ @expose async moveWidget(key: unknown, direction: unknown): Promise<void> {
53
+ const store = this._panel.dashboardLayoutStore();
54
+ const layout = await this._layout();
55
+ if (!store || !layout) return;
56
+ await store.save({
57
+ ...layout,
58
+ order: moveKey(layout.order, String(key), Number(direction) < 0 ? -1 : 1),
59
+ });
60
+ }
61
+
62
+ @expose async toggleWidget(key: unknown): Promise<void> {
63
+ const store = this._panel.dashboardLayoutStore();
64
+ const layout = await this._layout();
65
+ if (!store || !layout) return;
66
+ const id = String(key);
67
+ await store.save({
68
+ ...layout,
69
+ hidden: layout.hidden.includes(id)
70
+ ? layout.hidden.filter((k) => k !== id)
71
+ : [...layout.hidden, id],
72
+ });
73
+ }
74
+
75
+ /** Put the dashboard back the way the app declares it. */
76
+ @expose async resetLayout(): Promise<void> {
77
+ const store = this._panel.dashboardLayoutStore();
78
+ if (!store) return;
79
+ await store.save({ order: [], hidden: [] });
80
+ this.flash("Dashboard reset.", "success");
81
+ }
82
+
83
+ /** The "Arrange" toggle, with a note when something is hidden. */
84
+ private _arrangeBar(
85
+ layout: { order: string[]; hidden: string[] },
86
+ hiddenCount: number,
87
+ ): HtmlNode {
88
+ return (
89
+ <div class="flex items-center justify-end gap-3 text-sm">
90
+ {hiddenCount > 0 ? (
91
+ <span class="text-xs text-muted-foreground">
92
+ {hiddenCount} widget{hiddenCount === 1 ? "" : "s"} hidden
93
+ </span>
94
+ ) : null}
95
+ <button
96
+ type="button"
97
+ onClick={this.toggleArranging}
98
+ class="inline-flex h-8 items-center gap-1.5 rounded-lg border border-input px-3 text-xs font-medium transition hover:bg-accent"
99
+ >
100
+ <Icon name="filter" class="h-3.5 w-3.5" />
101
+ {this.arranging ? "Done" : "Arrange"}
102
+ </button>
103
+ {layout.order.length > 0 || layout.hidden.length > 0 ? (
104
+ <button
105
+ type="button"
106
+ onClick={this.resetLayout}
107
+ confirm="Put the dashboard back the way it started?"
108
+ class="text-xs text-muted-foreground transition hover:text-foreground"
109
+ >
110
+ Reset
111
+ </button>
112
+ ) : null}
113
+ </div>
114
+ );
115
+ }
116
+
117
+ /** One row per widget: move up, move down, show or hide. */
118
+ private _arrangePanel(layout: { order: string[]; hidden: string[] }): HtmlNode {
119
+ // Listed in the saved order rather than declaration order, so what is on
120
+ // screen matches what the rows say.
121
+ const rows = layout.order
122
+ .map((key) => this._keyed.find((k) => k.key === key))
123
+ .filter((k): k is (typeof this._keyed)[number] => Boolean(k));
124
+
125
+ return (
126
+ <div class="divide-y divide-border rounded-lg border border-border bg-card">
127
+ {rows.map((row, index) => {
128
+ const hidden = layout.hidden.includes(row.key);
129
+ return (
130
+ <div class="flex items-center gap-3 px-3 py-2">
131
+ <span class={`flex-1 text-sm ${hidden ? "text-muted-foreground line-through" : ""}`}>
132
+ {row.title}
133
+ </span>
134
+ <button
135
+ type="button"
136
+ onClick={this.moveWidget}
137
+ data-args={JSON.stringify([row.key, -1])}
138
+ disabled={index === 0}
139
+ aria-label={`Move ${row.title} up`}
140
+ class="rounded p-1 text-muted-foreground transition hover:bg-accent hover:text-foreground disabled:opacity-30"
141
+ >
142
+ <Icon name="chevron-up" class="h-4 w-4" />
143
+ </button>
144
+ <button
145
+ type="button"
146
+ onClick={this.moveWidget}
147
+ data-args={JSON.stringify([row.key, 1])}
148
+ disabled={index === rows.length - 1}
149
+ aria-label={`Move ${row.title} down`}
150
+ class="rounded p-1 text-muted-foreground transition hover:bg-accent hover:text-foreground disabled:opacity-30"
151
+ >
152
+ <Icon name="chevron-down" class="h-4 w-4" />
153
+ </button>
154
+ <button
155
+ type="button"
156
+ onClick={this.toggleWidget}
157
+ data-args={JSON.stringify([row.key])}
158
+ aria-label={`${hidden ? "Show" : "Hide"} ${row.title}`}
159
+ class="rounded p-1 text-muted-foreground transition hover:bg-accent hover:text-foreground"
160
+ >
161
+ <Icon name="eye" class="h-4 w-4" />
162
+ </button>
163
+ </div>
164
+ );
165
+ })}
166
+ </div>
167
+ );
168
+ }
169
+
170
+ override async render(): Promise<HtmlNode> {
171
+ const panel = this._panel;
172
+ const cfg = panel.config();
173
+ // A nested resource has no URL without a parent record, so it gets no card.
174
+ const resources = panel.resources().filter((r) => !r.parent);
175
+
176
+ // The app's widgets first, then whatever packages contributed and this user
177
+ // may see — so a contributed widget never displaces the app's own headline.
178
+ // A saved layout then reorders and hides within that, per user.
179
+ this._keyed = await this._allWidgets();
180
+ const store = panel.dashboardLayoutStore();
181
+ const layout = store
182
+ ? reconcile(
183
+ await store.load(),
184
+ this._keyed.map((k) => k.key),
185
+ )
186
+ : null;
187
+ const arranged = applyLayout(this._keyed, (k) => k.key, layout);
188
+ const widgets = arranged.visible.map((k) => k.widget);
189
+ const widgetBlock = await renderWidgets(widgets);
190
+
191
+ // The keenest widget sets the page's refresh rate; with none, this renders
192
+ // once per navigation as before.
193
+ const poll = widgetPollInterval(widgets);
194
+
195
+ return (
196
+ <div class="mx-auto w-full max-w-7xl space-y-8" {...(poll ? { poll: { every: poll } } : {})}>
197
+ <div>
198
+ <h1 class="text-2xl font-semibold tracking-tight">Welcome to {cfg.brand}</h1>
199
+ <p class="mt-1 text-sm text-muted-foreground">
200
+ Manage your application's data from one place.
201
+ </p>
202
+ </div>
203
+
204
+ {store ? this._arrangeBar(layout!, arranged.hidden.length) : null}
205
+ {this.arranging && store ? this._arrangePanel(layout!) : null}
206
+
207
+ {widgetBlock}
208
+
209
+ {resources.length > 0 ? (
210
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
211
+ {resources.map((r) => (
212
+ <a
213
+ href={r.indexUrl(panel.base())}
214
+ navigate
215
+ class="group relative overflow-hidden rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm transition hover:border-primary/40 hover:shadow-md"
216
+ >
217
+ <div class="flex items-center gap-3">
218
+ <div class="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10 text-primary transition group-hover:bg-primary group-hover:text-primary-foreground">
219
+ <Icon name={r.navigationIcon} class="h-5 w-5" />
220
+ </div>
221
+ <div class="min-w-0">
222
+ <div class="font-medium leading-tight">{r.getPluralLabel()}</div>
223
+ {r.navigationGroup ? (
224
+ <div class="text-xs text-muted-foreground">{r.navigationGroup}</div>
225
+ ) : null}
226
+ </div>
227
+ </div>
228
+ <div class="mt-4 flex items-center gap-1 text-sm font-medium text-primary">
229
+ Manage
230
+ <Icon
231
+ name="chevron-right"
232
+ class="h-4 w-4 transition group-hover:translate-x-0.5"
233
+ />
234
+ </div>
235
+ </a>
236
+ ))}
237
+ </div>
238
+ ) : (
239
+ <div class="rounded-xl border border-dashed border-border p-12 text-center">
240
+ <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
241
+ <Icon name="layout-grid" class="h-6 w-6" />
242
+ </div>
243
+ <p class="mt-3 text-sm font-medium">No resources registered yet</p>
244
+ <p class="mt-1 text-sm text-muted-foreground">
245
+ Register one with{" "}
246
+ <code class="rounded bg-muted px-1.5 py-0.5">Panel.register(...)</code> in your admin
247
+ bootstrap file.
248
+ </p>
249
+ </div>
250
+ )}
251
+ </div>
252
+ );
253
+ }
254
+ }
255
+
256
+ /** The dashboard for one panel, showing only that panel's resources and widgets. */
257
+ export function makeDashboardPage(panel: PanelInstance = Panel.default()): typeof DashboardPage {
258
+ const Page = class extends DashboardPage {
259
+ static override panel = panel;
260
+ static override layout = makeAdminLayout(panel);
261
+ };
262
+ Object.defineProperty(Page, "name", { value: "DashboardPage" });
263
+ return Page;
264
+ }