@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,251 @@
1
+ /**
2
+ * Dashboard widgets — stat rows, charts and small tables whose values are
3
+ * computed on the server each time the dashboard renders. A widget is a *data
4
+ * provider*: the dashboard page owns the markup, so widgets stay
5
+ * framework-light and easy to test.
6
+ *
7
+ * Panel.widgets(
8
+ * statsWidget(async () => [
9
+ * stat("Users", await User.count()).icon("users").tone("primary"),
10
+ * stat("Posts", await Post.count()).description("Published").icon("document"),
11
+ * ]).poll("30s"),
12
+ * );
13
+ *
14
+ * A widget that polls re-renders itself on an interval, so an ops dashboard left
15
+ * open on a wall display stays current without anyone reloading it.
16
+ */
17
+ import type { BadgeTone } from "../table/Column.ts";
18
+
19
+ export type WidgetTone = BadgeTone;
20
+
21
+ /**
22
+ * Re-render interval shared by every widget kind.
23
+ *
24
+ * Kept as the caller's own string ("30s", "5s") because that is what the poll
25
+ * directive takes; an unset interval means the widget renders once per page load.
26
+ */
27
+ abstract class PollableWidget {
28
+ /** @internal */ _poll?: string;
29
+ /** @internal Stable identity, for a persisted dashboard layout. */ _key?: string;
30
+
31
+ /**
32
+ * Re-render this widget every `interval` — `"10s"`, `"1m"`. Costs a query per
33
+ * tick per viewer, so reach for it on dashboards that are watched, not on
34
+ * every widget by habit.
35
+ */
36
+ poll(interval: string): this {
37
+ this._poll = interval;
38
+ return this;
39
+ }
40
+
41
+ /**
42
+ * Name this widget, so a saved dashboard layout can refer to it.
43
+ *
44
+ * Worth setting on any widget whose title might change: a layout keyed by
45
+ * title silently resets the moment somebody rewords a heading, whereas an
46
+ * explicit key survives it.
47
+ */
48
+ key(key: string): this {
49
+ this._key = key;
50
+ return this;
51
+ }
52
+
53
+ /** The identity a layout stores. Falls back to the title, then the position. */
54
+ widgetKey(index: number): string {
55
+ if (this._key) return this._key;
56
+ const title = (this as { _title?: string })._title;
57
+ return title
58
+ ? title
59
+ .toLowerCase()
60
+ .replace(/[^a-z0-9]+/g, "-")
61
+ .replace(/^-|-$/g, "")
62
+ : `widget-${index}`;
63
+ }
64
+ }
65
+
66
+ export class Stat {
67
+ /** @internal */ _label: string;
68
+ /** @internal */ _value: string | number;
69
+ /** @internal */ _description?: string;
70
+ /** @internal */ _icon?: string;
71
+ /** @internal */ _tone: WidgetTone = "default";
72
+
73
+ constructor(label: string, value: string | number) {
74
+ this._label = label;
75
+ this._value = value;
76
+ }
77
+
78
+ description(text: string): this {
79
+ this._description = text;
80
+ return this;
81
+ }
82
+
83
+ icon(name: string): this {
84
+ this._icon = name;
85
+ return this;
86
+ }
87
+
88
+ tone(tone: WidgetTone): this {
89
+ this._tone = tone;
90
+ return this;
91
+ }
92
+ }
93
+
94
+ /** Build a single stat card. */
95
+ export function stat(label: string, value: string | number): Stat {
96
+ return new Stat(label, value);
97
+ }
98
+
99
+ export type StatsResolver = () => Promise<Stat[]> | Stat[];
100
+
101
+ export class StatsWidget extends PollableWidget {
102
+ /** @internal */ _resolver: StatsResolver;
103
+ /** @internal */ _columns = 4;
104
+
105
+ constructor(resolver: StatsResolver) {
106
+ super();
107
+ this._resolver = resolver;
108
+ }
109
+
110
+ /** Number of cards per row (responsive; 1–4). */
111
+ columns(n: number): this {
112
+ this._columns = Math.min(4, Math.max(1, n));
113
+ return this;
114
+ }
115
+
116
+ /** Resolve the stats for this render. */
117
+ async stats(): Promise<Stat[]> {
118
+ return this._resolver();
119
+ }
120
+ }
121
+
122
+ /** A row of stat cards. */
123
+ export function statsWidget(resolver: StatsResolver): StatsWidget {
124
+ return new StatsWidget(resolver);
125
+ }
126
+
127
+ // ── Chart widget ─────────────────────────────────────────────────────────────
128
+
129
+ export type ChartType = "line" | "bar" | "doughnut" | "pie";
130
+
131
+ export interface ChartDataset {
132
+ label?: string;
133
+ data: number[];
134
+ /** CSS color; defaults to the panel's primary token. */
135
+ color?: string;
136
+ }
137
+
138
+ export interface ChartData {
139
+ type: ChartType;
140
+ labels: string[];
141
+ datasets: ChartDataset[];
142
+ }
143
+
144
+ export type ChartResolver = () => Promise<ChartData> | ChartData;
145
+
146
+ export class ChartWidget extends PollableWidget {
147
+ /** @internal */ _title: string;
148
+ /** @internal */ _resolver: ChartResolver;
149
+ /** @internal */ _columns = 2;
150
+ /** @internal */ _height = 220;
151
+
152
+ constructor(title: string, resolver: ChartResolver) {
153
+ super();
154
+ this._title = title;
155
+ this._resolver = resolver;
156
+ }
157
+
158
+ /** Grid span on the dashboard (1–4). */
159
+ columns(n: number): this {
160
+ this._columns = Math.min(4, Math.max(1, n));
161
+ return this;
162
+ }
163
+
164
+ /** Canvas height in pixels. */
165
+ height(px: number): this {
166
+ this._height = px;
167
+ return this;
168
+ }
169
+
170
+ async data(): Promise<ChartData> {
171
+ return this._resolver();
172
+ }
173
+ }
174
+
175
+ /** A chart, drawn with Chart.js on the dashboard. */
176
+ export function chartWidget(title: string, resolver: ChartResolver): ChartWidget {
177
+ return new ChartWidget(title, resolver);
178
+ }
179
+
180
+ // ── Table widget ─────────────────────────────────────────────────────────────
181
+
182
+ export interface TableWidgetColumn {
183
+ key: string;
184
+ label: string;
185
+ }
186
+
187
+ export type TableRowsResolver = () =>
188
+ Promise<Record<string, unknown>[]> | Record<string, unknown>[];
189
+
190
+ export class TableWidget extends PollableWidget {
191
+ /** @internal */ _title: string;
192
+ /** @internal */ _columns: TableWidgetColumn[];
193
+ /** @internal */ _resolver: TableRowsResolver;
194
+ /** @internal */ _wide = true;
195
+
196
+ constructor(title: string, columns: TableWidgetColumn[], resolver: TableRowsResolver) {
197
+ super();
198
+ this._title = title;
199
+ this._columns = columns;
200
+ this._resolver = resolver;
201
+ }
202
+
203
+ /** Span the full dashboard width (default) or a single column. */
204
+ wide(value = true): this {
205
+ this._wide = value;
206
+ return this;
207
+ }
208
+
209
+ async rows(): Promise<Record<string, unknown>[]> {
210
+ return this._resolver();
211
+ }
212
+ }
213
+
214
+ /** A small data table on the dashboard. */
215
+ export function tableWidget(
216
+ title: string,
217
+ columns: TableWidgetColumn[],
218
+ resolver: TableRowsResolver,
219
+ ): TableWidget {
220
+ return new TableWidget(title, columns, resolver);
221
+ }
222
+
223
+ /** A dashboard widget — stats overview, chart, or table. */
224
+ export type DashboardWidget = StatsWidget | ChartWidget | TableWidget;
225
+
226
+ /**
227
+ * The shortest poll interval among these widgets, or `undefined` when none
228
+ * polls. The dashboard polls as one unit, so the keenest widget sets the pace.
229
+ */
230
+ export function widgetPollInterval(widgets: DashboardWidget[]): string | undefined {
231
+ const intervals = widgets.map((w) => w._poll).filter((i): i is string => Boolean(i));
232
+ if (intervals.length === 0) return undefined;
233
+ return intervals.reduce((a, b) => (pollMs(b) < pollMs(a) ? b : a));
234
+ }
235
+
236
+ /** Parse a poll interval to milliseconds; an unparseable one sorts last. */
237
+ function pollMs(interval: string): number {
238
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(interval.trim());
239
+ if (!match) return Number.MAX_SAFE_INTEGER;
240
+ const n = Number(match[1]);
241
+ switch (match[2]) {
242
+ case "ms":
243
+ return n;
244
+ case "m":
245
+ return n * 60_000;
246
+ case "h":
247
+ return n * 3_600_000;
248
+ default:
249
+ return n * 1000;
250
+ }
251
+ }
@@ -0,0 +1,154 @@
1
+ /** @jsxImportSource @zerotal/flow */
2
+ // Widget rendering, shared by the dashboard and by resource pages.
3
+ //
4
+ // Widgets are data providers — they resolve values and leave the markup to the
5
+ // panel. Keeping that markup in one place is what lets the same widget appear on
6
+ // the dashboard and above a resource's table without being written twice.
7
+
8
+ import type { HtmlNode } from "@zerotal/flow";
9
+ import { Table } from "@zerotal/flow-ui";
10
+ import { Icon } from "../ui/icons.tsx";
11
+ import { StatsWidget, ChartWidget, TableWidget } from "./Widget.ts";
12
+ import type { DashboardWidget, WidgetTone } from "./Widget.ts";
13
+
14
+ const STAT_TONE: Record<WidgetTone, string> = {
15
+ default: "bg-secondary text-secondary-foreground",
16
+ primary: "bg-primary/10 text-primary",
17
+ success: "bg-success/10 text-success",
18
+ muted: "bg-muted text-muted-foreground",
19
+ destructive: "bg-destructive/10 text-destructive",
20
+ };
21
+
22
+ const STAT_COLS: Record<number, string> = {
23
+ 1: "sm:grid-cols-1",
24
+ 2: "sm:grid-cols-2",
25
+ 3: "sm:grid-cols-3",
26
+ 4: "sm:grid-cols-2 lg:grid-cols-4",
27
+ };
28
+
29
+ /**
30
+ * Build the inline Chart.js init script (injected raw via dangerouslySetInnerHTML
31
+ * so its `<`/`>` operators aren't HTML-escaped). Loads Chart.js from the CDN on
32
+ * demand, then draws each canvas once.
33
+ *
34
+ * `idPrefix` keeps canvases unique when two widget sets share a page — a
35
+ * resource's own widgets above its table, say, alongside a contributed one.
36
+ */
37
+ export function chartInitScript(configs: unknown[]): string {
38
+ if (!Array.isArray(configs) || configs.length === 0) return "";
39
+ const data = JSON.stringify(configs);
40
+ return `(function(){var C=${data};function pal(n){var o=[];for(var i=0;i<n;i++){o.push('hsl('+((i*53)%360)+' 70% 55%)');}return o;}function draw(){var r=getComputedStyle(document.documentElement);var p='hsl('+r.getPropertyValue('--primary').trim()+')';C.forEach(function(c){var el=document.getElementById(c.id);if(!el||el.__k){return;}el.__k=1;var pie=(c.type==='doughnut'||c.type==='pie');var line=(c.type==='line');new Chart(el,{type:c.type,data:{labels:c.labels,datasets:c.datasets.map(function(d){var col=d.color||p;return{label:d.label,data:d.data,backgroundColor:pie?pal(c.labels.length):(line?col+'33':col),borderColor:pie?'transparent':col,borderWidth:2,tension:0.35,fill:line};})},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:(c.datasets.length>1)||pie}},scales:pie?{}:{y:{beginAtZero:true}}}});});}if(window.Chart){draw();}else{var s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js';s.onload=draw;document.head.appendChild(s);}})();`;
41
+ }
42
+
43
+ /**
44
+ * Resolve and render a set of widgets.
45
+ *
46
+ * Returns `null` when there is nothing to draw, so a caller can drop the whole
47
+ * block rather than leaving an empty gap above its table.
48
+ */
49
+ export async function renderWidgets(
50
+ widgets: DashboardWidget[],
51
+ idPrefix = "kchart",
52
+ ): Promise<HtmlNode | null> {
53
+ if (widgets.length === 0) return null;
54
+
55
+ const statGroups = await Promise.all(
56
+ widgets
57
+ .filter((w): w is StatsWidget => w instanceof StatsWidget)
58
+ .map(async (w) => ({ columns: w._columns, stats: await w.stats() })),
59
+ );
60
+
61
+ const charts = await Promise.all(
62
+ widgets
63
+ .filter((w): w is ChartWidget => w instanceof ChartWidget)
64
+ .map(async (w, i) => ({
65
+ id: `${idPrefix}-${i}`,
66
+ title: w._title,
67
+ wide: w._columns >= 2,
68
+ height: w._height,
69
+ data: await w.data(),
70
+ })),
71
+ );
72
+
73
+ const tables = await Promise.all(
74
+ widgets
75
+ .filter((w): w is TableWidget => w instanceof TableWidget)
76
+ .map(async (w) => ({
77
+ title: w._title,
78
+ wide: w._wide,
79
+ columns: w._columns.map((c) => ({ key: c.key, label: c.label })),
80
+ rows: await w.rows(),
81
+ })),
82
+ );
83
+
84
+ const chartScript = chartInitScript(
85
+ charts.map((c) => ({
86
+ id: c.id,
87
+ type: c.data.type,
88
+ labels: c.data.labels,
89
+ datasets: c.data.datasets,
90
+ })),
91
+ );
92
+
93
+ return (
94
+ <div class="space-y-4">
95
+ {statGroups.map((group) => (
96
+ <div
97
+ class={`grid grid-cols-1 gap-4 ${STAT_COLS[group.columns] ?? "sm:grid-cols-2 lg:grid-cols-4"}`}
98
+ >
99
+ {group.stats.map((s) => (
100
+ <div class="rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm">
101
+ <div class="flex items-center justify-between">
102
+ <p class="text-sm font-medium text-muted-foreground">{s._label}</p>
103
+ {s._icon ? (
104
+ <span
105
+ class={`flex h-8 w-8 items-center justify-center rounded-lg ${STAT_TONE[s._tone]}`}
106
+ >
107
+ <Icon name={s._icon} class="h-4 w-4" />
108
+ </span>
109
+ ) : null}
110
+ </div>
111
+ <p class="mt-2 text-3xl font-semibold tracking-tight">{String(s._value)}</p>
112
+ {s._description ? (
113
+ <p class="mt-1 text-xs text-muted-foreground">{s._description}</p>
114
+ ) : null}
115
+ </div>
116
+ ))}
117
+ </div>
118
+ ))}
119
+
120
+ {charts.length > 0 ? (
121
+ <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
122
+ {charts.map((c) => (
123
+ <div
124
+ class={`rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm ${c.wide ? "lg:col-span-2" : ""}`}
125
+ >
126
+ <h3 class="text-sm font-semibold">{c.title}</h3>
127
+ <div class="mt-3" style={`position:relative;height:${c.height}px`}>
128
+ <canvas id={c.id} />
129
+ </div>
130
+ </div>
131
+ ))}
132
+ </div>
133
+ ) : null}
134
+ {chartScript ? <script dangerouslySetInnerHTML={{ __html: chartScript }} /> : null}
135
+
136
+ {tables.map((t) => (
137
+ <div
138
+ class={`overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm ${t.wide ? "" : "lg:max-w-2xl"}`}
139
+ >
140
+ <div class="border-b border-border px-5 py-3">
141
+ <h3 class="text-sm font-semibold">{t.title}</h3>
142
+ </div>
143
+ <div class="overflow-x-auto p-1.5">
144
+ {t.rows.length > 0 ? (
145
+ <Table columns={t.columns} rows={t.rows} hover />
146
+ ) : (
147
+ <p class="px-4 py-8 text-center text-sm text-muted-foreground">No data.</p>
148
+ )}
149
+ </div>
150
+ </div>
151
+ ))}
152
+ </div>
153
+ );
154
+ }