@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,346 @@
1
+ /** @jsxImportSource @zerotal/flow */
2
+ // The media library — every uploaded file in one grid, with upload, search,
3
+ // folder filtering, alt-text editing and deletion. Appears only when the panel
4
+ // has a media 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 { MediaItem } from "../media.ts";
13
+ import { deleteMedia, formatSize, isImage, isUpload, mediaUrl, storeMedia } from "../media.ts";
14
+
15
+ /** How many files the grid shows before the "load more" step. */
16
+ const PAGE_SIZE = 60;
17
+
18
+ export class MediaPage extends Component {
19
+ static layout = AdminLayout;
20
+ /** The panel this page belongs to — set by each generated subclass. */
21
+ static panel: PanelInstance;
22
+
23
+ @expose search = "";
24
+ @expose folder = "";
25
+ @expose limit = PAGE_SIZE;
26
+ /** The item whose details are open, by id. */
27
+ @expose editing = "";
28
+ @expose alt = "";
29
+ @expose editFolder = "";
30
+ /**
31
+ * The file chosen in the browser.
32
+ *
33
+ * A bound file input POSTs its bytes separately and leaves a temporary upload
34
+ * here, so a large file never has to fit in a WebSocket frame — and the panel
35
+ * only has to move it once it knows where it belongs.
36
+ */
37
+ @expose upload: unknown = null;
38
+
39
+ private get _panel(): PanelInstance {
40
+ return (this.constructor as typeof MediaPage).panel ?? Panel.current();
41
+ }
42
+
43
+ private _items: MediaItem[] = [];
44
+
45
+ /**
46
+ * Store the file once its bytes have arrived.
47
+ *
48
+ * A bound file input starts an HTTP upload on `change` and only sets the
49
+ * signed reference when that finishes, so an action fired from `change` runs
50
+ * while the property is still empty and silently stores nothing.
51
+ */
52
+ override async onUpdated(prop: string, _value: unknown): Promise<void> {
53
+ if (prop === "upload") await this.uploadFile();
54
+ }
55
+
56
+ @expose async uploadFile(): Promise<void> {
57
+ const provider = this._panel.mediaProvider();
58
+ if (!provider || !this.upload) return;
59
+
60
+ if (!isUpload(this.upload)) {
61
+ this.flash("That file could not be read.", "warning");
62
+ return;
63
+ }
64
+
65
+ const [ok, result] = await storeMedia(this.upload, {
66
+ provider,
67
+ ...(this.folder ? { folder: this.folder } : {}),
68
+ ...(this._panel.mediaDisk() ? { disk: this._panel.mediaDisk()! } : {}),
69
+ });
70
+ // Cleared either way — a failed upload should not sit in the form waiting to
71
+ // be retried against the same error.
72
+ this.upload = null;
73
+ this.flash(
74
+ ok ? `${(result as MediaItem).name} uploaded.` : (result as string),
75
+ ok ? "success" : "warning",
76
+ );
77
+ }
78
+
79
+ @expose openDetails(id: unknown): void {
80
+ const item = this._items.find((i) => i.id === String(id));
81
+ if (!item) return;
82
+ this.editing = item.id;
83
+ this.alt = item.alt ?? "";
84
+ this.editFolder = item.folder ?? "";
85
+ }
86
+
87
+ @expose closeDetails(): void {
88
+ this.editing = "";
89
+ }
90
+
91
+ @expose async saveDetails(): Promise<void> {
92
+ const provider = this._panel.mediaProvider();
93
+ if (!provider?.update || !this.editing) return;
94
+ await provider.update(this.editing, { alt: this.alt, folder: this.editFolder });
95
+ this.editing = "";
96
+ this.flash("Details saved.", "success");
97
+ }
98
+
99
+ @expose async remove(id: unknown): Promise<void> {
100
+ const provider = this._panel.mediaProvider();
101
+ const item = this._items.find((i) => i.id === String(id));
102
+ if (!provider || !item) return;
103
+
104
+ const [ok, message] = await deleteMedia(item, {
105
+ provider,
106
+ ...(this._panel.mediaDisk() ? { disk: this._panel.mediaDisk()! } : {}),
107
+ });
108
+ if (this.editing === item.id) this.editing = "";
109
+ this.flash(ok ? `${item.name} removed.` : message!, ok ? "success" : "warning");
110
+ }
111
+
112
+ @expose loadMore(): void {
113
+ this.limit += PAGE_SIZE;
114
+ }
115
+
116
+ override async render(): Promise<HtmlNode> {
117
+ const provider = this._panel.mediaProvider();
118
+ const base = this._panel.base();
119
+
120
+ this._items = provider
121
+ ? await provider.list({
122
+ limit: this.limit,
123
+ ...(this.folder ? { folder: this.folder } : {}),
124
+ ...(this.search ? { search: this.search } : {}),
125
+ })
126
+ : [];
127
+
128
+ // Resolved up front: a URL may need the storage disk, and doing that inside
129
+ // the grid would mean an await per tile.
130
+ const urls = new Map(
131
+ await Promise.all(
132
+ this._items.map(async (i) => [i.id, await mediaUrl(i, this._panel.mediaDisk())] as const),
133
+ ),
134
+ );
135
+ // Folders come from what is actually filed, so the filter never offers an
136
+ // empty one.
137
+ const folders = [...new Set(this._items.map((i) => i.folder).filter(Boolean))].sort();
138
+ const open = this._items.find((i) => i.id === this.editing);
139
+
140
+ return (
141
+ <div class="space-y-6">
142
+ <div class="flex flex-wrap items-end justify-between gap-4">
143
+ <div>
144
+ <nav class="mb-1 text-xs text-muted-foreground">
145
+ <a href={base} navigate class="hover:text-foreground">
146
+ Dashboard
147
+ </a>
148
+ <span class="mx-1">/</span>
149
+ <span>Media</span>
150
+ </nav>
151
+ <h1 class="text-2xl font-semibold tracking-tight">Media</h1>
152
+ <p class="mt-1 text-sm text-muted-foreground">
153
+ {this._items.length === 0
154
+ ? "Nothing uploaded yet."
155
+ : `${this._items.length} file${this._items.length === 1 ? "" : "s"}`}
156
+ </p>
157
+ </div>
158
+
159
+ <label class="inline-flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:opacity-90">
160
+ <Icon name="upload" class="h-4 w-4" />
161
+ Upload
162
+ <input
163
+ type="file"
164
+ class="hidden"
165
+ {...{ "flow:model": "upload" }}
166
+ onChange={this.uploadFile}
167
+ />
168
+ </label>
169
+ </div>
170
+
171
+ {!provider ? (
172
+ <div class="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
173
+ No media provider is configured for this panel.
174
+ </div>
175
+ ) : (
176
+ <>
177
+ <div class="flex flex-wrap items-center gap-2">
178
+ <div class="relative max-w-sm flex-1">
179
+ <span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-muted-foreground">
180
+ <Icon name="search" class="h-4 w-4" />
181
+ </span>
182
+ <input
183
+ {...{ "flow:model.live": "search" }}
184
+ placeholder="Search files…"
185
+ class="h-9 w-full rounded-lg border border-input bg-background pl-9 pr-3 text-sm outline-none transition focus:ring-2 focus:ring-ring"
186
+ />
187
+ </div>
188
+ {folders.length > 0 ? (
189
+ <select
190
+ {...{ "flow:model.live": "folder" }}
191
+ class="h-9 rounded-lg border border-input bg-background px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
192
+ >
193
+ <option value="">All folders</option>
194
+ {folders.map((f) => (
195
+ <option value={f} selected={f === this.folder}>
196
+ {f}
197
+ </option>
198
+ ))}
199
+ </select>
200
+ ) : null}
201
+ </div>
202
+
203
+ {this._items.length === 0 ? (
204
+ <div class="rounded-lg border border-dashed border-border p-10 text-center">
205
+ <Icon name="image" class="mx-auto h-8 w-8 text-muted-foreground" />
206
+ <p class="mt-3 text-sm font-medium">No files here</p>
207
+ <p class="mt-1 text-sm text-muted-foreground">
208
+ Upload one, and it becomes reusable everywhere in the panel.
209
+ </p>
210
+ </div>
211
+ ) : (
212
+ <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6">
213
+ {this._items.map((item) => (
214
+ <button
215
+ type="button"
216
+ {...{ "flow:key": item.id }}
217
+ onClick={this.openDetails}
218
+ data-args={JSON.stringify([item.id])}
219
+ class="group overflow-hidden rounded-lg border border-border bg-card text-left transition hover:border-primary/50 hover:shadow-sm"
220
+ >
221
+ <div class="flex aspect-square items-center justify-center bg-muted/40">
222
+ {isImage(item) && urls.get(item.id) ? (
223
+ <img
224
+ src={urls.get(item.id)!}
225
+ alt={item.alt ?? item.name}
226
+ loading="lazy"
227
+ class="h-full w-full object-cover"
228
+ />
229
+ ) : (
230
+ <Icon name="file" class="h-8 w-8 text-muted-foreground" />
231
+ )}
232
+ </div>
233
+ <div class="p-2">
234
+ <p class="truncate text-xs font-medium" title={item.name}>
235
+ {item.name}
236
+ </p>
237
+ <p class="text-[11px] text-muted-foreground">{formatSize(item.size)}</p>
238
+ </div>
239
+ </button>
240
+ ))}
241
+ </div>
242
+ )}
243
+
244
+ {/* Only offered when the page is full — otherwise there is nothing more. */}
245
+ {this._items.length >= this.limit ? (
246
+ <div class="text-center">
247
+ <button
248
+ type="button"
249
+ onClick={this.loadMore}
250
+ class="inline-flex h-9 items-center rounded-lg border border-input px-4 text-sm font-medium transition hover:bg-accent"
251
+ >
252
+ Load more
253
+ </button>
254
+ </div>
255
+ ) : null}
256
+ </>
257
+ )}
258
+
259
+ {/* Details panel for the selected file. */}
260
+ {open ? (
261
+ <div class="fixed inset-y-0 right-0 z-40 w-full max-w-sm overflow-y-auto border-l border-border bg-card p-5 shadow-xl">
262
+ <div class="flex items-start justify-between gap-3">
263
+ <h2 class="text-sm font-semibold break-all">{open.name}</h2>
264
+ <button
265
+ type="button"
266
+ onClick={this.closeDetails}
267
+ aria-label="Close"
268
+ class="rounded-md p-1 text-muted-foreground transition hover:bg-accent hover:text-foreground"
269
+ >
270
+ <Icon name="x" class="h-4 w-4" />
271
+ </button>
272
+ </div>
273
+
274
+ {isImage(open) && urls.get(open.id) ? (
275
+ <img
276
+ src={urls.get(open.id)}
277
+ alt={open.alt ?? open.name}
278
+ class="mt-4 w-full rounded-lg border border-border object-contain"
279
+ />
280
+ ) : null}
281
+
282
+ <dl class="mt-4 space-y-1 text-xs text-muted-foreground">
283
+ <div class="flex justify-between gap-3">
284
+ <dt>Type</dt>
285
+ <dd class="text-foreground">{open.mime}</dd>
286
+ </div>
287
+ <div class="flex justify-between gap-3">
288
+ <dt>Size</dt>
289
+ <dd class="text-foreground">{formatSize(open.size)}</dd>
290
+ </div>
291
+ <div class="flex justify-between gap-3">
292
+ <dt>Path</dt>
293
+ <dd class="break-all text-foreground">{open.path}</dd>
294
+ </div>
295
+ </dl>
296
+
297
+ <div class="mt-5 space-y-3">
298
+ <label class="block">
299
+ <span class="mb-1 block text-xs font-medium">Alt text</span>
300
+ <input
301
+ {...{ "flow:model": "alt" }}
302
+ placeholder="What the image shows"
303
+ class="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
304
+ />
305
+ </label>
306
+ <label class="block">
307
+ <span class="mb-1 block text-xs font-medium">Folder</span>
308
+ <input
309
+ {...{ "flow:model": "editFolder" }}
310
+ placeholder="products"
311
+ class="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
312
+ />
313
+ </label>
314
+ </div>
315
+
316
+ <div class="mt-5 flex items-center gap-2">
317
+ <button
318
+ type="button"
319
+ onClick={this.saveDetails}
320
+ class="inline-flex h-9 flex-1 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:opacity-90"
321
+ >
322
+ Save
323
+ </button>
324
+ <button
325
+ type="button"
326
+ onClick={this.remove}
327
+ data-args={JSON.stringify([open.id])}
328
+ confirm="Delete this file? Anything still pointing at it will break."
329
+ class="inline-flex h-9 items-center justify-center rounded-lg border border-destructive/40 px-3 text-sm font-medium text-destructive transition hover:bg-destructive/10"
330
+ >
331
+ <Icon name="trash" class="h-4 w-4" />
332
+ </button>
333
+ </div>
334
+ </div>
335
+ ) : null}
336
+ </div>
337
+ );
338
+ }
339
+ }
340
+
341
+ /** Build a MediaPage bound to one panel. */
342
+ export function makeMediaPage(panel: PanelInstance = Panel.default()): typeof MediaPage {
343
+ return class BoundMediaPage extends MediaPage {
344
+ static override panel = panel;
345
+ };
346
+ }
@@ -0,0 +1,155 @@
1
+ /** @jsxImportSource @zerotal/flow */
2
+ // Notification center — lists the current user's notifications (resolved by the
3
+ // app-supplied provider), highlights unread ones, and offers mark-as-read /
4
+ // mark-all-read. Reactive: marking re-renders the list server-side.
5
+
6
+ import { Component, expose, on } from "@zerotal/flow";
7
+ import type { HtmlNode } from "@zerotal/flow";
8
+ import { AdminLayout, makeAdminLayout } 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
+
13
+ export class NotificationsPage extends Component {
14
+ static layout = AdminLayout;
15
+ /** The panel this page belongs to — set by each generated subclass. */
16
+ static panel: PanelInstance;
17
+
18
+ private get _panel(): PanelInstance {
19
+ return (this.constructor as typeof NotificationsPage).panel ?? Panel.current();
20
+ }
21
+
22
+ @expose async markRead(id: unknown): Promise<void> {
23
+ await this._panel.notificationProvider()?.markRead?.(String(id));
24
+ }
25
+
26
+ @expose async markAllRead(): Promise<void> {
27
+ await this._panel.notificationProvider()?.markAllRead?.();
28
+ }
29
+
30
+ /**
31
+ * Broadcast listener. When the app broadcasts a notification on
32
+ * the `admin-notifications` channel, this fires server-side and the resulting
33
+ * re-render re-resolves the list — so a new notification appears live. The body
34
+ * is intentionally empty: the re-render does the work.
35
+ */
36
+ @on("echo:admin-notifications,.notification.sent")
37
+ onBroadcast(): void {}
38
+
39
+ override async render(): Promise<HtmlNode> {
40
+ const provider = this._panel.notificationProvider();
41
+ const base = this._panel.base();
42
+ const items = provider ? await provider.resolve() : [];
43
+ const unread = items.filter((n) => !n.read).length;
44
+ const canMarkAll = !!provider?.markAllRead;
45
+ const canMarkOne = !!provider?.markRead;
46
+
47
+ return (
48
+ // Polls every 20s and listens for Echo broadcasts → new notifications arrive live.
49
+ <div class="mx-auto w-full max-w-2xl space-y-6" poll={{ every: "20s" }}>
50
+ <div class="flex flex-wrap items-end justify-between gap-4">
51
+ <div>
52
+ <nav class="mb-1 text-xs text-muted-foreground">
53
+ <a href={base} navigate class="hover:text-foreground">
54
+ Dashboard
55
+ </a>
56
+ <span class="px-1.5">/</span>
57
+ <span>Notifications</span>
58
+ </nav>
59
+ <h1 class="text-2xl font-semibold tracking-tight">Notifications</h1>
60
+ <p class="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
61
+ <span>
62
+ {unread} unread of {items.length}
63
+ </span>
64
+ {provider ? (
65
+ <span class="inline-flex items-center gap-1 text-xs text-success">
66
+ <span class="h-1.5 w-1.5 rounded-full bg-success" /> Live
67
+ </span>
68
+ ) : null}
69
+ </p>
70
+ </div>
71
+ {canMarkAll && unread > 0 ? (
72
+ <button
73
+ type="button"
74
+ onClick={this.markAllRead}
75
+ class="inline-flex h-9 items-center gap-1.5 rounded-lg border border-input bg-background px-3 text-sm font-medium transition hover:bg-accent hover:text-accent-foreground"
76
+ >
77
+ <Icon name="check-circle" class="h-4 w-4" /> Mark all read
78
+ </button>
79
+ ) : null}
80
+ </div>
81
+
82
+ {!provider ? (
83
+ <div class="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
84
+ No notification provider configured. Call{" "}
85
+ <code class="rounded bg-muted px-1.5 py-0.5">Panel.notifications(...)</code>.
86
+ </div>
87
+ ) : items.length === 0 ? (
88
+ <div class="rounded-xl border border-dashed border-border p-12 text-center">
89
+ <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
90
+ <Icon name="bell" class="h-6 w-6" />
91
+ </div>
92
+ <p class="mt-3 text-sm font-medium">You're all caught up</p>
93
+ </div>
94
+ ) : (
95
+ <div class="overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm">
96
+ <ul class="divide-y divide-border">
97
+ {items.map((n) => (
98
+ <li class={`flex items-start gap-3 px-4 py-3 ${n.read ? "" : "bg-primary/5"}`}>
99
+ <span
100
+ class={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
101
+ n.read ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary"
102
+ }`}
103
+ >
104
+ <Icon name={n.icon ?? "bell"} class="h-4 w-4" />
105
+ </span>
106
+ <div class="min-w-0 flex-1">
107
+ <div class="flex items-center gap-2">
108
+ {n.href ? (
109
+ <a
110
+ href={n.href}
111
+ navigate
112
+ class="truncate text-sm font-medium hover:underline"
113
+ >
114
+ {n.title}
115
+ </a>
116
+ ) : (
117
+ <span class="truncate text-sm font-medium">{n.title}</span>
118
+ )}
119
+ {n.read ? null : <span class="h-2 w-2 shrink-0 rounded-full bg-primary" />}
120
+ </div>
121
+ {n.body ? <p class="mt-0.5 text-sm text-muted-foreground">{n.body}</p> : null}
122
+ {n.time ? <p class="mt-1 text-xs text-muted-foreground/70">{n.time}</p> : null}
123
+ </div>
124
+ {canMarkOne && !n.read ? (
125
+ <button
126
+ type="button"
127
+ onClick={this.markRead}
128
+ data-args={JSON.stringify([n.id])}
129
+ title="Mark read"
130
+ class="shrink-0 rounded-md p-1.5 text-muted-foreground transition hover:bg-accent hover:text-foreground"
131
+ >
132
+ <Icon name="check-circle" class="h-4 w-4" />
133
+ </button>
134
+ ) : null}
135
+ </li>
136
+ ))}
137
+ </ul>
138
+ </div>
139
+ )}
140
+ </div>
141
+ );
142
+ }
143
+ }
144
+
145
+ /** The notification centre for one panel, reading that panel's provider. */
146
+ export function makeNotificationsPage(
147
+ panel: PanelInstance = Panel.default(),
148
+ ): typeof NotificationsPage {
149
+ const Page = class extends NotificationsPage {
150
+ static override panel = panel;
151
+ static override layout = makeAdminLayout(panel);
152
+ };
153
+ Object.defineProperty(Page, "name", { value: "NotificationsPage" });
154
+ return Page;
155
+ }