@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,325 @@
|
|
|
1
|
+
/** @jsxImportSource @zerotal/flow */
|
|
2
|
+
// Roles and permissions — a matrix of every ability the panel checks against
|
|
3
|
+
// every role, with the whole grid editable in place. Appears only when the panel
|
|
4
|
+
// has a role provider configured.
|
|
5
|
+
|
|
6
|
+
import { Component, expose } from "@zerotal/flow";
|
|
7
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
8
|
+
import { AdminLayout } from "../ui/AdminLayout.tsx";
|
|
9
|
+
import { Icon } from "../ui/icons.tsx";
|
|
10
|
+
import { Panel } from "../Panel.ts";
|
|
11
|
+
import type { PanelInstance } from "../PanelInstance.ts";
|
|
12
|
+
import type { Permission, Role } from "../roles.ts";
|
|
13
|
+
import { groupPermissions, panelPermissions, roleHas } from "../roles.ts";
|
|
14
|
+
|
|
15
|
+
export class RolesPage extends Component {
|
|
16
|
+
static layout = AdminLayout;
|
|
17
|
+
/** The panel this page belongs to — set by each generated subclass. */
|
|
18
|
+
static panel: PanelInstance;
|
|
19
|
+
|
|
20
|
+
/** A new role's details, while the create form is open. */
|
|
21
|
+
@expose creating = false;
|
|
22
|
+
@expose newName = "";
|
|
23
|
+
@expose newDescription = "";
|
|
24
|
+
|
|
25
|
+
private get _panel(): PanelInstance {
|
|
26
|
+
return (this.constructor as typeof RolesPage).panel ?? Panel.current();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private _roles: Role[] = [];
|
|
30
|
+
private _held: Record<string, string[]> = {};
|
|
31
|
+
private _permissions: Permission[] = [];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Turn one permission on or off for one role.
|
|
35
|
+
*
|
|
36
|
+
* The provider is given the role's complete new set rather than a delta, so a
|
|
37
|
+
* concurrent edit cannot leave a role holding half of two different answers.
|
|
38
|
+
*/
|
|
39
|
+
@expose async toggle(roleId: unknown, key: unknown): Promise<void> {
|
|
40
|
+
const provider = this._panel.roleProvider();
|
|
41
|
+
const role = this._roles.find((r) => r.id === String(roleId));
|
|
42
|
+
if (!provider || !role) return;
|
|
43
|
+
// A superuser's permissions are not a set to edit — they are "everything".
|
|
44
|
+
if (role.superuser) {
|
|
45
|
+
this.flash(`${role.name} holds every permission by definition.`, "warning");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const current = this._held[role.id] ?? [];
|
|
50
|
+
const permission = String(key);
|
|
51
|
+
const next = current.includes(permission)
|
|
52
|
+
? current.filter((k) => k !== permission)
|
|
53
|
+
: [...current, permission];
|
|
54
|
+
|
|
55
|
+
await provider.setPermissions(role.id, next);
|
|
56
|
+
this._held[role.id] = next;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Grant or revoke a whole resource's abilities at once. */
|
|
60
|
+
@expose async toggleGroup(roleId: unknown, group: unknown): Promise<void> {
|
|
61
|
+
const provider = this._panel.roleProvider();
|
|
62
|
+
const role = this._roles.find((r) => r.id === String(roleId));
|
|
63
|
+
if (!provider || !role || role.superuser) return;
|
|
64
|
+
|
|
65
|
+
const keys = this._permissions.filter((p) => p.group === String(group)).map((p) => p.key);
|
|
66
|
+
const current = this._held[role.id] ?? [];
|
|
67
|
+
// Partly-ticked counts as off, so one click fills the group rather than
|
|
68
|
+
// clearing the few that were already set.
|
|
69
|
+
const all = keys.every((k) => current.includes(k));
|
|
70
|
+
const next = all
|
|
71
|
+
? current.filter((k) => !keys.includes(k))
|
|
72
|
+
: [...new Set([...current, ...keys])];
|
|
73
|
+
|
|
74
|
+
await provider.setPermissions(role.id, next);
|
|
75
|
+
this._held[role.id] = next;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@expose openCreate(): void {
|
|
79
|
+
this.creating = true;
|
|
80
|
+
this.newName = "";
|
|
81
|
+
this.newDescription = "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@expose cancelCreate(): void {
|
|
85
|
+
this.creating = false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@expose async createRole(): Promise<void> {
|
|
89
|
+
const provider = this._panel.roleProvider();
|
|
90
|
+
if (!provider?.create || !this.newName.trim()) return;
|
|
91
|
+
await provider.create({
|
|
92
|
+
name: this.newName.trim(),
|
|
93
|
+
...(this.newDescription.trim() ? { description: this.newDescription.trim() } : {}),
|
|
94
|
+
});
|
|
95
|
+
this.creating = false;
|
|
96
|
+
this.flash(`${this.newName.trim()} created.`, "success");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@expose async removeRole(roleId: unknown): Promise<void> {
|
|
100
|
+
const provider = this._panel.roleProvider();
|
|
101
|
+
const role = this._roles.find((r) => r.id === String(roleId));
|
|
102
|
+
if (!provider?.remove || !role) return;
|
|
103
|
+
await provider.remove(role.id);
|
|
104
|
+
this.flash(`${role.name} deleted.`, "success");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
override async render(): Promise<HtmlNode> {
|
|
108
|
+
const provider = this._panel.roleProvider();
|
|
109
|
+
const base = this._panel.base();
|
|
110
|
+
|
|
111
|
+
this._permissions = panelPermissions(this._panel);
|
|
112
|
+
this._roles = provider ? await provider.list() : [];
|
|
113
|
+
this._held = {};
|
|
114
|
+
for (const [id, keys] of await Promise.all(
|
|
115
|
+
this._roles.map(async (r) => [r.id, await provider!.permissionsFor(r.id)] as const),
|
|
116
|
+
)) {
|
|
117
|
+
this._held[id] = keys;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const groups = groupPermissions(this._permissions);
|
|
121
|
+
const canCreate = !!provider?.create;
|
|
122
|
+
const canRemove = !!provider?.remove;
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div class="space-y-6">
|
|
126
|
+
<div class="flex flex-wrap items-end justify-between gap-4">
|
|
127
|
+
<div>
|
|
128
|
+
<nav class="mb-1 text-xs text-muted-foreground">
|
|
129
|
+
<a href={base} navigate class="hover:text-foreground">
|
|
130
|
+
Dashboard
|
|
131
|
+
</a>
|
|
132
|
+
<span class="mx-1">/</span>
|
|
133
|
+
<span>Roles</span>
|
|
134
|
+
</nav>
|
|
135
|
+
<h1 class="text-2xl font-semibold tracking-tight">Roles & permissions</h1>
|
|
136
|
+
<p class="mt-1 text-sm text-muted-foreground">
|
|
137
|
+
Every ability this panel checks, and which roles hold it. Changes apply immediately.
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
{canCreate && !this.creating ? (
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
onClick={this.openCreate}
|
|
144
|
+
class="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:opacity-90"
|
|
145
|
+
>
|
|
146
|
+
<Icon name="plus" class="h-4 w-4" />
|
|
147
|
+
New role
|
|
148
|
+
</button>
|
|
149
|
+
) : null}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
{this.creating ? (
|
|
153
|
+
<div class="space-y-3 rounded-lg border border-border bg-card p-4">
|
|
154
|
+
<div class="grid gap-3 sm:grid-cols-2">
|
|
155
|
+
<label class="block">
|
|
156
|
+
<span class="mb-1 block text-xs font-medium">Name</span>
|
|
157
|
+
<input
|
|
158
|
+
{...{ "flow:model": "newName" }}
|
|
159
|
+
placeholder="Editor"
|
|
160
|
+
class="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
|
|
161
|
+
/>
|
|
162
|
+
</label>
|
|
163
|
+
<label class="block">
|
|
164
|
+
<span class="mb-1 block text-xs font-medium">Description</span>
|
|
165
|
+
<input
|
|
166
|
+
{...{ "flow:model": "newDescription" }}
|
|
167
|
+
placeholder="Can publish, but not delete"
|
|
168
|
+
class="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
|
|
169
|
+
/>
|
|
170
|
+
</label>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="flex items-center gap-2">
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
onClick={this.createRole}
|
|
176
|
+
class="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:opacity-90"
|
|
177
|
+
>
|
|
178
|
+
Create
|
|
179
|
+
</button>
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
onClick={this.cancelCreate}
|
|
183
|
+
class="inline-flex h-9 items-center rounded-lg border border-input px-4 text-sm font-medium transition hover:bg-accent"
|
|
184
|
+
>
|
|
185
|
+
Cancel
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
) : null}
|
|
190
|
+
|
|
191
|
+
{!provider ? (
|
|
192
|
+
<div class="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
|
193
|
+
No role provider is configured for this panel.
|
|
194
|
+
</div>
|
|
195
|
+
) : this._roles.length === 0 ? (
|
|
196
|
+
<div class="rounded-lg border border-dashed border-border p-10 text-center">
|
|
197
|
+
<Icon name="shield" class="mx-auto h-8 w-8 text-muted-foreground" />
|
|
198
|
+
<p class="mt-3 text-sm font-medium">No roles yet</p>
|
|
199
|
+
<p class="mt-1 text-sm text-muted-foreground">
|
|
200
|
+
Create one, and every ability below becomes assignable.
|
|
201
|
+
</p>
|
|
202
|
+
</div>
|
|
203
|
+
) : (
|
|
204
|
+
<div class="overflow-x-auto rounded-lg border border-border">
|
|
205
|
+
<table class="w-full text-sm">
|
|
206
|
+
<thead class="bg-muted/40">
|
|
207
|
+
<tr>
|
|
208
|
+
<th
|
|
209
|
+
scope="col"
|
|
210
|
+
class="sticky left-0 z-10 bg-muted/40 px-3 py-2 text-left font-medium"
|
|
211
|
+
>
|
|
212
|
+
Permission
|
|
213
|
+
</th>
|
|
214
|
+
{this._roles.map((role) => (
|
|
215
|
+
<th scope="col" class="px-3 py-2 text-center font-medium">
|
|
216
|
+
<div class="flex flex-col items-center gap-0.5">
|
|
217
|
+
<span class="flex items-center gap-1">
|
|
218
|
+
{role.superuser ? (
|
|
219
|
+
<Icon name="shield" class="h-3.5 w-3.5 text-primary" />
|
|
220
|
+
) : null}
|
|
221
|
+
{role.name}
|
|
222
|
+
</span>
|
|
223
|
+
{role.description ? (
|
|
224
|
+
<span class="text-[11px] font-normal text-muted-foreground">
|
|
225
|
+
{role.description}
|
|
226
|
+
</span>
|
|
227
|
+
) : null}
|
|
228
|
+
{canRemove && !role.superuser ? (
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
onClick={this.removeRole}
|
|
232
|
+
data-args={JSON.stringify([role.id])}
|
|
233
|
+
confirm={`Delete the ${role.name} role? Anyone holding it loses its permissions.`}
|
|
234
|
+
class="text-[11px] font-normal text-destructive hover:underline"
|
|
235
|
+
>
|
|
236
|
+
Delete
|
|
237
|
+
</button>
|
|
238
|
+
) : null}
|
|
239
|
+
</div>
|
|
240
|
+
</th>
|
|
241
|
+
))}
|
|
242
|
+
</tr>
|
|
243
|
+
</thead>
|
|
244
|
+
<tbody>
|
|
245
|
+
{groups.map((group) => (
|
|
246
|
+
<>
|
|
247
|
+
<tr class="border-t border-border bg-muted/20">
|
|
248
|
+
<th
|
|
249
|
+
scope="rowgroup"
|
|
250
|
+
class="sticky left-0 z-10 bg-muted/20 px-3 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
|
251
|
+
>
|
|
252
|
+
{group.group}
|
|
253
|
+
</th>
|
|
254
|
+
{this._roles.map((role) => {
|
|
255
|
+
const keys = group.items.map((p) => p.key);
|
|
256
|
+
const held = this._held[role.id] ?? [];
|
|
257
|
+
const all = role.superuser || keys.every((k) => held.includes(k));
|
|
258
|
+
return (
|
|
259
|
+
<td class="px-3 py-1.5 text-center">
|
|
260
|
+
{/* One click for the whole block — the common case is
|
|
261
|
+
granting a role a resource, not one ability of it. */}
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
onClick={this.toggleGroup}
|
|
265
|
+
data-args={JSON.stringify([role.id, group.group])}
|
|
266
|
+
disabled={role.superuser}
|
|
267
|
+
aria-label={`${all ? "Revoke" : "Grant"} all ${group.group} permissions for ${role.name}`}
|
|
268
|
+
class="text-[11px] text-muted-foreground transition hover:text-foreground disabled:opacity-40"
|
|
269
|
+
>
|
|
270
|
+
{all ? "none" : "all"}
|
|
271
|
+
</button>
|
|
272
|
+
</td>
|
|
273
|
+
);
|
|
274
|
+
})}
|
|
275
|
+
</tr>
|
|
276
|
+
{group.items.map((permission) => (
|
|
277
|
+
<tr class="border-t border-border hover:bg-muted/30">
|
|
278
|
+
<td class="sticky left-0 z-10 bg-card px-3 py-1.5">
|
|
279
|
+
<span>{permission.label}</span>
|
|
280
|
+
<span class="ml-2 font-mono text-[11px] text-muted-foreground">
|
|
281
|
+
{permission.key}
|
|
282
|
+
</span>
|
|
283
|
+
</td>
|
|
284
|
+
{this._roles.map((role) => {
|
|
285
|
+
const on = roleHas(role, this._held[role.id] ?? [], permission.key);
|
|
286
|
+
return (
|
|
287
|
+
<td class="px-3 py-1.5 text-center">
|
|
288
|
+
<button
|
|
289
|
+
type="button"
|
|
290
|
+
onClick={this.toggle}
|
|
291
|
+
data-args={JSON.stringify([role.id, permission.key])}
|
|
292
|
+
disabled={role.superuser}
|
|
293
|
+
role="switch"
|
|
294
|
+
aria-checked={on ? "true" : "false"}
|
|
295
|
+
aria-label={`${permission.label} for ${role.name}`}
|
|
296
|
+
class={`inline-flex h-5 w-5 items-center justify-center rounded border transition disabled:opacity-40 ${
|
|
297
|
+
on
|
|
298
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
299
|
+
: "border-input hover:border-primary/50"
|
|
300
|
+
}`}
|
|
301
|
+
>
|
|
302
|
+
{on ? <Icon name="check" class="h-3.5 w-3.5" /> : null}
|
|
303
|
+
</button>
|
|
304
|
+
</td>
|
|
305
|
+
);
|
|
306
|
+
})}
|
|
307
|
+
</tr>
|
|
308
|
+
))}
|
|
309
|
+
</>
|
|
310
|
+
))}
|
|
311
|
+
</tbody>
|
|
312
|
+
</table>
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Build a RolesPage bound to one panel. */
|
|
321
|
+
export function makeRolesPage(panel: PanelInstance = Panel.default()): typeof RolesPage {
|
|
322
|
+
return class BoundRolesPage extends RolesPage {
|
|
323
|
+
static override panel = panel;
|
|
324
|
+
};
|
|
325
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/** @jsxImportSource @zerotal/flow */
|
|
2
|
+
// Global search. Queries every globally-searchable resource for the `?q=` term
|
|
3
|
+
// and groups the matches, each linking to its View page. Reuses each resource's
|
|
4
|
+
// `records({ search })` (its searchable columns), so there's no separate search
|
|
5
|
+
// index to maintain.
|
|
6
|
+
|
|
7
|
+
import { Component, url } from "@zerotal/flow";
|
|
8
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
9
|
+
import { AdminLayout, makeAdminLayout } from "../ui/AdminLayout.tsx";
|
|
10
|
+
import { Icon } from "../ui/icons.tsx";
|
|
11
|
+
import { Panel } from "../Panel.ts";
|
|
12
|
+
import type { PanelInstance } from "../PanelInstance.ts";
|
|
13
|
+
|
|
14
|
+
const PER_RESOURCE = 5;
|
|
15
|
+
|
|
16
|
+
export class SearchPage extends Component {
|
|
17
|
+
static layout = AdminLayout;
|
|
18
|
+
/** The panel this page belongs to — set by each generated subclass. */
|
|
19
|
+
static panel: PanelInstance;
|
|
20
|
+
|
|
21
|
+
@url q = "";
|
|
22
|
+
|
|
23
|
+
private get _panel(): PanelInstance {
|
|
24
|
+
return (this.constructor as typeof SearchPage).panel ?? Panel.current();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
override async render(): Promise<HtmlNode> {
|
|
28
|
+
const panel = this._panel;
|
|
29
|
+
const base = panel.base();
|
|
30
|
+
const term = this.q.trim();
|
|
31
|
+
|
|
32
|
+
const groups = term
|
|
33
|
+
? (
|
|
34
|
+
await Promise.all(
|
|
35
|
+
panel
|
|
36
|
+
.resources()
|
|
37
|
+
.filter((r) => r.globallySearchable())
|
|
38
|
+
.map(async (r) => {
|
|
39
|
+
const result = await r.records({ search: term, perPage: PER_RESOURCE });
|
|
40
|
+
return { resource: r, rows: result.rows, total: result.total };
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
).filter((g) => g.rows.length > 0)
|
|
44
|
+
: [];
|
|
45
|
+
|
|
46
|
+
// Contributed sources — jobs, log lines, audit entries, anything a package
|
|
47
|
+
// can address by URL. A provider that throws is dropped rather than taking
|
|
48
|
+
// the whole results page down with it.
|
|
49
|
+
const contributed = term
|
|
50
|
+
? (
|
|
51
|
+
await Promise.all(
|
|
52
|
+
(await panel.visibleSearchProviders()).map(async (p) => {
|
|
53
|
+
try {
|
|
54
|
+
return { provider: p, hits: (await p.search(term)).slice(0, PER_RESOURCE) };
|
|
55
|
+
} catch {
|
|
56
|
+
return { provider: p, hits: [] };
|
|
57
|
+
}
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
).filter((g) => g.hits.length > 0)
|
|
61
|
+
: [];
|
|
62
|
+
|
|
63
|
+
const totalHits =
|
|
64
|
+
groups.reduce((n, g) => n + g.total, 0) + contributed.reduce((n, g) => n + g.hits.length, 0);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div class="mx-auto w-full max-w-3xl space-y-6">
|
|
68
|
+
<div>
|
|
69
|
+
<h1 class="text-2xl font-semibold tracking-tight">Search</h1>
|
|
70
|
+
{term ? (
|
|
71
|
+
<p class="mt-1 text-sm text-muted-foreground">
|
|
72
|
+
{totalHits} {totalHits === 1 ? "result" : "results"} for “{term}”
|
|
73
|
+
</p>
|
|
74
|
+
) : (
|
|
75
|
+
<p class="mt-1 text-sm text-muted-foreground">
|
|
76
|
+
Type a query in the search box above to look across your resources.
|
|
77
|
+
</p>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{term && groups.length === 0 && contributed.length === 0 ? (
|
|
82
|
+
<div class="rounded-xl border border-dashed border-border p-12 text-center">
|
|
83
|
+
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
|
84
|
+
<Icon name="search" class="h-6 w-6" />
|
|
85
|
+
</div>
|
|
86
|
+
<p class="mt-3 text-sm font-medium">No matches</p>
|
|
87
|
+
<p class="mt-1 text-sm text-muted-foreground">Nothing matched “{term}”.</p>
|
|
88
|
+
</div>
|
|
89
|
+
) : null}
|
|
90
|
+
|
|
91
|
+
{groups.map((g) => (
|
|
92
|
+
<div class="rounded-xl border border-border bg-card text-card-foreground shadow-sm">
|
|
93
|
+
<div class="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
|
94
|
+
<Icon name={g.resource.navigationIcon} class="h-4 w-4 text-muted-foreground" />
|
|
95
|
+
<span class="text-sm font-semibold">{g.resource.getPluralLabel()}</span>
|
|
96
|
+
<span class="text-xs text-muted-foreground">({g.total})</span>
|
|
97
|
+
</div>
|
|
98
|
+
<ul class="divide-y divide-border">
|
|
99
|
+
{g.rows.map((row) => (
|
|
100
|
+
<li>
|
|
101
|
+
<a
|
|
102
|
+
href={`${base}/${g.resource.getSlug()}/${String(row[g.resource.primaryKey])}`}
|
|
103
|
+
navigate
|
|
104
|
+
class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm transition hover:bg-accent"
|
|
105
|
+
>
|
|
106
|
+
<span class="truncate">{g.resource.recordTitle(row)}</span>
|
|
107
|
+
<Icon name="chevron-right" class="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
108
|
+
</a>
|
|
109
|
+
</li>
|
|
110
|
+
))}
|
|
111
|
+
{g.total > g.rows.length ? (
|
|
112
|
+
<li>
|
|
113
|
+
<a
|
|
114
|
+
href={`${base}/${g.resource.getSlug()}?search=${encodeURIComponent(term)}`}
|
|
115
|
+
navigate
|
|
116
|
+
class="block px-4 py-2 text-xs font-medium text-primary hover:underline"
|
|
117
|
+
>
|
|
118
|
+
See all {g.total} {g.resource.getPluralLabel().toLowerCase()} →
|
|
119
|
+
</a>
|
|
120
|
+
</li>
|
|
121
|
+
) : null}
|
|
122
|
+
</ul>
|
|
123
|
+
</div>
|
|
124
|
+
))}
|
|
125
|
+
|
|
126
|
+
{contributed.map((g) => (
|
|
127
|
+
<div class="rounded-xl border border-border bg-card text-card-foreground shadow-sm">
|
|
128
|
+
<div class="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
|
129
|
+
<Icon name={g.provider.icon ?? "search"} class="h-4 w-4 text-muted-foreground" />
|
|
130
|
+
<span class="text-sm font-semibold">{g.provider.label}</span>
|
|
131
|
+
<span class="text-xs text-muted-foreground">({g.hits.length})</span>
|
|
132
|
+
</div>
|
|
133
|
+
<ul class="divide-y divide-border">
|
|
134
|
+
{g.hits.map((hit) => (
|
|
135
|
+
<li>
|
|
136
|
+
<a
|
|
137
|
+
href={hit.href}
|
|
138
|
+
navigate
|
|
139
|
+
class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm transition hover:bg-accent"
|
|
140
|
+
>
|
|
141
|
+
<span class="min-w-0">
|
|
142
|
+
<span class="block truncate">{hit.label}</span>
|
|
143
|
+
{hit.description ? (
|
|
144
|
+
<span class="block truncate text-xs text-muted-foreground">
|
|
145
|
+
{hit.description}
|
|
146
|
+
</span>
|
|
147
|
+
) : null}
|
|
148
|
+
</span>
|
|
149
|
+
<Icon name="chevron-right" class="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
150
|
+
</a>
|
|
151
|
+
</li>
|
|
152
|
+
))}
|
|
153
|
+
</ul>
|
|
154
|
+
</div>
|
|
155
|
+
))}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The search page for one panel, searching only that panel's resources. */
|
|
162
|
+
export function makeSearchPage(panel: PanelInstance = Panel.default()): typeof SearchPage {
|
|
163
|
+
const Page = class extends SearchPage {
|
|
164
|
+
static override panel = panel;
|
|
165
|
+
static override layout = makeAdminLayout(panel);
|
|
166
|
+
};
|
|
167
|
+
Object.defineProperty(Page, "name", { value: "SearchPage" });
|
|
168
|
+
return Page;
|
|
169
|
+
}
|