@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,355 @@
1
+ /** @jsxImportSource @zerotal/flow */
2
+ // The persistent admin shell: sidebar navigation + sticky top bar + content slot.
3
+ // Pages set `static layout = AdminLayout`; on flow:navigate only the slot swaps,
4
+ // so the sidebar and theme state stay put. Dark/light is a pure-client toggle.
5
+
6
+ import { Layout } from "@zerotal/flow";
7
+ import type { HtmlNode } from "@zerotal/flow";
8
+ import { DropdownMenu } from "@zerotal/flow-ui";
9
+ import { Panel } from "../Panel.ts";
10
+ import type { PanelInstance } from "../PanelInstance.ts";
11
+ import { adminHead } from "../theme.ts";
12
+ import { resolveRenderHooks } from "../renderHooks.ts";
13
+ import { impersonatedName } from "../impersonation.ts";
14
+ import { Icon } from "./icons.tsx";
15
+ import { Command, Toaster, Sidebar } from "@zerotal/flow-ui";
16
+ import type { CommandItem, SidebarItem, SidebarGroup } from "@zerotal/flow-ui";
17
+
18
+ export class AdminLayout extends Layout {
19
+ /**
20
+ * The panel this shell belongs to. The base class serves whichever panel owns
21
+ * the request; {@link makeAdminLayout} binds a specific one, which is what
22
+ * every generated page uses so a WebSocket action can't drift to a sibling.
23
+ */
24
+ static panel: PanelInstance | undefined;
25
+
26
+ /** The bound panel, or the one owning the current request. */
27
+ protected panel(): PanelInstance {
28
+ return (this.constructor as typeof AdminLayout).panel ?? Panel.current();
29
+ }
30
+
31
+ // Dynamic so app config (brand + theme/stylesheet) is reflected — the router
32
+ // reads `LayoutClass.head` per render, after `Panel.configure(...)` has run.
33
+ static override get head(): string {
34
+ const cfg = (this.panel ?? Panel.current()).config();
35
+ return adminHead(cfg.brand ?? "Admin", cfg.theme);
36
+ }
37
+
38
+ async render(slot: HtmlNode): Promise<HtmlNode> {
39
+ const panel = this.panel();
40
+ const cfg = panel.config();
41
+ const base = panel.base();
42
+ // Only what this user may reach — the route guard enforces the same verdict,
43
+ // so a link that isn't drawn is also a URL that won't open.
44
+ const nav = await panel.visibleNavigation();
45
+ const topbarSlots = await Promise.all(
46
+ (await panel.visibleTopbarSlots()).map((s) => s.render()),
47
+ );
48
+ // The app's configured menu first, then anything packages added — so a
49
+ // contributed entry can't push Profile/Logout out of their familiar spot.
50
+ const menuItems = [
51
+ ...(cfg.userMenu?.items ?? []),
52
+ ...(await panel.visibleUserMenuItems()).map((i) => ({
53
+ label: i.label,
54
+ href: i.href,
55
+ icon: i.icon,
56
+ })),
57
+ ];
58
+
59
+ // Unread notification count for the bell badge (refreshes each navigation).
60
+ const notifications = panel.notificationProvider();
61
+ let unread = 0;
62
+ if (notifications) {
63
+ try {
64
+ unread =
65
+ typeof notifications.unreadCount === "function"
66
+ ? await notifications.unreadCount()
67
+ : (await notifications.resolve()).filter((n) => !n.read).length;
68
+ } catch {
69
+ unread = 0;
70
+ }
71
+ }
72
+
73
+ // Flat list of navigable destinations for the command palette. The group
74
+ // heading rides along as a keyword, so typing a cluster's name finds the
75
+ // resources inside it.
76
+ const paletteItems: CommandItem[] = [];
77
+ for (const g of nav) {
78
+ for (const item of g.items) {
79
+ paletteItems.push({
80
+ label: item.label,
81
+ href: item.href,
82
+ ...(g.group ? { group: g.group } : {}),
83
+ });
84
+ for (const child of item.children ?? []) {
85
+ paletteItems.push({
86
+ label: child.label,
87
+ href: child.href,
88
+ ...(g.group ? { group: g.group } : {}),
89
+ keywords: item.label,
90
+ });
91
+ }
92
+ }
93
+ }
94
+
95
+ // While acting as someone else, say so on every page. A quiet impersonation
96
+ // is how an operator forgets and does something as the wrong person.
97
+ const actingAs = await impersonatedName();
98
+
99
+ // Contributed chrome: banners, badges, notices. Resolved once per render so
100
+ // a throwing hook can't take the shell down with it.
101
+ const hook = (name: Parameters<typeof panel.renderHooks>[0]): (HtmlNode | string)[] =>
102
+ resolveRenderHooks(panel.renderHooks(name));
103
+
104
+ // Sidebar navigation badges (counts), resolved per resource slug.
105
+ const badges = await panel.navigationBadges();
106
+ const badgeTone: Record<string, string> = {
107
+ primary: "bg-primary/10 text-primary ring-1 ring-inset ring-primary/20",
108
+ success: "bg-success/10 text-success ring-1 ring-inset ring-success/20",
109
+ muted: "bg-muted text-muted-foreground",
110
+ destructive: "bg-destructive/10 text-destructive ring-1 ring-inset ring-destructive/20",
111
+ };
112
+
113
+ /** One nav entry, in the shape the sidebar component takes. */
114
+ const toItem = (item: {
115
+ href: string;
116
+ label: string;
117
+ icon: string;
118
+ slug?: string;
119
+ external?: boolean | undefined;
120
+ children?: Array<{
121
+ href: string;
122
+ label: string;
123
+ icon: string;
124
+ slug?: string;
125
+ external?: boolean | undefined;
126
+ }>;
127
+ }): SidebarItem => {
128
+ const badge = item.slug ? badges[item.slug] : undefined;
129
+ return {
130
+ label: item.label,
131
+ href: item.href,
132
+ icon: <Icon name={item.icon} class="h-[18px] w-[18px] shrink-0" />,
133
+ external: item.external,
134
+ ...(badge
135
+ ? { badge: badge.text, badgeClass: badgeTone[badge.color] ?? badgeTone["primary"] }
136
+ : {}),
137
+ ...(item.children?.length ? { children: item.children.map(toItem) } : {}),
138
+ };
139
+ };
140
+
141
+ const navGroups: SidebarGroup[] = [
142
+ {
143
+ items: [
144
+ {
145
+ label: "Dashboard",
146
+ href: base,
147
+ icon: <Icon name="home" class="h-[18px] w-[18px] shrink-0" />,
148
+ },
149
+ ],
150
+ },
151
+ ...nav.map((group) => ({
152
+ ...(group.group ? { label: group.group } : {}),
153
+ items: group.items.map(toItem),
154
+ })),
155
+ ];
156
+
157
+ return (
158
+ // The shell identity: two panels render different sidebars, so a navigation
159
+ // that crosses from one to the other has to replace the shell, not just the
160
+ // content slot.
161
+ <div
162
+ data-flow-layout={`admin-${panel.id}`}
163
+ class="min-h-screen bg-background text-foreground lg:grid lg:grid-cols-[16rem_1fr]"
164
+ >
165
+ {hook("body.start")}
166
+
167
+ {actingAs ? (
168
+ <div class="flex flex-wrap items-center justify-center gap-3 bg-amber-500 px-4 py-1.5 text-xs font-medium text-black lg:col-span-2">
169
+ <span>
170
+ Acting as <strong>{actingAs}</strong>
171
+ </span>
172
+ <a
173
+ href={`${base}/stop-impersonating`}
174
+ class="rounded bg-black/15 px-2 py-0.5 font-semibold transition hover:bg-black/25"
175
+ >
176
+ Stop
177
+ </a>
178
+ </div>
179
+ ) : null}
180
+ {/* The nav rail. Desktop-only here — the top bar carries the mobile
181
+ menu, so the component's own drawer is switched off. The active link
182
+ is marked by the client router rather than passed in, which is what
183
+ keeps it correct after a soft navigation. */}
184
+ <Sidebar
185
+ class="hidden border-r border-border bg-card/40 lg:flex lg:flex-col"
186
+ collapsible={false}
187
+ collapsibleGroups
188
+ brand={cfg.brand}
189
+ {...(cfg.tagline ? { tagline: cfg.tagline } : {})}
190
+ brandHref={base}
191
+ groups={navGroups}
192
+ beforeNav={hook("sidebar.start")}
193
+ afterNav={hook("sidebar.end")}
194
+ />
195
+
196
+ {/* ── Main column ─────────────────────────────────────────── */}
197
+ <div class="flex min-w-0 flex-col">
198
+ <header class="sticky top-0 z-20 flex h-16 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/60 sm:px-6">
199
+ <a href={base} navigate class="lg:hidden flex items-center gap-2 font-semibold">
200
+ <span class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-bold">
201
+ {cfg.brand.slice(0, 1).toUpperCase()}
202
+ </span>
203
+ </a>
204
+ {/* Global search — a plain GET form navigates to the search page;
205
+ the ⌘K hint opens the client-side command palette. */}
206
+ <form action={`${base}/search`} method="get" class="flex-1 max-w-md">
207
+ <div class="relative">
208
+ <span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-muted-foreground">
209
+ <Icon name="search" class="h-4 w-4" />
210
+ </span>
211
+ <input
212
+ type="search"
213
+ name="q"
214
+ placeholder="Search…"
215
+ class="h-9 w-full rounded-lg border border-input bg-background pl-9 pr-12 text-sm outline-none transition placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
216
+ />
217
+ <button
218
+ type="button"
219
+ onclick="window.__kPaletteOpen&&window.__kPaletteOpen()"
220
+ aria-label="Open command palette"
221
+ class="absolute inset-y-0 right-2 my-auto hidden h-6 items-center rounded border border-border bg-muted px-1.5 text-[10px] font-medium text-muted-foreground sm:inline-flex"
222
+ >
223
+ ⌘K
224
+ </button>
225
+ </div>
226
+ </form>
227
+ {/* Contributed top-bar slots — status pills, health indicators, tenant
228
+ switchers. Rendered before the panel's own controls so the bell and
229
+ the theme toggle stay where users expect them. */}
230
+ {hook("topbar.start")}
231
+ {topbarSlots}
232
+ {panel.roleProvider() ? (
233
+ <a
234
+ href={`${base}/roles`}
235
+ navigate
236
+ aria-label="Roles and permissions"
237
+ class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-input bg-background text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
238
+ >
239
+ <Icon name="shield" class="h-[18px] w-[18px]" />
240
+ </a>
241
+ ) : null}
242
+ {panel.mediaProvider() ? (
243
+ <a
244
+ href={`${base}/media`}
245
+ navigate
246
+ aria-label="Media library"
247
+ class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-input bg-background text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
248
+ >
249
+ <Icon name="image" class="h-[18px] w-[18px]" />
250
+ </a>
251
+ ) : null}
252
+ {notifications ? (
253
+ <a
254
+ href={`${base}/notifications`}
255
+ navigate
256
+ aria-label={unread > 0 ? `Notifications (${unread} unread)` : "Notifications"}
257
+ class="relative inline-flex h-9 w-9 items-center justify-center rounded-lg border border-input bg-background text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
258
+ >
259
+ <Icon name="bell" class="h-[18px] w-[18px]" />
260
+ {unread > 0 ? (
261
+ <span class="absolute -right-1 -top-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-destructive-foreground">
262
+ {unread > 99 ? "99+" : String(unread)}
263
+ </span>
264
+ ) : null}
265
+ </a>
266
+ ) : null}
267
+ <button
268
+ type="button"
269
+ onclick="window.__zerotalToggleTheme()"
270
+ aria-label="Toggle dark mode"
271
+ class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-input bg-background text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
272
+ >
273
+ <Icon name="sun" class="hidden h-[18px] w-[18px] dark:block" />
274
+ <Icon name="moon" class="h-[18px] w-[18px] dark:hidden" />
275
+ </button>
276
+
277
+ {menuItems.length > 0 ? (
278
+ <DropdownMenu
279
+ align="right"
280
+ trigger={
281
+ <button
282
+ type="button"
283
+ aria-label="Account menu"
284
+ class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-input bg-background text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
285
+ >
286
+ <Icon name="users" class="h-[18px] w-[18px]" />
287
+ </button>
288
+ }
289
+ >
290
+ {cfg.userMenu?.label ? (
291
+ <div class="px-2 py-1.5 text-sm font-semibold text-foreground">
292
+ {cfg.userMenu.label}
293
+ </div>
294
+ ) : null}
295
+ {cfg.userMenu?.label ? <div class="-mx-1 my-1 h-px bg-border" /> : null}
296
+ {menuItems.map((it) => (
297
+ <a
298
+ href={it.href}
299
+ navigate
300
+ class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
301
+ >
302
+ {it.icon ? <Icon name={it.icon} class="h-4 w-4" /> : null}
303
+ {it.label}
304
+ </a>
305
+ ))}
306
+ </DropdownMenu>
307
+ ) : null}
308
+ {hook("topbar.end")}
309
+ </header>
310
+
311
+ <main class="flex-1 animate-fade-in px-4 py-6 sm:px-6 lg:px-8">{slot}</main>
312
+ </div>
313
+
314
+ {/* Command palette. The items are the same nav tree the sidebar draws,
315
+ so a destination can never be reachable from one and not the other. */}
316
+ <Command
317
+ items={paletteItems}
318
+ placeholder="Jump to a resource…"
319
+ emptyMessage="Nothing matches that."
320
+ />
321
+
322
+ {hook("body.end")}
323
+
324
+ {/* Toast host for action flashes (delete, save, …). The themed one, so
325
+ a toast follows the panel's palette in light and dark alike. */}
326
+ <Toaster position="bottom-right" />
327
+ </div>
328
+ );
329
+ }
330
+ }
331
+
332
+ /**
333
+ * The shell class for one panel, cached so every page of that panel shares it.
334
+ *
335
+ * The router keys shell persistence on the layout class name, so each panel needs
336
+ * a class of its own: without that, navigating from one panel to another would
337
+ * swap the content while leaving the previous panel's sidebar in place.
338
+ */
339
+ export function makeAdminLayout(panel: PanelInstance): typeof AdminLayout {
340
+ const cached = LAYOUTS.get(panel);
341
+ if (cached) return cached;
342
+
343
+ const PanelAdminLayout = class extends AdminLayout {
344
+ static override panel = panel;
345
+ };
346
+ Object.defineProperty(PanelAdminLayout, "name", {
347
+ value: panel.id === "admin" ? "AdminLayout" : `AdminLayout_${panel.id}`,
348
+ });
349
+ LAYOUTS.set(panel, PanelAdminLayout);
350
+ return PanelAdminLayout;
351
+ }
352
+
353
+ const LAYOUTS = new WeakMap<PanelInstance, typeof AdminLayout>();
354
+
355
+ /** Self-contained command-palette controller (open/close, filter, keyboard nav). */
@@ -0,0 +1,84 @@
1
+ /** @jsxImportSource @zerotal/flow */
2
+ // Where a panel page sits, and every step back out.
3
+ //
4
+ // The trail matters most once resources stop being flat. A record three levels
5
+ // down — panel → cluster → parent record → nested resource → record — has no
6
+ // other affordance for climbing back to the middle of that path.
7
+ //
8
+ // The rendering is `@zerotal/flow-ui`'s; what lives here is `resourceTrail`,
9
+ // which knows how a panel's URLs are built.
10
+
11
+ import type { HtmlNode } from "@zerotal/flow";
12
+ import { Breadcrumb } from "@zerotal/flow-ui";
13
+ import type { PanelInstance } from "../PanelInstance.ts";
14
+ import type { ResourceClass } from "../Panel.ts";
15
+
16
+ /** One step in the trail. The last one is where you are, so it has no link. */
17
+ export interface Crumb {
18
+ label: string;
19
+ href?: string;
20
+ }
21
+
22
+ export function Breadcrumbs({ trail }: { trail: Crumb[] }): HtmlNode {
23
+ // Collapsed past five steps: a deeply nested record produces a trail that
24
+ // wraps onto two lines otherwise, and the middle is the part nobody clicks.
25
+ return <Breadcrumb items={trail} maxItems={5} class="mb-1" />;
26
+ }
27
+
28
+ export interface TrailOptions {
29
+ panel: PanelInstance;
30
+ resource: ResourceClass;
31
+ /** The parent record's id, for a nested resource. */
32
+ parentId?: string | undefined;
33
+ /** Title of the parent record, when it has been loaded. */
34
+ parentTitle?: string | undefined;
35
+ /** The final step — a record title, "New", "Edit". Omit on an index page. */
36
+ leaf?: string | undefined;
37
+ /** Link the resource step even when it is the last one (used on edit pages). */
38
+ recordId?: string | undefined;
39
+ recordTitle?: string | undefined;
40
+ }
41
+
42
+ /**
43
+ * Build the trail for a resource page.
44
+ *
45
+ * Each step is derived from the resource's own URL builders, so a resource that
46
+ * moves into a cluster or under a parent gets a correct trail with no extra
47
+ * declaration.
48
+ */
49
+ export function resourceTrail(options: TrailOptions): Crumb[] {
50
+ const { panel, resource, parentId, parentTitle, leaf, recordId, recordTitle } = options;
51
+ const base = panel.base();
52
+ const trail: Crumb[] = [{ label: "Dashboard", href: base }];
53
+
54
+ if (resource.cluster) {
55
+ // A cluster is a grouping rather than a page, so it points at its own first
56
+ // member — which for a member's own trail is this resource's index.
57
+ trail.push({ label: resource.cluster.title, href: resource.indexUrl(base, parentId) });
58
+ }
59
+
60
+ const parent = resource.parentResource();
61
+ if (parent && parentId) {
62
+ trail.push({ label: parent.getPluralLabel(), href: parent.indexUrl(base) });
63
+ trail.push({
64
+ label: parentTitle ?? `#${parentId}`,
65
+ href: parent.recordUrl(base, parentId),
66
+ });
67
+ }
68
+
69
+ const needsResourceLink = Boolean(leaf || recordId);
70
+ trail.push({
71
+ label: resource.getPluralLabel(),
72
+ ...(needsResourceLink ? { href: resource.indexUrl(base, parentId) } : {}),
73
+ });
74
+
75
+ if (recordId) {
76
+ trail.push({
77
+ label: recordTitle ?? `#${recordId}`,
78
+ ...(leaf ? { href: resource.recordUrl(base, recordId, parentId) } : {}),
79
+ });
80
+ }
81
+ if (leaf) trail.push({ label: leaf });
82
+
83
+ return trail;
84
+ }
@@ -0,0 +1,63 @@
1
+ /** @jsxImportSource @zerotal/flow */
2
+ // A visible answer to "which environment am I about to change data in?"
3
+ //
4
+ // The expensive mistake is editing production believing it is staging. A border
5
+ // and a badge cost nothing and remove the ambiguity entirely, which is why every
6
+ // admin panel ecosystem grows a plugin for exactly this.
7
+
8
+ import type { HtmlNode } from "@zerotal/flow";
9
+ import type { RenderHook } from "../renderHooks.ts";
10
+
11
+ export interface EnvironmentIndicatorOptions {
12
+ /** Environment name. Defaults to `APP_ENV`. */
13
+ environment?: string;
14
+ /**
15
+ * Environments to stay silent in. Local development needs no reminder that it
16
+ * is local — the point is to mark the ones where a mistake costs something.
17
+ */
18
+ quiet?: string[];
19
+ /** Tone per environment; anything unlisted falls back to a warning amber. */
20
+ tones?: Record<string, string>;
21
+ }
22
+
23
+ const DEFAULT_TONES: Record<string, string> = {
24
+ production: "bg-destructive text-destructive-foreground",
25
+ prod: "bg-destructive text-destructive-foreground",
26
+ staging: "bg-amber-500 text-black",
27
+ test: "bg-primary text-primary-foreground",
28
+ };
29
+
30
+ /**
31
+ * A render hook drawing a strip across the top of the panel naming the
32
+ * environment.
33
+ *
34
+ * Panel.renderHook("body.start", environmentIndicator());
35
+ *
36
+ * Returns `null` in the quiet environments, so registering it unconditionally is
37
+ * the intended usage — there is nothing to switch off per environment.
38
+ */
39
+ export function environmentIndicator(options: EnvironmentIndicatorOptions = {}): RenderHook {
40
+ const quiet = new Set(options.quiet ?? ["local", "development", "dev"]);
41
+ const tones = { ...DEFAULT_TONES, ...(options.tones ?? {}) };
42
+
43
+ return (): HtmlNode | null => {
44
+ const env = (options.environment ?? Bun.env["APP_ENV"] ?? "").trim();
45
+ if (!env || quiet.has(env.toLowerCase())) return null;
46
+
47
+ const tone = tones[env.toLowerCase()] ?? "bg-amber-500 text-black";
48
+ return (
49
+ <div
50
+ // Fixed rather than in flow: a strip that pushes the layout down would
51
+ // shift every page by a few pixels between environments, which is its
52
+ // own kind of confusing.
53
+ class={`pointer-events-none fixed inset-x-0 top-0 z-50 flex h-1.5 items-center justify-center ${tone}`}
54
+ >
55
+ <span
56
+ class={`absolute top-1.5 rounded-b px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider ${tone}`}
57
+ >
58
+ {env}
59
+ </span>
60
+ </div>
61
+ );
62
+ };
63
+ }
@@ -0,0 +1,124 @@
1
+ /** @jsxImportSource @zerotal/flow */
2
+ // Inline outline icons (Heroicons-style, 24×24, stroke=currentColor) so the admin
3
+ // has zero icon-font/runtime dependency. Add a key here to expose a new icon.
4
+
5
+ import type { HtmlNode } from "@zerotal/flow";
6
+
7
+ /** Path `d` data per icon name. */
8
+ const PATHS: Record<string, string[]> = {
9
+ home: [
10
+ "M2.25 12l8.954-8.955a1.5 1.5 0 012.122 0L22.5 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75",
11
+ ],
12
+ collection: ["M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"],
13
+ "layout-grid": [
14
+ "M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25A2.25 2.25 0 0113.5 8.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z",
15
+ ],
16
+ users: [
17
+ "M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z",
18
+ ],
19
+ shield: [
20
+ "M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z",
21
+ ],
22
+ database: [
23
+ "M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125",
24
+ ],
25
+ document: [
26
+ "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z",
27
+ ],
28
+ search: ["M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"],
29
+ menu: ["M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"],
30
+ sun: [
31
+ "M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z",
32
+ ],
33
+ moon: [
34
+ "M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z",
35
+ ],
36
+ "chevron-left": ["M15.75 19.5L8.25 12l7.5-7.5"],
37
+ "chevron-right": ["M8.25 4.5l7.5 7.5-7.5 7.5"],
38
+ logout: [
39
+ "M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75",
40
+ ],
41
+ eye: [
42
+ "M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z",
43
+ "M15 12a3 3 0 11-6 0 3 3 0 016 0z",
44
+ ],
45
+ trash: [
46
+ "M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0",
47
+ ],
48
+ "check-circle": ["M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"],
49
+ "x-circle": ["M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"],
50
+ copy: [
51
+ "M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75",
52
+ ],
53
+ "chevron-down": ["M19.5 8.25l-7.5 7.5-7.5-7.5"],
54
+ "chevron-up": ["M4.5 15.75l7.5-7.5 7.5 7.5"],
55
+ mail: [
56
+ "M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75",
57
+ ],
58
+ calendar: [
59
+ "M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5m-9-6h.008v.008H12v-.008z",
60
+ ],
61
+ key: [
62
+ "M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z",
63
+ ],
64
+ plus: ["M12 4.5v15m7.5-7.5h-15"],
65
+ pencil: [
66
+ "M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125",
67
+ ],
68
+ undo: ["M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"],
69
+ bell: [
70
+ "M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0",
71
+ ],
72
+ chat: [
73
+ "M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.184-4.183a1.14 1.14 0 01.778-.332 48.294 48.294 0 005.83-.498c1.585-.233 2.708-1.626 2.708-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z",
74
+ ],
75
+ folder: [
76
+ "M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z",
77
+ ],
78
+ download: [
79
+ "M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3",
80
+ ],
81
+ upload: [
82
+ "M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 7.5L12 3m0 0l4.5 4.5M12 3v13.5",
83
+ ],
84
+ filter: [
85
+ "M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z",
86
+ ],
87
+ duplicate: [
88
+ "M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75",
89
+ ],
90
+ "dots-horizontal": [
91
+ "M6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z",
92
+ ],
93
+ inbox: [
94
+ "M2.25 13.5h3.86a2.25 2.25 0 012.012 1.244l.256.512a2.25 2.25 0 002.013 1.244h3.218a2.25 2.25 0 002.013-1.244l.256-.512a2.25 2.25 0 012.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 00-2.15-1.588H6.911a2.25 2.25 0 00-2.15 1.588L2.35 13.177a2.25 2.25 0 00-.1.661z",
95
+ ],
96
+ image: [
97
+ "M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z",
98
+ ],
99
+ x: ["M6 18L18 6M6 6l12 12"],
100
+ check: ["M4.5 12.75l6 6 9-13.5"],
101
+ };
102
+
103
+ export interface IconProps {
104
+ name: string;
105
+ class?: string;
106
+ }
107
+
108
+ export function Icon({ name, class: cls }: IconProps): HtmlNode {
109
+ const paths = PATHS[name] ?? PATHS["collection"]!;
110
+ return (
111
+ <svg
112
+ viewBox="0 0 24 24"
113
+ fill="none"
114
+ stroke="currentColor"
115
+ stroke-width="1.6"
116
+ class={cls ?? "h-5 w-5"}
117
+ aria-hidden="true"
118
+ >
119
+ {paths.map((d) => (
120
+ <path d={d} stroke-linecap="round" stroke-linejoin="round" />
121
+ ))}
122
+ </svg>
123
+ );
124
+ }