@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,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
|
+
}
|