elysia-rack 0.2.0 → 0.3.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/README.md CHANGED
@@ -1,7 +1,15 @@
1
1
  # elysia-rack
2
2
 
3
+ [![Ko-fi Donate](https://img.shields.io/badge/Ko--fi-Donate-ff5e5b?logo=ko-fi&logoColor=white)](https://ko-fi.com/notoofly_manu) [![npm](https://img.shields.io/npm/v/elysia-rack)](https://www.npmjs.com/package/elysia-rack)
4
+
3
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+.
4
6
 
7
+ Full usage guide: [docs/USAGE.md](docs/USAGE.md).
8
+
9
+ ## Documentation
10
+
11
+ End-user documentation covers installation, configuration, daily usage, and troubleshooting: [docs/index.md](docs/index.md).
12
+
5
13
  ## Install
6
14
 
7
15
  ```bash
@@ -83,7 +91,7 @@ work through plain `GET` navigation without JavaScript).
83
91
 
84
92
  The panel ships create / edit / delete UI driven by the same API:
85
93
 
86
- - `+ New` opens a create dialog (fields derived from row columns,
94
+ - `New record` opens a create dialog (fields derived from row columns,
87
95
  primary key optional). Submit sends `POST /` with an auto-generated
88
96
  `Idempotency-Key`. Tables without known columns fall back to a raw
89
97
  JSON body field.
@@ -145,7 +153,7 @@ Conventions:
145
153
  - String route ids are coerced to numbers for integer columns
146
154
  (e.g. `serial`); anything else passes through. Malformed ids resolve
147
155
  to `404`, missing rows to `404`.
148
- - `searchable` fields use SQL `LIKE` (case-sensitivity follows the DB).
156
+ - `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.
149
157
  - `settings.softDelete: true` writes a timestamp to `deletedAt`
150
158
  (override with `settings.deletedAtField`) instead of deleting, and
151
159
  reads skip soft-deleted rows.
@@ -153,7 +161,7 @@ Conventions:
153
161
 
154
162
  ## Dashboard
155
163
 
156
- `dashboard()` is now a simple *Coming Soon* placeholder. The real navigation lives in `panel` — each `rack()` panel shows a sidebar with the entire tree.
164
+ `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.
157
165
 
158
166
  Recommended setup:
159
167
 
@@ -163,7 +171,7 @@ import { reactPlugin } from "elysia-rack/react";
163
171
 
164
172
  const app = new Elysia()
165
173
  .use(reactPlugin()) // pages are optional — defaults to dashboard + panel
166
- .use(dashboard({ title: "My Panel", path: "/" })) // → Coming Soon at /
174
+ .use(dashboard({ title: "My Panel", path: "/" })) // → Dashboard at /
167
175
  .use(rack("/catalog/products", { model, metadata: { id: "products", group: "Catalog", order: 1 } }))
168
176
  .use(rack("/catalog/variants", { model, metadata: { id: "variants", group: "Catalog", order: 2, parent: "products" } }));
169
177
  // → Panel at /catalog/products now shows sidebar with Catalog > Products > Variants
@@ -180,6 +188,9 @@ rack("/catalog/variants", { metadata: { id: "variants", parent: "products", grou
180
188
 
181
189
  Helpers: `listRacks()`, `getRack(id)`, `getRackTree()`, `flatRackTree()`, `buildRackTree(racks)`, `clearRacks()` from `elysia-rack`.
182
190
 
191
+ ### Donate — disable on website
192
+ Donate link (Ko-fi) tampil di Dashboard & Panel footer. Disable via `dashboard({ donate: false })` atau global `reactPlugin({ donate: false })` (default `true`).
193
+
183
194
  You can still render the dashboard manually:
184
195
 
185
196
  ```ts
@@ -222,14 +233,14 @@ rack("/catalog/products", {
222
233
  pluralLabel: "Products", // shown in sidebar
223
234
  group: "Catalog", // sidebar group
224
235
  order: 1, // smaller = higher
225
- icon: "📦",
236
+ icon: "package", // Lucide icon name (kebab-case or PascalCase)
226
237
  parent: "catalog-root", // nest under another id
227
238
  hidden: false
228
239
  }
229
240
  });
230
241
  ```
231
242
 
232
- **Decide what users can do.** Turn operations on/off. If you disable `create`, the “+ New” button disappears automatically.
243
+ **Decide what users can do.** Turn operations on/off. If you disable `create`, the “New record” button disappears automatically.
233
244
 
234
245
  ```ts
235
246
  rack("/catalog/products", {
@@ -288,9 +299,9 @@ rack("/catalog/products", {
288
299
 
289
300
  That’s the whole `rack()` surface. Start with `model`, then add `metadata` → `query` → `validation/authorization` as your app grows.
290
301
 
291
- ### 2) `dashboard()` — one line, now Coming Soon
302
+ ### 2) `dashboard()`: one line, navigation overview
292
303
 
293
- `dashboard()` mounts `GET /` that renders a *Coming Soon* page. The tree you registered with `rack()` is not shown here — it lives in `panel`.
304
+ `dashboard()` mounts `GET /` and renders a sidebar plus a table of every resource registered with `rack()`.
294
305
 
295
306
  ```ts
296
307
  import { dashboard, rack } from "elysia-rack";
@@ -298,7 +309,7 @@ import { reactPlugin } from "elysia-rack/react";
298
309
 
299
310
  const app = new Elysia()
300
311
  .use(reactPlugin()) // 1) UI engine — pages optional
301
- .use(dashboard({ title: "My Shop", path: "/" })) // 2) Coming Soon at /
312
+ .use(dashboard({ title: "My Shop", path: "/" })) // 2) Dashboard at /
302
313
  .use(rack("/catalog/products", { model, metadata: { id: "products", group: "Catalog", order: 1 } }))
303
314
  .use(rack("/catalog/variants", { model, metadata: { id: "variants", parent: "products", group: "Catalog", order: 2 } }));
304
315
  // visit /catalog/products → panel shows sidebar with Catalog > Products > Variants
@@ -399,13 +410,13 @@ Fallback is `dist/panel.css`.
399
410
 
400
411
  Static assets are served under `/__rack/*`:
401
412
 
402
- - `dist/panel.css` — panel stylesheet (prebuilt)
413
+ - `dist/panel.css` — panel stylesheet (built with Tailwind CSS)
403
414
  - `dist/panel-theme.js` — dark/light toggle
404
415
  - `dist/panel-app.js` — live query + actions (imports `components/href.ts`, `components/panelForm.ts`)
405
416
 
406
417
  Pages reference `/__rack/panel.css`, `/__rack/panel-theme.js` and
407
- `/__rack/panel-app.js` via `reactPlugin`. No build step required — assets are
408
- static and included in the published package.
418
+ `/__rack/panel-app.js` via `reactPlugin`. Run `bun run build` to produce
419
+ `dist/` (`build:css` compiles the stylesheet, `build:js` bundles the clients).
409
420
 
410
421
  ## Panel theme
411
422
 
@@ -415,9 +426,10 @@ static and included in the published package.
415
426
  chart, selection, focus, overlay, navigation, table, form, sidebar),
416
427
  shadcn/Tailwind compatible, mapped via `@theme inline`.
417
428
  - `theme/dark.css` — `.dark` overrides.
418
- - `theme/koran.css` — default newspaper theme (warm paper, serif
419
- headlines). Remove its import for the neutral look.
420
- - `theme/components.css` — newspaper component classes + base styles.
429
+ - `theme/rack.css` — default theme (black, white, dark blue, gold;
430
+ Poppins for UI, Chocolate Classical Sans for display headings).
431
+ Remove its import for the neutral look.
432
+ - `theme/components.css` — base styles, dialog reset, reduced motion.
421
433
 
422
434
  Dark mode flips automatically: stored choice (`localStorage`), else
423
435
  `prefers-color-scheme`. The `◐ Theme` button in the masthead toggles it.
@@ -442,6 +454,16 @@ bun run typecheck # tsc --noEmit
442
454
  bun run build # dist/ (js via bun build + .d.ts via tsc)
443
455
  ```
444
456
 
457
+ ## Donate
458
+
459
+ Support pengembangan elysia-rack — traktir kopi via Ko-fi:
460
+
461
+ [![Ko-fi](https://img.shields.io/badge/Ko--fi-Donate-ff5e5b?logo=ko-fi&logoColor=white)](https://ko-fi.com/notoofly_manu)
462
+
463
+ > https://ko-fi.com/notoofly_manu
464
+
465
+ Terima kasih sudah mendukung open-source!
466
+
445
467
  ## License
446
468
 
447
469
  MIT © notoofly
@@ -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
+ };