elysia-rack 0.2.2 → 0.4.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 (41) hide show
  1. package/README.md +37 -20
  2. package/dist/Dashboard-4wv82gwx.js +212 -0
  3. package/dist/Dashboard-jkvfzyk2.js +210 -0
  4. package/dist/Dashboard-zhg2jtq5.js +211 -0
  5. package/dist/{Panel-fbp3wrsw.js → Panel-1qkzh8t7.js} +355 -311
  6. package/dist/Panel-7zpqz8gy.js +782 -0
  7. package/dist/Panel-9mmh3tbh.js +790 -0
  8. package/dist/Panel-ba0qzknj.js +789 -0
  9. package/dist/Panel-e6jyf4rc.js +789 -0
  10. package/dist/index-0dwhzk3j.js +145 -0
  11. package/dist/{index-6v0ns1xn.js → index-b8sq8rbd.js} +1 -1
  12. package/dist/index-rg87f3v1.js +174 -0
  13. package/dist/panel-app.js +27 -11
  14. package/dist/panel.css +2 -1
  15. package/dist/rack/index.js +5 -5
  16. package/dist/rack/types/metadata.d.ts +2 -1
  17. package/dist/react/donate.d.ts +1 -1
  18. package/dist/react/index.js +40 -15
  19. package/dist/react/page/Panel.d.ts +1 -0
  20. package/dist/react/page/components/Icon.d.ts +10 -0
  21. package/dist/react/page/components/Masthead.d.ts +5 -4
  22. package/dist/react/render.d.ts +1 -1
  23. package/dist/react/types.d.ts +18 -2
  24. package/package.json +8 -3
  25. package/dist/Dashboard-0da8dvbv.js +0 -225
  26. package/dist/Dashboard-2j561p7c.js +0 -225
  27. package/dist/Dashboard-5mt9hnbw.js +0 -252
  28. package/dist/Dashboard-7gmx8c9y.js +0 -248
  29. package/dist/Dashboard-s8nfyf7s.js +0 -39
  30. package/dist/Dashboard-xkh2g9f7.js +0 -248
  31. package/dist/Dashboard-xpstr7fr.js +0 -172
  32. package/dist/Panel-3yrk3krj.js +0 -914
  33. package/dist/Panel-b7jhgvnm.js +0 -916
  34. package/dist/Panel-n0kstkyw.js +0 -735
  35. package/dist/Panel-pkvqf5v8.js +0 -912
  36. package/dist/Panel-wjxjtbmr.js +0 -753
  37. package/dist/Panel-za4av616.js +0 -753
  38. package/dist/index-h3zbq796.js +0 -39
  39. package/dist/index-tgk4jt4d.js +0 -63
  40. package/dist/index-xsfqad54.js +0 -187
  41. package/dist/index-zc7rgvmm.js +0 -13
package/README.md CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  Model-driven CRUD rack for [Elysia](https://elysiajs.com): declare a resource once, get REST + `QUERY` routes, validation, authorization, query engine, OpenAPI metadata, and a React panel — running on [Bun](https://bun.com) or Node.js 20+.
6
6
 
7
+ ## Documentation
8
+
9
+ End-user documentation covers installation, configuration, daily usage, and troubleshooting: [docs/index.md](docs/index.md).
10
+
7
11
  ## Install
8
12
 
9
13
  ```bash
@@ -85,7 +89,7 @@ work through plain `GET` navigation without JavaScript).
85
89
 
86
90
  The panel ships create / edit / delete UI driven by the same API:
87
91
 
88
- - `+ New` opens a create dialog (fields derived from row columns,
92
+ - `New record` opens a create dialog (fields derived from row columns,
89
93
  primary key optional). Submit sends `POST /` with an auto-generated
90
94
  `Idempotency-Key`. Tables without known columns fall back to a raw
91
95
  JSON body field.
@@ -147,7 +151,7 @@ Conventions:
147
151
  - String route ids are coerced to numbers for integer columns
148
152
  (e.g. `serial`); anything else passes through. Malformed ids resolve
149
153
  to `404`, missing rows to `404`.
150
- - `searchable` fields use SQL `LIKE` (case-sensitivity follows the DB).
154
+ - `searchable` fields use SQL `ILIKE` through the Drizzle adapter, so search requires a database that supports `ILIKE` (for example PostgreSQL). SQLite search fails with a SQL syntax error.
151
155
  - `settings.softDelete: true` writes a timestamp to `deletedAt`
152
156
  (override with `settings.deletedAtField`) instead of deleting, and
153
157
  reads skip soft-deleted rows.
@@ -155,7 +159,7 @@ Conventions:
155
159
 
156
160
  ## Dashboard
157
161
 
158
- `dashboard()` is now a simple *Coming Soon* placeholder. The real navigation lives in `panel` — each `rack()` panel shows a sidebar with the entire tree.
162
+ `dashboard()` renders a navigation overview of every registered resource, with a sidebar and a resource table. Each `rack()` panel also shows a sidebar with the entire tree.
159
163
 
160
164
  Recommended setup:
161
165
 
@@ -165,7 +169,7 @@ import { reactPlugin } from "elysia-rack/react";
165
169
 
166
170
  const app = new Elysia()
167
171
  .use(reactPlugin()) // pages are optional — defaults to dashboard + panel
168
- .use(dashboard({ title: "My Panel", path: "/" })) // → Coming Soon at /
172
+ .use(dashboard({ title: "My Panel", path: "/" })) // → Dashboard at /
169
173
  .use(rack("/catalog/products", { model, metadata: { id: "products", group: "Catalog", order: 1 } }))
170
174
  .use(rack("/catalog/variants", { model, metadata: { id: "variants", group: "Catalog", order: 2, parent: "products" } }));
171
175
  // → Panel at /catalog/products now shows sidebar with Catalog > Products > Variants
@@ -227,14 +231,14 @@ rack("/catalog/products", {
227
231
  pluralLabel: "Products", // shown in sidebar
228
232
  group: "Catalog", // sidebar group
229
233
  order: 1, // smaller = higher
230
- icon: "📦",
234
+ icon: "package", // Lucide icon name (kebab-case or PascalCase)
231
235
  parent: "catalog-root", // nest under another id
232
236
  hidden: false
233
237
  }
234
238
  });
235
239
  ```
236
240
 
237
- **Decide what users can do.** Turn operations on/off. If you disable `create`, the “+ New” button disappears automatically.
241
+ **Decide what users can do.** Turn operations on/off. If you disable `create`, the “New record” button disappears automatically.
238
242
 
239
243
  ```ts
240
244
  rack("/catalog/products", {
@@ -293,9 +297,9 @@ rack("/catalog/products", {
293
297
 
294
298
  That’s the whole `rack()` surface. Start with `model`, then add `metadata` → `query` → `validation/authorization` as your app grows.
295
299
 
296
- ### 2) `dashboard()` — one line, now Coming Soon
300
+ ### 2) `dashboard()`: one line, navigation overview
297
301
 
298
- `dashboard()` mounts `GET /` that renders a *Coming Soon* page. The tree you registered with `rack()` is not shown here — it lives in `panel`.
302
+ `dashboard()` mounts `GET /` and renders a sidebar plus a table of every resource registered with `rack()`.
299
303
 
300
304
  ```ts
301
305
  import { dashboard, rack } from "elysia-rack";
@@ -303,7 +307,7 @@ import { reactPlugin } from "elysia-rack/react";
303
307
 
304
308
  const app = new Elysia()
305
309
  .use(reactPlugin()) // 1) UI engine — pages optional
306
- .use(dashboard({ title: "My Shop", path: "/" })) // 2) Coming Soon at /
310
+ .use(dashboard({ title: "My Shop", path: "/" })) // 2) Dashboard at /
307
311
  .use(rack("/catalog/products", { model, metadata: { id: "products", group: "Catalog", order: 1 } }))
308
312
  .use(rack("/catalog/variants", { model, metadata: { id: "variants", parent: "products", group: "Catalog", order: 2 } }));
309
313
  // visit /catalog/products → panel shows sidebar with Catalog > Products > Variants
@@ -329,17 +333,28 @@ Helpers to inspect the tree yourself: `listRacks()`, `getRack(id)`, `getRackTree
329
333
  `reactPlugin` is the bridge between Elysia and React. `pages` is now optional and mergeable — defaults already include `"/dashboard"` and `"/panel"`, and any custom pages you pass are merged in. You can pass a single registry or an array.
330
334
 
331
335
  ```ts
332
- import { pages, reactPlugin, page } from "elysia-rack/react";
336
+ import { lazyPage, pages, reactPlugin, page } from "elysia-rack/react";
333
337
 
334
338
  // Option A: just use defaults
335
339
  new Elysia().use(reactPlugin());
336
340
 
337
341
  // Option B: extend defaults with your own page
338
- const myPages = { "/hello": () => import("./Hello") };
342
+ const myPages = { "/hello": lazyPage(() => import("./Hello")) };
339
343
  new Elysia().use(reactPlugin({ pages: myPages })); // merged with defaults
340
344
 
341
- // Option C: merge multiple registries — array will be merged
342
- const extra = { "/admin": () => import("./Admin") };
345
+ // Option C: pass a component directly (no import wrapper)
346
+ import Hello from "./Hello";
347
+ new Elysia().use(reactPlugin({ pages: { "/hello": Hello } }));
348
+
349
+ // Option D: auto-mount a view — no page() call needed
350
+ new Elysia().use(
351
+ reactPlugin({
352
+ views: [{ path: "/hello", component: Hello, props: { name: "Ada" } }],
353
+ }),
354
+ );
355
+
356
+ // Option E: merge multiple registries — array will be merged
357
+ const extra = { "/admin": lazyPage(() => import("./Admin")) };
343
358
  new Elysia().use(reactPlugin({ pages: [pages, myPages, extra] }));
344
359
 
345
360
  // With a style override
@@ -354,7 +369,8 @@ new Elysia().use(reactPlugin({
354
369
 
355
370
  What each option does:
356
371
 
357
- - `pages` *(optional, `PageRegistry | PageRegistry[]`)* — registry of lazy page components. If omitted, defaults are used. If you pass one or an array, they are merged with defaults so `"/dashboard"` + `"/panel"` stay available. Later entries win on key collision.
372
+ - `pages` *(optional, `PageRegistry | PageRegistry[]`)* — registry of page components or loaders wrapped with `lazyPage(() => import(...))`. If omitted, defaults are used. If you pass one or an array, they are merged with defaults so `"/dashboard"` + `"/panel"` stay available. Later entries win on key collision.
373
+ - `views` *(optional, `ReactView[]`)* — components auto-mounted at their `path` with fixed `props`. Equivalent to registering a page and returning `page(path, props)` yourself.
358
374
  - `css` / `panelCss` — replace `dist/panel.css` served at `/__rack/panel.css`. If the string points to an existing file it’s used, otherwise it’s treated as raw CSS. Useful for quick theming without forking.
359
375
 
360
376
  Under the hood `reactPlugin` also serves `/__rack/panel-theme.js` and `/__rack/panel-app.js` and turns any `page()` return value into streamed HTML using `src/react/app.html` (`{{title}}` + `<!--app-->`).
@@ -404,13 +420,13 @@ Fallback is `dist/panel.css`.
404
420
 
405
421
  Static assets are served under `/__rack/*`:
406
422
 
407
- - `dist/panel.css` — panel stylesheet (prebuilt)
423
+ - `dist/panel.css` — panel stylesheet (built with Tailwind CSS)
408
424
  - `dist/panel-theme.js` — dark/light toggle
409
425
  - `dist/panel-app.js` — live query + actions (imports `components/href.ts`, `components/panelForm.ts`)
410
426
 
411
427
  Pages reference `/__rack/panel.css`, `/__rack/panel-theme.js` and
412
- `/__rack/panel-app.js` via `reactPlugin`. No build step required — assets are
413
- static and included in the published package.
428
+ `/__rack/panel-app.js` via `reactPlugin`. Run `bun run build` to produce
429
+ `dist/` (`build:css` compiles the stylesheet, `build:js` bundles the clients).
414
430
 
415
431
  ## Panel theme
416
432
 
@@ -420,9 +436,10 @@ static and included in the published package.
420
436
  chart, selection, focus, overlay, navigation, table, form, sidebar),
421
437
  shadcn/Tailwind compatible, mapped via `@theme inline`.
422
438
  - `theme/dark.css` — `.dark` overrides.
423
- - `theme/koran.css` — default newspaper theme (warm paper, serif
424
- headlines). Remove its import for the neutral look.
425
- - `theme/components.css` — newspaper component classes + base styles.
439
+ - `theme/rack.css` — default theme (black, white, dark blue, gold;
440
+ Poppins for UI, Chocolate Classical Sans for display headings).
441
+ Remove its import for the neutral look.
442
+ - `theme/components.css` — base styles, dialog reset, reduced motion.
426
443
 
427
444
  Dark mode flips automatically: stored choice (`localStorage`), else
428
445
  `prefers-color-scheme`. The `◐ Theme` button in the masthead toggles it.
@@ -0,0 +1,212 @@
1
+ import {
2
+ Masthead,
3
+ RackIcon,
4
+ Sidebar
5
+ } from "./index-rg87f3v1.js";
6
+ import {
7
+ getRackTree
8
+ } from "./index-4wxvmd8a.js";
9
+ import {
10
+ resolveDonateProp
11
+ } from "./index-b8sq8rbd.js";
12
+ import"./index-37x76zdn.js";
13
+
14
+ // src/react/page/Dashboard.tsx
15
+ import { Fragment } from "react";
16
+ import { jsxDEV, Fragment as Fragment2 } from "react/jsx-dev-runtime";
17
+ function itemLabel(r) {
18
+ return r.metadata.pluralLabel ?? r.metadata.label ?? r.metadata.id;
19
+ }
20
+ function toSidebarItem(node, activeId) {
21
+ return {
22
+ id: node.metadata.id,
23
+ label: itemLabel(node),
24
+ icon: node.metadata.icon,
25
+ href: node.path,
26
+ active: node.metadata.id === activeId,
27
+ children: node.children.filter((c) => !c.metadata.hidden).map((c) => toSidebarItem(c, activeId))
28
+ };
29
+ }
30
+ function buildGroupsFromTree(tree, activeId) {
31
+ const byGroup = new Map;
32
+ for (const root of tree) {
33
+ if (root.metadata.hidden)
34
+ continue;
35
+ const name = root.metadata.group ?? "General";
36
+ let group = byGroup.get(name);
37
+ if (!group) {
38
+ group = { name, items: [] };
39
+ byGroup.set(name, group);
40
+ }
41
+ group.items.push(toSidebarItem(root, activeId));
42
+ }
43
+ return [...byGroup.values()].sort((a, b) => a.name.localeCompare(b.name));
44
+ }
45
+ function countNodes(nodes) {
46
+ return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children), 0);
47
+ }
48
+ function operationsLabel(node) {
49
+ const op = node.operations;
50
+ const write = [op.create, op.update, op.replace, op.delete];
51
+ if (!write.some(Boolean))
52
+ return "Read only";
53
+ if (write.every(Boolean))
54
+ return "Full";
55
+ const labels = [];
56
+ if (op.create)
57
+ labels.push("Create");
58
+ if (op.update)
59
+ labels.push("Update");
60
+ if (op.replace)
61
+ labels.push("Replace");
62
+ if (op.delete)
63
+ labels.push("Delete");
64
+ return labels.join(", ");
65
+ }
66
+ function plural(count, word) {
67
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
68
+ }
69
+ function ResourceRow({ node, depth = 0 }) {
70
+ return /* @__PURE__ */ jsxDEV(Fragment2, {
71
+ children: [
72
+ /* @__PURE__ */ jsxDEV("tr", {
73
+ className: "hover:bg-table-row-hover",
74
+ children: [
75
+ /* @__PURE__ */ jsxDEV("td", {
76
+ className: "border-table-border border-t px-3 py-2.5",
77
+ children: /* @__PURE__ */ jsxDEV("a", {
78
+ href: node.path,
79
+ className: "text-link hover:text-link-hover font-medium hover:underline",
80
+ style: depth > 0 ? { paddingLeft: depth * 16 } : undefined,
81
+ children: [
82
+ node.metadata.icon ? /* @__PURE__ */ jsxDEV(RackIcon, {
83
+ name: node.metadata.icon,
84
+ size: 16,
85
+ className: "mr-1.5 inline size-4 align-text-bottom"
86
+ }, undefined, false, undefined, this) : null,
87
+ node.metadata.label ?? node.metadata.id
88
+ ]
89
+ }, undefined, true, undefined, this)
90
+ }, undefined, false, undefined, this),
91
+ /* @__PURE__ */ jsxDEV("td", {
92
+ className: "border-table-border text-text-muted border-t px-3 py-2.5 font-mono text-xs",
93
+ children: node.path
94
+ }, undefined, false, undefined, this),
95
+ /* @__PURE__ */ jsxDEV("td", {
96
+ className: "border-table-border text-text-muted border-t px-3 py-2.5 text-xs",
97
+ children: operationsLabel(node)
98
+ }, undefined, false, undefined, this)
99
+ ]
100
+ }, undefined, true, undefined, this),
101
+ node.children.filter((c) => !c.metadata.hidden).map((child) => /* @__PURE__ */ jsxDEV(ResourceRow, {
102
+ node: child,
103
+ depth: depth + 1
104
+ }, child.metadata.id, false, undefined, this))
105
+ ]
106
+ }, undefined, true, undefined, this);
107
+ }
108
+ async function Dashboard(props) {
109
+ const tree = getRackTree().filter((r) => !r.metadata.hidden);
110
+ const groups = buildGroupsFromTree(tree, props.resource);
111
+ const totalResources = countNodes(tree);
112
+ const totalGroups = groups.length;
113
+ const donate = resolveDonateProp(props.donate);
114
+ return /* @__PURE__ */ jsxDEV("div", {
115
+ className: "text-foreground flex min-h-screen flex-col lg:flex-row",
116
+ children: [
117
+ /* @__PURE__ */ jsxDEV(Sidebar, {
118
+ title: props.name ?? "Panel",
119
+ groups
120
+ }, undefined, false, undefined, this),
121
+ /* @__PURE__ */ jsxDEV("main", {
122
+ className: "min-w-0 flex-1",
123
+ children: /* @__PURE__ */ jsxDEV("div", {
124
+ className: "mx-auto w-full max-w-5xl px-4 py-4 sm:px-6",
125
+ children: [
126
+ /* @__PURE__ */ jsxDEV(Masthead, {
127
+ title: props.name ?? "Panel",
128
+ detail: `${plural(totalResources, "resource")} in ${plural(totalGroups, "group")}`
129
+ }, undefined, false, undefined, this),
130
+ /* @__PURE__ */ jsxDEV("div", {
131
+ className: "bg-table border-border overflow-x-auto rounded-lg border",
132
+ children: /* @__PURE__ */ jsxDEV("table", {
133
+ className: "w-full border-collapse text-left text-sm",
134
+ children: [
135
+ /* @__PURE__ */ jsxDEV("thead", {
136
+ children: /* @__PURE__ */ jsxDEV("tr", {
137
+ children: [
138
+ /* @__PURE__ */ jsxDEV("th", {
139
+ className: "border-border text-text-muted border-b px-3 py-2 text-xs font-medium",
140
+ children: "Resource"
141
+ }, undefined, false, undefined, this),
142
+ /* @__PURE__ */ jsxDEV("th", {
143
+ className: "border-border text-text-muted border-b px-3 py-2 text-xs font-medium",
144
+ children: "Path"
145
+ }, undefined, false, undefined, this),
146
+ /* @__PURE__ */ jsxDEV("th", {
147
+ className: "border-border text-text-muted border-b px-3 py-2 text-xs font-medium",
148
+ children: "Operations"
149
+ }, undefined, false, undefined, this)
150
+ ]
151
+ }, undefined, true, undefined, this)
152
+ }, undefined, false, undefined, this),
153
+ /* @__PURE__ */ jsxDEV("tbody", {
154
+ children: groups.length === 0 ? /* @__PURE__ */ jsxDEV("tr", {
155
+ children: /* @__PURE__ */ jsxDEV("td", {
156
+ colSpan: 3,
157
+ className: "text-text-muted px-4 py-10 text-center",
158
+ children: "No resources registered. Call rack() before rendering this page."
159
+ }, undefined, false, undefined, this)
160
+ }, undefined, false, undefined, this) : groups.map((group) => {
161
+ const nodes = tree.filter((r) => (r.metadata.group ?? "General") === group.name);
162
+ return /* @__PURE__ */ jsxDEV(Fragment, {
163
+ children: [
164
+ /* @__PURE__ */ jsxDEV("tr", {
165
+ className: "bg-surface-muted",
166
+ children: /* @__PURE__ */ jsxDEV("th", {
167
+ colSpan: 3,
168
+ className: "border-border text-text-muted border-b px-3 py-1.5 text-left text-xs font-medium",
169
+ children: [
170
+ group.name,
171
+ " ",
172
+ /* @__PURE__ */ jsxDEV("span", {
173
+ className: "text-text-subtle font-normal",
174
+ children: plural(countNodes(nodes), "resource")
175
+ }, undefined, false, undefined, this)
176
+ ]
177
+ }, undefined, true, undefined, this)
178
+ }, undefined, false, undefined, this),
179
+ nodes.map((node) => /* @__PURE__ */ jsxDEV(ResourceRow, {
180
+ node
181
+ }, node.metadata.id, false, undefined, this))
182
+ ]
183
+ }, group.name, true, undefined, this);
184
+ })
185
+ }, undefined, false, undefined, this)
186
+ ]
187
+ }, undefined, true, undefined, this)
188
+ }, undefined, false, undefined, this),
189
+ /* @__PURE__ */ jsxDEV("footer", {
190
+ className: "border-border text-text-muted mt-6 flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs",
191
+ children: [
192
+ /* @__PURE__ */ jsxDEV("span", {
193
+ children: "elysia-rack"
194
+ }, undefined, false, undefined, this),
195
+ donate.enabled ? /* @__PURE__ */ jsxDEV("a", {
196
+ href: donate.url,
197
+ target: "_blank",
198
+ rel: "noopener noreferrer",
199
+ className: "hover:text-foreground underline-offset-4 hover:underline",
200
+ children: donate.label
201
+ }, undefined, false, undefined, this) : null
202
+ ]
203
+ }, undefined, true, undefined, this)
204
+ ]
205
+ }, undefined, true, undefined, this)
206
+ }, undefined, false, undefined, this)
207
+ ]
208
+ }, undefined, true, undefined, this);
209
+ }
210
+ export {
211
+ Dashboard as default
212
+ };
@@ -0,0 +1,210 @@
1
+ import {
2
+ Masthead,
3
+ Sidebar
4
+ } from "./index-0dwhzk3j.js";
5
+ import {
6
+ getRackTree
7
+ } from "./index-4wxvmd8a.js";
8
+ import {
9
+ resolveDonateProp
10
+ } from "./index-b8sq8rbd.js";
11
+ import"./index-37x76zdn.js";
12
+
13
+ // src/react/page/Dashboard.tsx
14
+ import { Fragment } from "react";
15
+ import { jsxDEV, Fragment as Fragment2 } from "react/jsx-dev-runtime";
16
+ function itemLabel(r) {
17
+ return r.metadata.pluralLabel ?? r.metadata.label ?? r.metadata.id;
18
+ }
19
+ function toSidebarItem(node, activeId) {
20
+ return {
21
+ id: node.metadata.id,
22
+ label: itemLabel(node),
23
+ icon: node.metadata.icon,
24
+ href: node.path,
25
+ active: node.metadata.id === activeId,
26
+ children: node.children.filter((c) => !c.metadata.hidden).map((c) => toSidebarItem(c, activeId))
27
+ };
28
+ }
29
+ function buildGroupsFromTree(tree, activeId) {
30
+ const byGroup = new Map;
31
+ for (const root of tree) {
32
+ if (root.metadata.hidden)
33
+ continue;
34
+ const name = root.metadata.group ?? "General";
35
+ let group = byGroup.get(name);
36
+ if (!group) {
37
+ group = { name, items: [] };
38
+ byGroup.set(name, group);
39
+ }
40
+ group.items.push(toSidebarItem(root, activeId));
41
+ }
42
+ return [...byGroup.values()].sort((a, b) => a.name.localeCompare(b.name));
43
+ }
44
+ function countNodes(nodes) {
45
+ return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children), 0);
46
+ }
47
+ function operationsLabel(node) {
48
+ const op = node.operations;
49
+ const write = [op.create, op.update, op.replace, op.delete];
50
+ if (!write.some(Boolean))
51
+ return "Read only";
52
+ if (write.every(Boolean))
53
+ return "Full";
54
+ const labels = [];
55
+ if (op.create)
56
+ labels.push("Create");
57
+ if (op.update)
58
+ labels.push("Update");
59
+ if (op.replace)
60
+ labels.push("Replace");
61
+ if (op.delete)
62
+ labels.push("Delete");
63
+ return labels.join(", ");
64
+ }
65
+ function plural(count, word) {
66
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
67
+ }
68
+ function ResourceRow({ node, depth = 0 }) {
69
+ return /* @__PURE__ */ jsxDEV(Fragment2, {
70
+ children: [
71
+ /* @__PURE__ */ jsxDEV("tr", {
72
+ className: "hover:bg-table-row-hover",
73
+ children: [
74
+ /* @__PURE__ */ jsxDEV("td", {
75
+ className: "border-table-border border-t px-3 py-2.5",
76
+ children: /* @__PURE__ */ jsxDEV("a", {
77
+ href: node.path,
78
+ className: "text-link hover:text-link-hover font-medium hover:underline",
79
+ style: depth > 0 ? { paddingLeft: depth * 16 } : undefined,
80
+ children: [
81
+ node.metadata.icon ? /* @__PURE__ */ jsxDEV("span", {
82
+ "aria-hidden": "true",
83
+ className: "mr-1.5",
84
+ children: node.metadata.icon
85
+ }, undefined, false, undefined, this) : null,
86
+ node.metadata.label ?? node.metadata.id
87
+ ]
88
+ }, undefined, true, undefined, this)
89
+ }, undefined, false, undefined, this),
90
+ /* @__PURE__ */ jsxDEV("td", {
91
+ className: "border-table-border text-text-muted border-t px-3 py-2.5 font-mono text-xs",
92
+ children: node.path
93
+ }, undefined, false, undefined, this),
94
+ /* @__PURE__ */ jsxDEV("td", {
95
+ className: "border-table-border text-text-muted border-t px-3 py-2.5 text-xs",
96
+ children: operationsLabel(node)
97
+ }, undefined, false, undefined, this)
98
+ ]
99
+ }, undefined, true, undefined, this),
100
+ node.children.filter((c) => !c.metadata.hidden).map((child) => /* @__PURE__ */ jsxDEV(ResourceRow, {
101
+ node: child,
102
+ depth: depth + 1
103
+ }, child.metadata.id, false, undefined, this))
104
+ ]
105
+ }, undefined, true, undefined, this);
106
+ }
107
+ async function Dashboard(props) {
108
+ const tree = getRackTree().filter((r) => !r.metadata.hidden);
109
+ const groups = buildGroupsFromTree(tree, props.resource);
110
+ const totalResources = countNodes(tree);
111
+ const totalGroups = groups.length;
112
+ const donate = resolveDonateProp(props.donate);
113
+ return /* @__PURE__ */ jsxDEV("div", {
114
+ className: "text-foreground flex min-h-screen flex-col lg:flex-row",
115
+ children: [
116
+ /* @__PURE__ */ jsxDEV(Sidebar, {
117
+ title: props.name ?? "Panel",
118
+ groups
119
+ }, undefined, false, undefined, this),
120
+ /* @__PURE__ */ jsxDEV("main", {
121
+ className: "min-w-0 flex-1",
122
+ children: /* @__PURE__ */ jsxDEV("div", {
123
+ className: "mx-auto w-full max-w-5xl px-4 py-4 sm:px-6",
124
+ children: [
125
+ /* @__PURE__ */ jsxDEV(Masthead, {
126
+ title: props.name ?? "Panel",
127
+ detail: `${plural(totalResources, "resource")} in ${plural(totalGroups, "group")}`
128
+ }, undefined, false, undefined, this),
129
+ /* @__PURE__ */ jsxDEV("div", {
130
+ className: "bg-table border-border overflow-x-auto rounded-lg border",
131
+ children: /* @__PURE__ */ jsxDEV("table", {
132
+ className: "w-full border-collapse text-left text-sm",
133
+ children: [
134
+ /* @__PURE__ */ jsxDEV("thead", {
135
+ children: /* @__PURE__ */ jsxDEV("tr", {
136
+ children: [
137
+ /* @__PURE__ */ jsxDEV("th", {
138
+ className: "border-border text-text-muted border-b px-3 py-2 text-xs font-medium",
139
+ children: "Resource"
140
+ }, undefined, false, undefined, this),
141
+ /* @__PURE__ */ jsxDEV("th", {
142
+ className: "border-border text-text-muted border-b px-3 py-2 text-xs font-medium",
143
+ children: "Path"
144
+ }, undefined, false, undefined, this),
145
+ /* @__PURE__ */ jsxDEV("th", {
146
+ className: "border-border text-text-muted border-b px-3 py-2 text-xs font-medium",
147
+ children: "Operations"
148
+ }, undefined, false, undefined, this)
149
+ ]
150
+ }, undefined, true, undefined, this)
151
+ }, undefined, false, undefined, this),
152
+ /* @__PURE__ */ jsxDEV("tbody", {
153
+ children: groups.length === 0 ? /* @__PURE__ */ jsxDEV("tr", {
154
+ children: /* @__PURE__ */ jsxDEV("td", {
155
+ colSpan: 3,
156
+ className: "text-text-muted px-4 py-10 text-center",
157
+ children: "No resources registered. Call rack() before rendering this page."
158
+ }, undefined, false, undefined, this)
159
+ }, undefined, false, undefined, this) : groups.map((group) => {
160
+ const nodes = tree.filter((r) => (r.metadata.group ?? "General") === group.name);
161
+ return /* @__PURE__ */ jsxDEV(Fragment, {
162
+ children: [
163
+ /* @__PURE__ */ jsxDEV("tr", {
164
+ className: "bg-surface-muted",
165
+ children: /* @__PURE__ */ jsxDEV("th", {
166
+ colSpan: 3,
167
+ className: "border-border text-text-muted border-b px-3 py-1.5 text-left text-xs font-medium",
168
+ children: [
169
+ group.name,
170
+ /* @__PURE__ */ jsxDEV("span", {
171
+ className: "text-text-subtle ml-2 font-normal",
172
+ children: plural(countNodes(nodes), "resource")
173
+ }, undefined, false, undefined, this)
174
+ ]
175
+ }, undefined, true, undefined, this)
176
+ }, undefined, false, undefined, this),
177
+ nodes.map((node) => /* @__PURE__ */ jsxDEV(ResourceRow, {
178
+ node
179
+ }, node.metadata.id, false, undefined, this))
180
+ ]
181
+ }, group.name, true, undefined, this);
182
+ })
183
+ }, undefined, false, undefined, this)
184
+ ]
185
+ }, undefined, true, undefined, this)
186
+ }, undefined, false, undefined, this),
187
+ /* @__PURE__ */ jsxDEV("footer", {
188
+ className: "border-border text-text-muted mt-6 flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs",
189
+ children: [
190
+ /* @__PURE__ */ jsxDEV("span", {
191
+ children: "elysia-rack"
192
+ }, undefined, false, undefined, this),
193
+ donate.enabled ? /* @__PURE__ */ jsxDEV("a", {
194
+ href: donate.url,
195
+ target: "_blank",
196
+ rel: "noopener noreferrer",
197
+ className: "hover:text-foreground underline-offset-4 hover:underline",
198
+ children: donate.label
199
+ }, undefined, false, undefined, this) : null
200
+ ]
201
+ }, undefined, true, undefined, this)
202
+ ]
203
+ }, undefined, true, undefined, this)
204
+ }, undefined, false, undefined, this)
205
+ ]
206
+ }, undefined, true, undefined, this);
207
+ }
208
+ export {
209
+ Dashboard as default
210
+ };