@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,148 @@
1
+ /**
2
+ * A media catalogue kept in a database table.
3
+ *
4
+ * {@link MediaProvider} is deliberately open, because where a file catalogue
5
+ * belongs is the app's decision. When the answer is the ordinary one — a table
6
+ * with a row per file — this builds the provider for you:
7
+ *
8
+ * import { databaseMedia } from "@zerotal/admin";
9
+ *
10
+ * Panel.media(databaseMedia());
11
+ *
12
+ * The table it expects:
13
+ *
14
+ * id, path, name, mime, size, alt, folder, uploaded_at
15
+ *
16
+ * Column names are adjustable, so an existing table usually needs no migration.
17
+ * `@zerotal/orm` is resolved lazily, keeping it an optional peer.
18
+ */
19
+ import { frameworkLog } from "@zerotal/core/logger";
20
+ import type { MediaItem, MediaProvider } from "./media.ts";
21
+
22
+ export interface DatabaseMediaOptions {
23
+ /** Table holding the catalogue. Defaults to `"media"`. */
24
+ table?: string;
25
+ /** Map the panel's fields onto your column names. */
26
+ columns?: Partial<Record<keyof MediaItem, string>>;
27
+ }
28
+
29
+ const DEFAULT_COLUMNS: Record<keyof MediaItem, string> = {
30
+ id: "id",
31
+ path: "path",
32
+ name: "name",
33
+ mime: "mime",
34
+ size: "size",
35
+ alt: "alt",
36
+ folder: "folder",
37
+ uploadedAt: "uploaded_at",
38
+ };
39
+
40
+ /** The minimum query surface this needs, kept structural so `orm` stays optional. */
41
+ interface QueryLike {
42
+ where(column: string, value: unknown): QueryLike;
43
+ whereLike?(column: string, value: string): QueryLike;
44
+ orderBy(column: string, direction: string): QueryLike;
45
+ limit(n: number): QueryLike;
46
+ get(): Promise<Record<string, unknown>[]>;
47
+ insert(values: Record<string, unknown>): Promise<unknown>;
48
+ update(values: Record<string, unknown>): Promise<unknown>;
49
+ delete(): Promise<unknown>;
50
+ }
51
+
52
+ async function table(name: string): Promise<QueryLike | null> {
53
+ try {
54
+ const mod = (await import(/* @vite-ignore */ "@zerotal/orm" as string)) as {
55
+ DB?: { table(name: string): QueryLike };
56
+ };
57
+ return mod.DB?.table(name) ?? null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Build a {@link MediaProvider} over a table.
65
+ *
66
+ * Listing fails soft — an unconfigured database or a missing table yields an
67
+ * empty library rather than a broken page — but saving and removing report
68
+ * their failures, because silently losing an upload is not a kindness.
69
+ */
70
+ export function databaseMedia(options: DatabaseMediaOptions = {}): MediaProvider {
71
+ const name = options.table ?? "media";
72
+ const col = { ...DEFAULT_COLUMNS, ...(options.columns ?? {}) };
73
+
74
+ /** Turn a row into a catalogue item, tolerating nulls in the optional fields. */
75
+ const toItem = (row: Record<string, unknown>): MediaItem => {
76
+ const str = (key: keyof MediaItem): string | undefined => {
77
+ const value = row[col[key]];
78
+ return typeof value === "string" && value !== "" ? value : undefined;
79
+ };
80
+ return {
81
+ id: String(row[col.id] ?? ""),
82
+ path: str("path") ?? "",
83
+ name: str("name") ?? "",
84
+ mime: str("mime") ?? "application/octet-stream",
85
+ size: Number(row[col.size] ?? 0),
86
+ ...(str("alt") ? { alt: str("alt")! } : {}),
87
+ ...(str("folder") ? { folder: str("folder")! } : {}),
88
+ ...(str("uploadedAt") ? { uploadedAt: str("uploadedAt")! } : {}),
89
+ };
90
+ };
91
+
92
+ return {
93
+ async list(query): Promise<MediaItem[]> {
94
+ try {
95
+ const t = await table(name);
96
+ if (!t) return [];
97
+ let q = t;
98
+ if (query.folder) q = q.where(col.folder, query.folder);
99
+ // Searching by name is what a library search means; falling back to an
100
+ // exact match keeps this working on a driver without `whereLike`.
101
+ if (query.search) {
102
+ q = q.whereLike
103
+ ? q.whereLike(col.name, `%${query.search}%`)
104
+ : q.where(col.name, query.search);
105
+ }
106
+ const rows = await q.orderBy(col.uploadedAt, "desc").limit(query.limit).get();
107
+ return rows.map(toItem);
108
+ } catch (error) {
109
+ frameworkLog("admin").warn("Media library unavailable", { table: name }, error);
110
+ return [];
111
+ }
112
+ },
113
+
114
+ async save(item): Promise<MediaItem> {
115
+ const t = await table(name);
116
+ if (!t) throw new Error("The media library needs a configured database.");
117
+ const values: Record<string, unknown> = {
118
+ [col.path]: item.path,
119
+ [col.name]: item.name,
120
+ [col.mime]: item.mime,
121
+ [col.size]: item.size,
122
+ [col.alt]: item.alt ?? "",
123
+ [col.folder]: item.folder ?? "",
124
+ [col.uploadedAt]: item.uploadedAt ?? new Date().toISOString(),
125
+ };
126
+ const inserted = (await t.insert(values)) as Record<string, unknown> | undefined;
127
+ // Drivers differ on what an insert returns; the path is unique enough to
128
+ // identify the row when an id does not come back.
129
+ const id = inserted?.[col.id] ?? inserted;
130
+ return { ...item, id: id == null ? item.path : String(id) } as MediaItem;
131
+ },
132
+
133
+ async remove(id): Promise<void> {
134
+ const t = await table(name);
135
+ if (!t) throw new Error("The media library needs a configured database.");
136
+ await t.where(col.id, id).delete();
137
+ },
138
+
139
+ async update(id, changes): Promise<void> {
140
+ const t = await table(name);
141
+ if (!t) throw new Error("The media library needs a configured database.");
142
+ await t.where(col.id, id).update({
143
+ [col.alt]: changes.alt ?? "",
144
+ [col.folder]: changes.folder ?? "",
145
+ });
146
+ },
147
+ };
148
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Database-backed notifications for the panel's bell.
3
+ *
4
+ * `Panel.notifications()` takes a provider because "the current user's
5
+ * notifications" depends on your auth and your schema. When those are the
6
+ * ordinary ones — `@zerotal/auth` for the user, `@zerotal/notifications`'
7
+ * `DatabaseChannel` for storage — this builds that provider for you:
8
+ *
9
+ * import { databaseNotifications } from "@zerotal/admin";
10
+ *
11
+ * Panel.notifications(databaseNotifications());
12
+ *
13
+ * Everything is optional and adjustable: which user the notifications belong to,
14
+ * how a stored row becomes a title and a link, and which table to read.
15
+ *
16
+ * `@zerotal/notifications` and `@zerotal/auth` are resolved lazily, so neither
17
+ * becomes a hard dependency of the admin package for apps that don't use this.
18
+ */
19
+ import type { AdminNotification, NotificationProvider } from "./notifications.ts";
20
+ import { frameworkLog } from "@zerotal/core/logger";
21
+
22
+ /** A stored notification row, as `DatabaseChannel` writes it. */
23
+ export interface StoredNotification {
24
+ id: string;
25
+ notifiable_type: string;
26
+ notifiable_id: string;
27
+ type: string;
28
+ data: string;
29
+ read_at: string | null;
30
+ created_at: string;
31
+ }
32
+
33
+ export interface DatabaseNotificationOptions {
34
+ /** Table holding the notifications. Defaults to `"notifications"`. */
35
+ table?: string;
36
+ /**
37
+ * Who the bell is showing. Defaults to the signed-in user from
38
+ * `@zerotal/auth`; returning `null` shows nothing, which is the right answer
39
+ * for a guest.
40
+ */
41
+ notifiable?: () => Promise<unknown> | unknown;
42
+ /** How many to show. Defaults to 20 — a bell is not an archive. */
43
+ limit?: number;
44
+ /**
45
+ * Turn a stored row into what the panel renders. The default reads `title`,
46
+ * `body`/`message`, `url`/`href` and `icon` out of the payload, which is what
47
+ * most notifications carry.
48
+ */
49
+ present?: (row: StoredNotification, data: Record<string, unknown>) => AdminNotification;
50
+ }
51
+
52
+ /** Parse a row's JSON payload, tolerating a payload that was never JSON. */
53
+ function payloadOf(row: StoredNotification): Record<string, unknown> {
54
+ try {
55
+ const parsed = JSON.parse(row.data) as unknown;
56
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
57
+ } catch {
58
+ return {};
59
+ }
60
+ }
61
+
62
+ /** The default presentation: whatever the payload says, with sane fallbacks. */
63
+ function defaultPresent(row: StoredNotification, data: Record<string, unknown>): AdminNotification {
64
+ const str = (key: string): string | undefined => {
65
+ const value = data[key];
66
+ return typeof value === "string" && value ? value : undefined;
67
+ };
68
+ return {
69
+ id: row.id,
70
+ // A notification with no title is still worth showing; name it by its class.
71
+ title: str("title") ?? row.type,
72
+ ...((str("body") ?? str("message")) ? { body: str("body") ?? str("message")! } : {}),
73
+ ...((str("url") ?? str("href")) ? { href: str("url") ?? str("href")! } : {}),
74
+ ...(str("icon") ? { icon: str("icon")! } : {}),
75
+ read: row.read_at != null,
76
+ time: row.created_at,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Build a {@link NotificationProvider} reading from the notifications table.
82
+ *
83
+ * Every method fails soft: a missing table, an unconfigured database or a
84
+ * signed-out user yields an empty bell rather than a broken panel, because a
85
+ * notification centre is never worth taking the page down for.
86
+ */
87
+ export function databaseNotifications(
88
+ options: DatabaseNotificationOptions = {},
89
+ ): NotificationProvider {
90
+ const limit = options.limit ?? 20;
91
+ const present = options.present ?? defaultPresent;
92
+
93
+ /** The DatabaseChannel instance, resolved lazily and cached. */
94
+ let channel: Promise<DatabaseChannelLike | null> | null = null;
95
+ const getChannel = (): Promise<DatabaseChannelLike | null> => {
96
+ channel ??= (async () => {
97
+ try {
98
+ const mod = (await import(/* @vite-ignore */ "@zerotal/notifications" as string)) as {
99
+ DatabaseChannel?: new (table?: string) => DatabaseChannelLike;
100
+ };
101
+ if (!mod.DatabaseChannel) return null;
102
+ return new mod.DatabaseChannel(options.table);
103
+ } catch {
104
+ return null;
105
+ }
106
+ })();
107
+ return channel;
108
+ };
109
+
110
+ const getNotifiable = async (): Promise<unknown> => {
111
+ if (options.notifiable) return options.notifiable();
112
+ try {
113
+ const mod = (await import(/* @vite-ignore */ "@zerotal/auth" as string)) as {
114
+ Auth?: { user?: () => unknown };
115
+ };
116
+ return mod.Auth?.user?.() ?? null;
117
+ } catch {
118
+ return null;
119
+ }
120
+ };
121
+
122
+ /** Run `fn` against the channel + current user, or yield `fallback`. */
123
+ const withChannel = async <T>(
124
+ fn: (channel: DatabaseChannelLike, notifiable: object) => Promise<T>,
125
+ fallback: T,
126
+ ): Promise<T> => {
127
+ try {
128
+ const [c, notifiable] = await Promise.all([getChannel(), getNotifiable()]);
129
+ if (!c || !notifiable || typeof notifiable !== "object") return fallback;
130
+ return await fn(c, notifiable);
131
+ } catch (error) {
132
+ frameworkLog("admin").warn("Database notifications unavailable", undefined, error);
133
+ return fallback;
134
+ }
135
+ };
136
+
137
+ return {
138
+ async resolve(): Promise<AdminNotification[]> {
139
+ return withChannel(async (c, notifiable) => {
140
+ const rows = await c.all(notifiable);
141
+ return rows.slice(0, limit).map((row) => present(row, payloadOf(row)));
142
+ }, []);
143
+ },
144
+
145
+ async unreadCount(): Promise<number> {
146
+ return withChannel(async (c, notifiable) => (await c.unread(notifiable)).length, 0);
147
+ },
148
+
149
+ async markRead(id: string): Promise<void> {
150
+ await withChannel(async (c) => {
151
+ await c.markAsRead(id);
152
+ }, undefined);
153
+ },
154
+
155
+ async markAllRead(): Promise<void> {
156
+ await withChannel(async (c, notifiable) => {
157
+ await c.markAllAsRead(notifiable);
158
+ }, undefined);
159
+ },
160
+ };
161
+ }
162
+
163
+ /** The slice of `DatabaseChannel` this bridge uses. */
164
+ interface DatabaseChannelLike {
165
+ all(notifiable: object): Promise<StoredNotification[]>;
166
+ unread(notifiable: object): Promise<StoredNotification[]>;
167
+ markAsRead(id: string): Promise<void>;
168
+ markAllAsRead(notifiable: object): Promise<void>;
169
+ }