@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.
- package/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +344 -0
- package/package.json +78 -0
- package/src/Cluster.ts +50 -0
- package/src/Panel.ts +288 -0
- package/src/PanelInstance.ts +644 -0
- package/src/Resource.ts +918 -0
- package/src/actions/Action.ts +607 -0
- package/src/actions/ImportRecordsJob.ts +108 -0
- package/src/actions/csv.ts +123 -0
- package/src/actions/index.ts +39 -0
- package/src/actions/render.tsx +181 -0
- package/src/actions/transfer.ts +307 -0
- package/src/actions/xlsx.ts +304 -0
- package/src/auth/AuthLayout.tsx +34 -0
- package/src/auth/index.ts +13 -0
- package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
- package/src/auth/pages/LoginPage.tsx +121 -0
- package/src/auth/pages/ProfilePage.tsx +216 -0
- package/src/auth/pages/ResetPasswordPage.tsx +103 -0
- package/src/auth/pages/VerifyEmailPage.tsx +68 -0
- package/src/auth/register.ts +44 -0
- package/src/authRoles.ts +141 -0
- package/src/commands/MakeAdminResourceCommand.ts +181 -0
- package/src/config.ts +128 -0
- package/src/dashboardLayout.ts +101 -0
- package/src/databaseMedia.ts +148 -0
- package/src/databaseNotifications.ts +169 -0
- package/src/form/Field.ts +928 -0
- package/src/form/ResourceForm.ts +48 -0
- package/src/form/Section.ts +364 -0
- package/src/form/editors.ts +43 -0
- package/src/form/index.ts +59 -0
- package/src/history.ts +151 -0
- package/src/impersonation.ts +126 -0
- package/src/index.ts +380 -0
- package/src/infolist/Entry.ts +537 -0
- package/src/infolist/Section.ts +99 -0
- package/src/infolist/index.ts +38 -0
- package/src/media.ts +297 -0
- package/src/notifications.ts +65 -0
- package/src/pages/AdminPage.ts +100 -0
- package/src/pages/ConsolePage.tsx +324 -0
- package/src/pages/DashboardPage.tsx +264 -0
- package/src/pages/MediaPage.tsx +346 -0
- package/src/pages/NotificationsPage.tsx +155 -0
- package/src/pages/RecordViewPage.tsx +951 -0
- package/src/pages/ResourceFormPage.tsx +1856 -0
- package/src/pages/ResourceListPage.tsx +2552 -0
- package/src/pages/RolesPage.tsx +325 -0
- package/src/pages/SearchPage.tsx +169 -0
- package/src/plugin.ts +283 -0
- package/src/provider/AdminAbilityMiddleware.ts +25 -0
- package/src/provider/AdminGuardMiddleware.ts +29 -0
- package/src/provider/AdminProvider.ts +334 -0
- package/src/relations/RelationManager.ts +114 -0
- package/src/renderHooks.ts +86 -0
- package/src/roles.ts +175 -0
- package/src/savedViews.ts +79 -0
- package/src/support/ability.ts +73 -0
- package/src/support/authorize.ts +105 -0
- package/src/support/countCache.ts +37 -0
- package/src/support/hostPage.ts +30 -0
- package/src/table/Column.ts +353 -0
- package/src/table/Constraint.ts +238 -0
- package/src/table/Filter.ts +275 -0
- package/src/table/Group.ts +73 -0
- package/src/table/Tab.ts +77 -0
- package/src/testing.ts +121 -0
- package/src/theme.ts +70 -0
- package/src/ui/AdminLayout.tsx +355 -0
- package/src/ui/Breadcrumbs.tsx +84 -0
- package/src/ui/environmentIndicator.tsx +63 -0
- package/src/ui/icons.tsx +124 -0
- package/src/widgets/Widget.ts +251 -0
- 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
|
+
}
|