@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
package/src/media.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The media library — one place to see, upload, reuse and remove files.
|
|
3
|
+
*
|
|
4
|
+
* A file upload field puts a file somewhere and stores a path. That is enough
|
|
5
|
+
* until the same logo is needed on twenty products, or somebody asks which
|
|
6
|
+
* records are still pointing at a file before deleting it. A library answers
|
|
7
|
+
* both, by keeping a catalogue alongside the bytes.
|
|
8
|
+
*
|
|
9
|
+
* The split matters: `@zerotal/core/storage` holds the file, and a
|
|
10
|
+
* {@link MediaProvider} holds the record of it. Listing a bucket is not a
|
|
11
|
+
* substitute — it cannot tell you alt text, who uploaded something, or what it
|
|
12
|
+
* is used for, and on a large disk it is slow besides. So the provider is the
|
|
13
|
+
* app's, exactly as the notification centre's and the saved views' are:
|
|
14
|
+
*
|
|
15
|
+
* Panel.media(databaseMedia()); // the ordinary case, over a table
|
|
16
|
+
* Panel.media({ list, save, remove }); // or your own
|
|
17
|
+
*
|
|
18
|
+
* With no provider configured the library page and the picker do not appear, and
|
|
19
|
+
* file fields keep working as plain uploads.
|
|
20
|
+
*
|
|
21
|
+
* Which disk the library writes to is the panel's to say — `Panel.media(provider,
|
|
22
|
+
* { disk })`. It matters more than it looks: the default disk is private and
|
|
23
|
+
* declares no `serve`, so a library left on it stores every upload successfully
|
|
24
|
+
* and has no URL for any of them.
|
|
25
|
+
*/
|
|
26
|
+
import { frameworkLog } from "@zerotal/core/logger";
|
|
27
|
+
import { Storage } from "@zerotal/core/storage";
|
|
28
|
+
|
|
29
|
+
/** One catalogued file. */
|
|
30
|
+
export interface MediaItem {
|
|
31
|
+
id: string;
|
|
32
|
+
/** Path on the disk — what a record stores, and what `Storage.url()` resolves. */
|
|
33
|
+
path: string;
|
|
34
|
+
/** Original file name, for display and for the download attribute. */
|
|
35
|
+
name: string;
|
|
36
|
+
/** MIME type as uploaded. */
|
|
37
|
+
mime: string;
|
|
38
|
+
/** Size in bytes. */
|
|
39
|
+
size: number;
|
|
40
|
+
/** Alternative text, for images. Empty is a valid answer for a decorative one. */
|
|
41
|
+
alt?: string;
|
|
42
|
+
/** Free-form grouping — "products", "avatars" — for filtering the library. */
|
|
43
|
+
folder?: string;
|
|
44
|
+
/** ISO timestamp. */
|
|
45
|
+
uploadedAt?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the app supplies so the library can be listed and maintained. */
|
|
49
|
+
export interface MediaProvider {
|
|
50
|
+
/** Catalogued files, newest first, optionally narrowed by folder or search. */
|
|
51
|
+
list(query: {
|
|
52
|
+
folder?: string;
|
|
53
|
+
search?: string;
|
|
54
|
+
limit: number;
|
|
55
|
+
}): Promise<MediaItem[]> | MediaItem[];
|
|
56
|
+
/** Record a newly stored file. The panel supplies everything but the id. */
|
|
57
|
+
save(item: Omit<MediaItem, "id">): Promise<MediaItem> | MediaItem;
|
|
58
|
+
/** Forget a file, and delete its bytes. */
|
|
59
|
+
remove(id: string): Promise<void> | void;
|
|
60
|
+
/** Update the editable metadata on one item. */
|
|
61
|
+
update?(id: string, changes: Pick<MediaItem, "alt" | "folder">): Promise<void> | void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Image types the picker shows a thumbnail for rather than an icon. */
|
|
65
|
+
const IMAGE_TYPES = /^image\/(png|jpe?g|gif|webp|avif|svg\+xml)$/i;
|
|
66
|
+
|
|
67
|
+
export function isImage(item: MediaItem): boolean {
|
|
68
|
+
return IMAGE_TYPES.test(item.mime);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `1.4 MB` — sizes are read at a glance, not audited. */
|
|
72
|
+
export function formatSize(bytes: number): string {
|
|
73
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "";
|
|
74
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
75
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
76
|
+
let value = bytes / 1024;
|
|
77
|
+
let unit = 0;
|
|
78
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
79
|
+
value /= 1024;
|
|
80
|
+
unit++;
|
|
81
|
+
}
|
|
82
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A storage path for an upload that will not collide with another.
|
|
87
|
+
*
|
|
88
|
+
* The random prefix is the point: two people uploading `logo.png` on the same
|
|
89
|
+
* day must not overwrite each other, and a guessable path is a small
|
|
90
|
+
* information leak on a private disk. The original name is kept on the end so
|
|
91
|
+
* the file is still recognisable in a bucket listing.
|
|
92
|
+
*
|
|
93
|
+
* The sanitising here is about producing a tidy, predictable key — it is not
|
|
94
|
+
* the defence against escaping the disk. The driver owns that, and rejects a
|
|
95
|
+
* traversing path with `PathTraversalError` whatever this produces.
|
|
96
|
+
*/
|
|
97
|
+
export function mediaPath(name: string, folder = "media"): string {
|
|
98
|
+
const safeName =
|
|
99
|
+
name
|
|
100
|
+
.replace(/[^\w.-]+/g, "-")
|
|
101
|
+
.replace(/^[.-]+/, "")
|
|
102
|
+
.slice(-80) || "file";
|
|
103
|
+
// Sanitised a segment at a time, so `../secrets` becomes `secrets` rather than
|
|
104
|
+
// a stray `-` directory beside it — and traversal is gone either way.
|
|
105
|
+
const safeFolder =
|
|
106
|
+
folder
|
|
107
|
+
.split("/")
|
|
108
|
+
.map((segment) => segment.replace(/[^\w-]+/g, "-").replace(/^-+|-+$/g, ""))
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.join("/") || "media";
|
|
111
|
+
return `${safeFolder}/${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-${safeName}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether storage is usable at all.
|
|
116
|
+
*
|
|
117
|
+
* Distinct from "this disk has no URL", which {@link StorageManager.isServed}
|
|
118
|
+
* answers. This one catches the earlier failure: an app that wired up a media
|
|
119
|
+
* provider but never registered `StorageProvider`, so the facade has nothing to
|
|
120
|
+
* resolve. That is a misconfiguration rather than a state to render, but it
|
|
121
|
+
* should degrade to a placeholder instead of taking the page down.
|
|
122
|
+
*/
|
|
123
|
+
function storageReady(disk?: string): boolean {
|
|
124
|
+
try {
|
|
125
|
+
return Storage.isServed(disk);
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A URL the browser can fetch for a catalogued file, or `null` when the disk
|
|
133
|
+
* has none.
|
|
134
|
+
*
|
|
135
|
+
* `null`, not the stored path. Returning the path produced a *relative* `src`
|
|
136
|
+
* that the browser resolved against whatever admin page was open — a media
|
|
137
|
+
* library at `/admin/shop/media` asking for `media/photo.jpg` fetched
|
|
138
|
+
* `/admin/shop/media/media/photo.jpg` and got the panel's own 404. A caller
|
|
139
|
+
* that knows there is no URL can render a placeholder; one handed a broken
|
|
140
|
+
* string cannot.
|
|
141
|
+
*/
|
|
142
|
+
export async function mediaUrl(item: MediaItem, disk?: string): Promise<string | null> {
|
|
143
|
+
// Asked, not caught. `isServed` exists precisely so "this disk has no public
|
|
144
|
+
// URL" is a branch rather than an exception, which leaves the catch below for
|
|
145
|
+
// things that are genuinely wrong — a signing key missing, a driver failing —
|
|
146
|
+
// and those get logged instead of quietly becoming a missing image.
|
|
147
|
+
if (!storageReady(disk)) return null;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
return await Storage.publicUrl(item.path, disk ? { disk } : {});
|
|
151
|
+
} catch (error) {
|
|
152
|
+
frameworkLog("admin").warn("Could not resolve a media URL", { path: item.path, disk }, error);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Turn a stored value into something an `<img src>` can fetch.
|
|
159
|
+
*
|
|
160
|
+
* Image columns and entries hold whatever the record holds, and that is one of
|
|
161
|
+
* three things: a full URL from an external service, a root-relative path the
|
|
162
|
+
* app already serves, or a *disk-relative* storage path like `media/photo.jpg`.
|
|
163
|
+
* Only the last needs resolving — and rendering it unresolved is the bug this
|
|
164
|
+
* exists to prevent, because a browser reads `media/photo.jpg` relative to the
|
|
165
|
+
* page and fetches `/admin/shop/media/photo.jpg`, which is the panel's own 404.
|
|
166
|
+
*
|
|
167
|
+
* Synchronous, because cells and entries render synchronously. That is possible
|
|
168
|
+
* for a plain public disk, where a URL is a prefix and a path. It is not for a
|
|
169
|
+
* signed disk, whose URL has to be minted per request — those return `null` and
|
|
170
|
+
* the caller shows a placeholder. Put images meant for a table on a public disk.
|
|
171
|
+
*
|
|
172
|
+
* @returns The URL, or `null` when there is none to give.
|
|
173
|
+
*/
|
|
174
|
+
export function resolveMediaSrc(value: unknown, disk?: string): string | null {
|
|
175
|
+
if (typeof value !== "string" || value === "") return null;
|
|
176
|
+
|
|
177
|
+
// Already fetchable: an absolute URL, a protocol-relative one, a data URI, or
|
|
178
|
+
// a root-relative path the app serves itself.
|
|
179
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(value)) return value;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
if (!Storage.isServed(disk)) return null;
|
|
183
|
+
// A signed disk cannot be resolved without minting a signature, which is
|
|
184
|
+
// async; say so rather than hand back an unsigned URL that will 403.
|
|
185
|
+
return Storage.disk(disk).url(value);
|
|
186
|
+
} catch {
|
|
187
|
+
// No storage configured at all.
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A file as it arrives from a bound file input.
|
|
194
|
+
*
|
|
195
|
+
* This is the shape of the temporary upload the framework hands a component
|
|
196
|
+
* once the browser has POSTed the bytes: the file is already on the temp disk,
|
|
197
|
+
* and `store()` moves it where it belongs.
|
|
198
|
+
*/
|
|
199
|
+
export interface UploadedFileLike {
|
|
200
|
+
originalName: string;
|
|
201
|
+
mime: string;
|
|
202
|
+
size: number;
|
|
203
|
+
store(directory: string, disk?: string, filename?: string): Promise<string>;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Whether a bound value is an upload rather than an already-stored path. */
|
|
207
|
+
export function isUpload(value: unknown): value is UploadedFileLike {
|
|
208
|
+
return (
|
|
209
|
+
value != null &&
|
|
210
|
+
typeof value === "object" &&
|
|
211
|
+
typeof (value as UploadedFileLike).store === "function" &&
|
|
212
|
+
typeof (value as UploadedFileLike).originalName === "string"
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface StoreMediaOptions {
|
|
217
|
+
provider: MediaProvider;
|
|
218
|
+
/** Disk to write to; the default disk when omitted. */
|
|
219
|
+
disk?: string;
|
|
220
|
+
folder?: string;
|
|
221
|
+
/** Refuse anything larger, in bytes. */
|
|
222
|
+
maxBytes?: number;
|
|
223
|
+
/** Accepted MIME types; anything goes when omitted. */
|
|
224
|
+
accept?: string[];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Move an uploaded file onto its permanent disk and catalogue it.
|
|
229
|
+
*
|
|
230
|
+
* Order matters: the bytes land first, and only a successful move is
|
|
231
|
+
* catalogued. A catalogue entry pointing at a file that isn't there is worse
|
|
232
|
+
* than a file nobody catalogued, because the panel would keep offering it.
|
|
233
|
+
*/
|
|
234
|
+
export async function storeMedia(
|
|
235
|
+
file: UploadedFileLike,
|
|
236
|
+
options: StoreMediaOptions,
|
|
237
|
+
): Promise<[true, MediaItem] | [false, string]> {
|
|
238
|
+
if (options.accept?.length && !options.accept.includes(file.mime)) {
|
|
239
|
+
return [false, `${file.originalName} is not an accepted file type.`];
|
|
240
|
+
}
|
|
241
|
+
if (options.maxBytes && file.size > options.maxBytes) {
|
|
242
|
+
return [false, `${file.originalName} is larger than ${formatSize(options.maxBytes)}.`];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const folder = options.folder ?? "media";
|
|
246
|
+
const path = mediaPath(file.originalName, folder);
|
|
247
|
+
// Split only to satisfy the directory/filename shape `store()` takes; the
|
|
248
|
+
// path was built as one string because that is what gets catalogued.
|
|
249
|
+
const slash = path.lastIndexOf("/");
|
|
250
|
+
let stored: string;
|
|
251
|
+
try {
|
|
252
|
+
stored = await file.store(path.slice(0, slash), options.disk, path.slice(slash + 1));
|
|
253
|
+
} catch (error) {
|
|
254
|
+
frameworkLog("admin").warn("Could not store an upload", { path }, error);
|
|
255
|
+
return [false, `Could not store ${file.originalName}.`];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const item = await options.provider.save({
|
|
259
|
+
path: stored || path,
|
|
260
|
+
name: file.originalName,
|
|
261
|
+
mime: file.mime,
|
|
262
|
+
size: file.size,
|
|
263
|
+
...(options.folder ? { folder: options.folder } : {}),
|
|
264
|
+
uploadedAt: new Date().toISOString(),
|
|
265
|
+
});
|
|
266
|
+
return [true, item];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Remove a file from both the catalogue and the disk.
|
|
271
|
+
*
|
|
272
|
+
* The catalogue entry goes first. If the disk delete then fails the result is an
|
|
273
|
+
* orphaned file — wasted space, but nothing broken — whereas the other order can
|
|
274
|
+
* leave the library offering a file that no longer exists.
|
|
275
|
+
*/
|
|
276
|
+
export async function deleteMedia(
|
|
277
|
+
item: MediaItem,
|
|
278
|
+
options: { provider: MediaProvider; disk?: string },
|
|
279
|
+
): Promise<[true] | [false, string]> {
|
|
280
|
+
try {
|
|
281
|
+
await options.provider.remove(item.id);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
frameworkLog("admin").warn("Could not remove a media record", { id: item.id }, error);
|
|
284
|
+
return [false, `Could not remove ${item.name}.`];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
await Storage.disk(options.disk).delete(item.path);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
frameworkLog("admin").warn(
|
|
291
|
+
"Media record removed but its file remains",
|
|
292
|
+
{ path: item.path },
|
|
293
|
+
error,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return [true];
|
|
297
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification center — app-wired. The `@zerotal/notifications` facade has no
|
|
3
|
+
* generic "current user's notifications" query (that depends on your auth +
|
|
4
|
+
* schema), so the admin owns the *UI* and the app supplies the *data* via a
|
|
5
|
+
* provider:
|
|
6
|
+
*
|
|
7
|
+
* Panel.notifications({
|
|
8
|
+
* async resolve() {
|
|
9
|
+
* const u = Auth.user();
|
|
10
|
+
* return (await u.notifications().latest().limit(20).get()).map((n) => ({
|
|
11
|
+
* id: String(n.id),
|
|
12
|
+
* title: n.data.title,
|
|
13
|
+
* body: n.data.body,
|
|
14
|
+
* href: n.data.url,
|
|
15
|
+
* read: n.read_at != null,
|
|
16
|
+
* time: n.created_at,
|
|
17
|
+
* }));
|
|
18
|
+
* },
|
|
19
|
+
* async markRead(id) { await Notification.find(id)?.markAsRead(); },
|
|
20
|
+
* async markAllRead() { await Auth.user().unreadNotifications().markAsRead(); },
|
|
21
|
+
* async unreadCount() { return Auth.user().unreadNotifications().count(); },
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* When no provider is configured, the bell and page are simply hidden.
|
|
25
|
+
*
|
|
26
|
+
* **Live / broadcast.** The notifications page polls (`flow:poll`) and listens on a
|
|
27
|
+
* broadcast channel (`@on("echo:…")`) so notifications appear without a manual
|
|
28
|
+
* reload. Broadcast a notification from the app on the channel named by
|
|
29
|
+
* {@link NOTIFICATION_CHANNEL} / {@link NOTIFICATION_EVENT} and the open page
|
|
30
|
+
* refreshes; the header bell's unread badge refreshes on each navigation (and
|
|
31
|
+
* whenever the page re-renders from a poll/broadcast tick).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The Echo channel the admin listens on for live notification broadcasts. */
|
|
35
|
+
export const NOTIFICATION_CHANNEL = "admin-notifications";
|
|
36
|
+
/** The Echo event name (a `broadcastAs` dotted name) the admin listens for. */
|
|
37
|
+
export const NOTIFICATION_EVENT = ".notification.sent";
|
|
38
|
+
|
|
39
|
+
export interface AdminNotification {
|
|
40
|
+
id: string;
|
|
41
|
+
title: string;
|
|
42
|
+
body?: string;
|
|
43
|
+
/** Optional link the notification points to (navigated on click). */
|
|
44
|
+
href?: string;
|
|
45
|
+
/** Optional icon key (see ui/icons). */
|
|
46
|
+
icon?: string;
|
|
47
|
+
/** Whether the notification has been read. */
|
|
48
|
+
read?: boolean;
|
|
49
|
+
/** Human/ISO timestamp shown beside the title. */
|
|
50
|
+
time?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface NotificationProvider {
|
|
54
|
+
/** Resolve the current user's notifications (newest first). */
|
|
55
|
+
resolve(): Promise<AdminNotification[]> | AdminNotification[];
|
|
56
|
+
/** Mark one notification read. */
|
|
57
|
+
markRead?(id: string): Promise<void> | void;
|
|
58
|
+
/** Mark all notifications read. */
|
|
59
|
+
markAllRead?(): Promise<void> | void;
|
|
60
|
+
/**
|
|
61
|
+
* Unread count for the header bell badge. Defaults to counting unread items
|
|
62
|
+
* from {@link resolve} when omitted — supply this for a cheaper count query.
|
|
63
|
+
*/
|
|
64
|
+
unreadCount?(): Promise<number> | number;
|
|
65
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A custom panel page — anything that belongs in the admin but isn't a Resource:
|
|
3
|
+
* a settings screen, a report, an ops console.
|
|
4
|
+
*
|
|
5
|
+
* import { AdminPage, Panel } from "@zerotal/admin";
|
|
6
|
+
*
|
|
7
|
+
* class ReportsPage extends AdminPage {
|
|
8
|
+
* static override slug = "reports";
|
|
9
|
+
* static override title = "Reports";
|
|
10
|
+
* static override navigationIcon = "chart";
|
|
11
|
+
* static override navigationGroup = "Insights";
|
|
12
|
+
* static override ability = "reports.view";
|
|
13
|
+
*
|
|
14
|
+
* override async render() {
|
|
15
|
+
* return <div>…</div>;
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* Panel.pages(ReportsPage);
|
|
20
|
+
*
|
|
21
|
+
* The page is a plain Flow component, so `@expose` state, actions and the
|
|
22
|
+
* WebSocket round-trip all work exactly as they do on a resource page. The panel
|
|
23
|
+
* mounts the route under its own path, applies the panel guard, and adds a
|
|
24
|
+
* sidebar entry — all from the statics above.
|
|
25
|
+
*
|
|
26
|
+
* This is the door for *application* code, which may depend on `@zerotal/admin`.
|
|
27
|
+
* Packages contribute through the container binding instead; see
|
|
28
|
+
* {@link AdminPanelHost}.
|
|
29
|
+
*/
|
|
30
|
+
import { Component } from "@zerotal/flow";
|
|
31
|
+
import { AdminLayout } from "../ui/AdminLayout.tsx";
|
|
32
|
+
import type { BadgeTone } from "../table/Column.ts";
|
|
33
|
+
import type { ClusterClass } from "../Cluster.ts";
|
|
34
|
+
|
|
35
|
+
export abstract class AdminPage extends Component {
|
|
36
|
+
static layout = AdminLayout;
|
|
37
|
+
|
|
38
|
+
/** Path under the panel root, without a leading slash — `"reports"`, `"settings/billing"`. */
|
|
39
|
+
static slug = "";
|
|
40
|
+
|
|
41
|
+
/** Page title, and the sidebar label unless {@link navigationLabel} overrides it. */
|
|
42
|
+
static title = "";
|
|
43
|
+
|
|
44
|
+
/** Sidebar label, when it should differ from the title. */
|
|
45
|
+
static navigationLabel?: string;
|
|
46
|
+
|
|
47
|
+
/** Icon name from the panel's icon set. */
|
|
48
|
+
static navigationIcon = "layout-grid";
|
|
49
|
+
|
|
50
|
+
/** Sidebar group heading. Ungrouped pages sort above every group. */
|
|
51
|
+
static navigationGroup?: string;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The {@link Cluster} this page belongs to. A clustered page shares the
|
|
55
|
+
* cluster's URL segment and sits under its sidebar entry alongside the
|
|
56
|
+
* cluster's resources — so a Shop report lives at `/admin/shop/report`.
|
|
57
|
+
*/
|
|
58
|
+
static cluster?: ClusterClass;
|
|
59
|
+
|
|
60
|
+
/** Sort weight within the group. Ties break alphabetically. */
|
|
61
|
+
static navigationSort = 0;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Ability required to see the sidebar entry and open the route.
|
|
65
|
+
*
|
|
66
|
+
* Leaving it unset means the page is governed by the panel guard alone — which
|
|
67
|
+
* is a defensible choice for a page the app wrote itself, and is why this is
|
|
68
|
+
* optional here but required for package contributions.
|
|
69
|
+
*/
|
|
70
|
+
static ability?: string;
|
|
71
|
+
|
|
72
|
+
/** Mount the route but keep the page out of the sidebar. */
|
|
73
|
+
static showInNavigation = true;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A count pill beside the sidebar entry — a pending total, an error count.
|
|
77
|
+
* A failing query is swallowed rather than taking the sidebar down with it.
|
|
78
|
+
*/
|
|
79
|
+
static navigationBadge?: () => Promise<string | number | null> | string | number | null;
|
|
80
|
+
|
|
81
|
+
/** Tone of the navigation badge. Defaults to `"primary"`. */
|
|
82
|
+
static navigationBadgeColor?: BadgeTone;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Extra route patterns mounted onto this page, relative to {@link slug} — e.g.
|
|
86
|
+
* `[":section"]` so one page serves `/reports` and `/reports/revenue`.
|
|
87
|
+
*/
|
|
88
|
+
static routeParams?: string[];
|
|
89
|
+
|
|
90
|
+
/** The sidebar label — {@link navigationLabel}, falling back to {@link title}. */
|
|
91
|
+
static getNavigationLabel(): string {
|
|
92
|
+
return this.navigationLabel ?? this.title;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A concrete {@link AdminPage} subclass — the static metadata above, plus the
|
|
98
|
+
* zero-argument constructor Flow builds the page with.
|
|
99
|
+
*/
|
|
100
|
+
export type AdminPageClass = typeof AdminPage & (new () => AdminPage);
|