elysia-rack 0.1.1 → 0.2.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 +237 -31
- package/dist/Dashboard-2j561p7c.js +225 -0
- package/dist/Dashboard-5mt9hnbw.js +252 -0
- package/dist/Dashboard-s8nfyf7s.js +39 -0
- package/dist/Panel-3yrk3krj.js +914 -0
- package/dist/Panel-b7jhgvnm.js +916 -0
- package/dist/Panel-pkvqf5v8.js +912 -0
- package/dist/app.html +14 -0
- package/dist/index-4wxvmd8a.js +65 -0
- package/dist/index-tgk4jt4d.js +63 -0
- package/dist/panel-app.js +498 -1
- package/dist/panel-theme.js +38 -1
- package/dist/panel.css +1 -1
- package/dist/rack/dashboard.d.ts +71 -0
- package/dist/rack/index.d.ts +2 -1
- package/dist/rack/index.js +40 -4
- package/dist/rack/registry.d.ts +16 -0
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.js +144 -16
- package/dist/react/page/Dashboard.d.ts +0 -3
- package/dist/react/page/components/Sidebar.d.ts +1 -0
- package/dist/react/types.d.ts +15 -1
- package/package.json +5 -9
package/README.md
CHANGED
|
@@ -151,55 +151,261 @@ Conventions:
|
|
|
151
151
|
reads skip soft-deleted rows.
|
|
152
152
|
- `settings.returning: false` omits `data` from mutation responses.
|
|
153
153
|
|
|
154
|
-
## Dashboard
|
|
154
|
+
## Dashboard
|
|
155
155
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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.
|
|
157
|
+
|
|
158
|
+
Recommended setup:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { dashboard, rack } from "elysia-rack";
|
|
162
|
+
import { reactPlugin } from "elysia-rack/react";
|
|
163
|
+
|
|
164
|
+
const app = new Elysia()
|
|
165
|
+
.use(reactPlugin()) // pages are optional — defaults to dashboard + panel
|
|
166
|
+
.use(dashboard({ title: "My Panel", path: "/" })) // → Coming Soon at /
|
|
167
|
+
.use(rack("/catalog/products", { model, metadata: { id: "products", group: "Catalog", order: 1 } }))
|
|
168
|
+
.use(rack("/catalog/variants", { model, metadata: { id: "variants", group: "Catalog", order: 2, parent: "products" } }));
|
|
169
|
+
// → Panel at /catalog/products now shows sidebar with Catalog > Products > Variants
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Tree metadata
|
|
173
|
+
|
|
174
|
+
`metadata.parent` builds a hierarchy. Children are rendered nested under their parent in `panel`’s sidebar and kept ordered by `order` then label.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
rack("/catalog/products", { metadata: { id: "products", group: "Catalog", order: 1 } });
|
|
178
|
+
rack("/catalog/variants", { metadata: { id: "variants", parent: "products", group: "Catalog", order: 2 } });
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Helpers: `listRacks()`, `getRack(id)`, `getRackTree()`, `flatRackTree()`, `buildRackTree(racks)`, `clearRacks()` from `elysia-rack`.
|
|
182
|
+
|
|
183
|
+
You can still render the dashboard manually:
|
|
162
184
|
|
|
163
185
|
```ts
|
|
164
|
-
import { page,
|
|
186
|
+
import { page, reactPlugin } from "elysia-rack/react";
|
|
165
187
|
|
|
166
188
|
const app = new Elysia()
|
|
167
|
-
.use(reactPlugin(
|
|
189
|
+
.use(reactPlugin())
|
|
168
190
|
.use(rack("/catalog/products", { model, metadata: { ... } }))
|
|
169
|
-
.get("/", ({
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
191
|
+
.get("/", () => page("/dashboard", { name: "My Shop" }));
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`panel` now handles the sidebar + breadcrumb. Each panel loads the full tree via `getRackTree()`, highlights the active resource (`props.resource`), and links via `href: node.path`. No iframe needed.
|
|
195
|
+
|
|
196
|
+
## Tutorial — make it yours
|
|
197
|
+
|
|
198
|
+
This guide walks you through `rack()`, `dashboard()` and `react()` the way you would build a real app. Copy, run, tweak.
|
|
199
|
+
|
|
200
|
+
### 1) `rack()` — describe a resource, get an API + panel
|
|
201
|
+
|
|
202
|
+
Start with the only required thing: which table/model to use.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import { rack } from "elysia-rack";
|
|
206
|
+
|
|
207
|
+
rack("/catalog/products", {
|
|
208
|
+
model: { drizzle: { db, table: products } } // or { prisma: { client: prisma, model: prisma.product } }
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
You already get `GET /`, `QUERY /data`, `POST /`, `PUT/PATCH/DELETE /:id` and a panel. Now make it yours step by step.
|
|
213
|
+
|
|
214
|
+
**Give it a name and a place in the menu.** `metadata` is what the dashboard reads from memory.
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
rack("/catalog/products", {
|
|
218
|
+
model: { drizzle: { db, table: products } },
|
|
219
|
+
metadata: {
|
|
220
|
+
id: "products", // unique id, defaults to path
|
|
221
|
+
label: "Product", // singular
|
|
222
|
+
pluralLabel: "Products", // shown in sidebar
|
|
223
|
+
group: "Catalog", // sidebar group
|
|
224
|
+
order: 1, // smaller = higher
|
|
225
|
+
icon: "📦",
|
|
226
|
+
parent: "catalog-root", // nest under another id
|
|
227
|
+
hidden: false
|
|
228
|
+
}
|
|
229
|
+
});
|
|
175
230
|
```
|
|
176
231
|
|
|
177
|
-
|
|
178
|
-
|
|
232
|
+
**Decide what users can do.** Turn operations on/off. If you disable `create`, the “+ New” button disappears automatically.
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
rack("/catalog/products", {
|
|
236
|
+
model,
|
|
237
|
+
operations: { list: true, detail: true, create: true, replace: true, update: true, delete: false }
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
**Shape the list experience.** Which fields can be searched, filtered, sorted? What’s the default?
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
rack("/catalog/products", {
|
|
245
|
+
model,
|
|
246
|
+
query: {
|
|
247
|
+
searchable: ["name", "sku"],
|
|
248
|
+
filterable: ["status"],
|
|
249
|
+
sortable: ["name", "price", "createdAt"],
|
|
250
|
+
defaultSort: { field: "createdAt", direction: "desc" },
|
|
251
|
+
pagination: { default: 20, max: 100 }
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
```
|
|
179
255
|
|
|
180
|
-
|
|
256
|
+
**Add safety.** Validation and authorization are per-operation. A string like `"products.delete"` checks the `x-permissions` header; a function gives you the full `Request`.
|
|
181
257
|
|
|
182
258
|
```ts
|
|
183
|
-
import {
|
|
259
|
+
import { t } from "elysia";
|
|
260
|
+
|
|
261
|
+
rack("/catalog/products", {
|
|
262
|
+
model,
|
|
263
|
+
validation: {
|
|
264
|
+
create: t.Object({ name: t.String({ minLength: 3 }) }),
|
|
265
|
+
update: t.Partial(t.Object({ name: t.String() })),
|
|
266
|
+
params: t.Object({ id: t.String() }),
|
|
267
|
+
query: t.Object({ search: t.Optional(t.String()) })
|
|
268
|
+
},
|
|
269
|
+
authorization: {
|
|
270
|
+
list: true,
|
|
271
|
+
delete: async ({ request }) => request.headers.get("x-api-key") === "secret"
|
|
272
|
+
// or: delete: "products.delete"
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Tweak the small things** when you need them:
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
rack("/catalog/products", {
|
|
281
|
+
model,
|
|
282
|
+
page: { enabled: true, path: "/panel" }, // change or disable GET / panel
|
|
283
|
+
settings: { primaryKey: "slug", softDelete: true, deletedAtField: "deletedAt", returning: true },
|
|
284
|
+
openapi: { tags: ["Catalog"], description: "Products", operations: { list: { summary: "List products" } } },
|
|
285
|
+
idempotency: { required: true, header: "Idempotency-Key", ttl: 3600, store: myRedisStore }
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
That’s the whole `rack()` surface. Start with `model`, then add `metadata` → `query` → `validation/authorization` as your app grows.
|
|
290
|
+
|
|
291
|
+
### 2) `dashboard()` — one line, now Coming Soon
|
|
292
|
+
|
|
293
|
+
`dashboard()` mounts `GET /` that renders a *Coming Soon* page. The tree you registered with `rack()` is not shown here — it lives in `panel`.
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
import { dashboard, rack } from "elysia-rack";
|
|
297
|
+
import { reactPlugin } from "elysia-rack/react";
|
|
184
298
|
|
|
185
299
|
const app = new Elysia()
|
|
186
|
-
.use(reactPlugin(
|
|
187
|
-
.
|
|
300
|
+
.use(reactPlugin()) // 1) UI engine — pages optional
|
|
301
|
+
.use(dashboard({ title: "My Shop", path: "/" })) // 2) Coming Soon at /
|
|
302
|
+
.use(rack("/catalog/products", { model, metadata: { id: "products", group: "Catalog", order: 1 } }))
|
|
303
|
+
.use(rack("/catalog/variants", { model, metadata: { id: "variants", parent: "products", group: "Catalog", order: 2 } }));
|
|
304
|
+
// visit /catalog/products → panel shows sidebar with Catalog > Products > Variants
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Options — all optional:
|
|
308
|
+
|
|
309
|
+
- `path` (default `"/"`) — where the dashboard lives
|
|
310
|
+
- `title` (default `"Panel"`) — header in the sidebar
|
|
311
|
+
- `pagePath` (default `"/dashboard"`) — which React page to render
|
|
312
|
+
|
|
313
|
+
Need it without the helper? The helper is just sugar for:
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
import { page } from "elysia-rack/react";
|
|
317
|
+
.get("/", ({ query }) => page("/dashboard", { name: "My Shop", resource: query.resource as string }))
|
|
188
318
|
```
|
|
189
319
|
|
|
190
|
-
|
|
320
|
+
Helpers to inspect the tree yourself: `listRacks()`, `getRack(id)`, `getRackTree()`, `flatRackTree()`, `buildRackTree(racks)`, `clearRacks()`.
|
|
321
|
+
|
|
322
|
+
### 3) `react()` — pages and look
|
|
323
|
+
|
|
324
|
+
`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.
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
import { pages, reactPlugin, page } from "elysia-rack/react";
|
|
328
|
+
|
|
329
|
+
// Option A: just use defaults
|
|
330
|
+
new Elysia().use(reactPlugin());
|
|
331
|
+
|
|
332
|
+
// Option B: extend defaults with your own page
|
|
333
|
+
const myPages = { "/hello": () => import("./Hello") };
|
|
334
|
+
new Elysia().use(reactPlugin({ pages: myPages })); // merged with defaults
|
|
335
|
+
|
|
336
|
+
// Option C: merge multiple registries — array will be merged
|
|
337
|
+
const extra = { "/admin": () => import("./Admin") };
|
|
338
|
+
new Elysia().use(reactPlugin({ pages: [pages, myPages, extra] }));
|
|
339
|
+
|
|
340
|
+
// With a style override
|
|
341
|
+
new Elysia().use(reactPlugin({
|
|
342
|
+
pages: myPages, // or [pages, myPages]
|
|
343
|
+
css: "./my-panel.css", // file path
|
|
344
|
+
// css: { path: "./my-panel.css" },
|
|
345
|
+
// css: { content: ":root{--color-primary:red}" },
|
|
346
|
+
// panelCss: "body{...}" // alias
|
|
347
|
+
})).get("/hello", () => page("/hello", { name: "Ada" }));
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
What each option does:
|
|
351
|
+
|
|
352
|
+
- `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.
|
|
353
|
+
- `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.
|
|
354
|
+
|
|
355
|
+
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-->`).
|
|
356
|
+
|
|
357
|
+
## HTML template
|
|
358
|
+
|
|
359
|
+
SSR uses `src/react/app.html` as the shell. It is read at runtime, cached,
|
|
360
|
+
and streamed with the React output.
|
|
361
|
+
|
|
362
|
+
```html
|
|
363
|
+
<!doctype html>
|
|
364
|
+
<html><head>
|
|
365
|
+
<title>{{title}}</title>
|
|
366
|
+
<link rel="stylesheet" href="/__rack/panel.css" />
|
|
367
|
+
<!--app-head-->
|
|
368
|
+
</head><body>
|
|
369
|
+
<!--app-->
|
|
370
|
+
</body></html>
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
- `{{title}}` is replaced (escaped) per page.
|
|
374
|
+
- `<!--app-->` (also `{{body}}`, `<!--app-html-->`) marks where the React stream is injected.
|
|
375
|
+
- Fallback shell is used if the file is missing. `dist/app.html` is a static copy of `src/react/app.html` included in the package.
|
|
376
|
+
|
|
377
|
+
Override the template by editing `src/react/app.html` (and `dist/app.html` for the published package).
|
|
378
|
+
|
|
379
|
+
## Panel CSS override
|
|
380
|
+
|
|
381
|
+
Override `panel.css` at `react()` config without forking:
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
import { reactPlugin } from "elysia-rack/react";
|
|
385
|
+
|
|
386
|
+
// file path (resolved from cwd/dist), raw string, or object — pages optional
|
|
387
|
+
reactPlugin({ css: "./my-panel.css" });
|
|
388
|
+
reactPlugin({ css: { path: "./my-panel.css" } });
|
|
389
|
+
reactPlugin({ css: { content: ":root{--color-primary:red}" } });
|
|
390
|
+
reactPlugin({ panelCss: "body{...}" }); // alias
|
|
391
|
+
// with custom pages: reactPlugin({ pages: myPages, css: "./my.css" })
|
|
392
|
+
// or array: reactPlugin({ pages: [pages, myPages], css: "./my.css" })
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
If a file path exists it is served; otherwise a raw CSS string is served.
|
|
396
|
+
Fallback is `dist/panel.css`.
|
|
397
|
+
|
|
398
|
+
## Panel assets
|
|
191
399
|
|
|
192
|
-
|
|
193
|
-
`bun run build:assets`):
|
|
400
|
+
Static assets are served under `/__rack/*`:
|
|
194
401
|
|
|
195
|
-
- `
|
|
196
|
-
- `
|
|
197
|
-
- `
|
|
198
|
-
(imports shared `components/href.ts` and `components/panelForm.ts`)
|
|
402
|
+
- `dist/panel.css` — panel stylesheet (prebuilt)
|
|
403
|
+
- `dist/panel-theme.js` — dark/light toggle
|
|
404
|
+
- `dist/panel-app.js` — live query + actions (imports `components/href.ts`, `components/panelForm.ts`)
|
|
199
405
|
|
|
200
406
|
Pages reference `/__rack/panel.css`, `/__rack/panel-theme.js` and
|
|
201
|
-
`/__rack/panel-app.js
|
|
202
|
-
|
|
407
|
+
`/__rack/panel-app.js` via `reactPlugin`. No build step required — assets are
|
|
408
|
+
static and included in the published package.
|
|
203
409
|
|
|
204
410
|
## Panel theme
|
|
205
411
|
|
|
@@ -224,7 +430,7 @@ touching React code.
|
|
|
224
430
|
1. One-time setup: npmjs.com → package → Settings → Trusted Publisher → GitHub Actions (`notoofly/elysia-rack`, workflow `publish.yml`).
|
|
225
431
|
2. Bump `version` in `package.json`.
|
|
226
432
|
3. Create a GitHub Release with tag `v<version>` (e.g. `v0.1.0`).
|
|
227
|
-
4. The `publish` workflow runs automatically: install, typecheck,
|
|
433
|
+
4. The `publish` workflow runs automatically: install, typecheck, test, build, tag-vs-version check, `npm publish --provenance`.
|
|
228
434
|
|
|
229
435
|
## Scripts
|
|
230
436
|
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildRackTree,
|
|
3
|
+
flatRackTree,
|
|
4
|
+
getRackTree
|
|
5
|
+
} from "./index-4wxvmd8a.js";
|
|
6
|
+
import"./index-37x76zdn.js";
|
|
7
|
+
|
|
8
|
+
// src/react/page/components/Breadcrumb.tsx
|
|
9
|
+
import { jsxDEV } from "react/jsx-dev-runtime";
|
|
10
|
+
function Breadcrumb({ trail }) {
|
|
11
|
+
return /* @__PURE__ */ jsxDEV("nav", {
|
|
12
|
+
"aria-label": "Breadcrumb",
|
|
13
|
+
className: "pb-3 text-sm",
|
|
14
|
+
children: /* @__PURE__ */ jsxDEV("ol", {
|
|
15
|
+
className: "flex flex-wrap items-center gap-1.5",
|
|
16
|
+
children: trail.map((item, i) => {
|
|
17
|
+
const last = i === trail.length - 1;
|
|
18
|
+
return /* @__PURE__ */ jsxDEV("li", {
|
|
19
|
+
className: "flex items-center gap-1.5",
|
|
20
|
+
children: [
|
|
21
|
+
i > 0 ? /* @__PURE__ */ jsxDEV("span", {
|
|
22
|
+
"aria-hidden": "true",
|
|
23
|
+
className: "text-text-muted",
|
|
24
|
+
children: "/"
|
|
25
|
+
}, undefined, false, undefined, this) : null,
|
|
26
|
+
item.href && !last ? /* @__PURE__ */ jsxDEV("a", {
|
|
27
|
+
href: item.href,
|
|
28
|
+
className: "text-text-muted hover:text-foreground",
|
|
29
|
+
children: item.label
|
|
30
|
+
}, undefined, false, undefined, this) : /* @__PURE__ */ jsxDEV("span", {
|
|
31
|
+
"aria-current": last ? "page" : undefined,
|
|
32
|
+
className: last ? "text-foreground font-semibold" : "text-text-muted",
|
|
33
|
+
children: item.label
|
|
34
|
+
}, undefined, false, undefined, this)
|
|
35
|
+
]
|
|
36
|
+
}, `${item.label}-${i}`, true, undefined, this);
|
|
37
|
+
})
|
|
38
|
+
}, undefined, false, undefined, this)
|
|
39
|
+
}, undefined, false, undefined, this);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/react/page/components/Sidebar.tsx
|
|
43
|
+
import { jsxDEV as jsxDEV2 } from "react/jsx-dev-runtime";
|
|
44
|
+
function SidebarItemView({ item, depth = 0 }) {
|
|
45
|
+
const hasChildren = item.children && item.children.length > 0;
|
|
46
|
+
return /* @__PURE__ */ jsxDEV2("li", {
|
|
47
|
+
children: [
|
|
48
|
+
/* @__PURE__ */ jsxDEV2("a", {
|
|
49
|
+
href: item.href,
|
|
50
|
+
"aria-current": item.active ? "page" : undefined,
|
|
51
|
+
className: item.active ? "bg-sidebar-primary text-sidebar-primary-foreground flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-semibold" : "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
|
52
|
+
style: depth > 0 ? { paddingLeft: `${8 + depth * 12}px` } : undefined,
|
|
53
|
+
children: [
|
|
54
|
+
item.icon ? /* @__PURE__ */ jsxDEV2("span", {
|
|
55
|
+
"aria-hidden": "true",
|
|
56
|
+
children: item.icon
|
|
57
|
+
}, undefined, false, undefined, this) : null,
|
|
58
|
+
/* @__PURE__ */ jsxDEV2("span", {
|
|
59
|
+
children: item.label
|
|
60
|
+
}, undefined, false, undefined, this)
|
|
61
|
+
]
|
|
62
|
+
}, undefined, true, undefined, this),
|
|
63
|
+
hasChildren ? /* @__PURE__ */ jsxDEV2("ul", {
|
|
64
|
+
className: "border-sidebar-border mt-0.5 ml-2 flex flex-col gap-0.5 border-l py-1 pl-2",
|
|
65
|
+
children: item.children.map((child) => /* @__PURE__ */ jsxDEV2(SidebarItemView, {
|
|
66
|
+
item: child,
|
|
67
|
+
depth: depth + 1
|
|
68
|
+
}, child.id, false, undefined, this))
|
|
69
|
+
}, undefined, false, undefined, this) : null
|
|
70
|
+
]
|
|
71
|
+
}, undefined, true, undefined, this);
|
|
72
|
+
}
|
|
73
|
+
function hasActive(items) {
|
|
74
|
+
for (const it of items) {
|
|
75
|
+
if (it.active)
|
|
76
|
+
return true;
|
|
77
|
+
if (it.children && hasActive(it.children))
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
function Sidebar({ title, groups }) {
|
|
83
|
+
return /* @__PURE__ */ jsxDEV2("aside", {
|
|
84
|
+
className: "bg-sidebar text-sidebar-foreground border-sidebar-border min-h-0 w-64 shrink-0 overflow-y-auto rounded-lg border",
|
|
85
|
+
children: [
|
|
86
|
+
/* @__PURE__ */ jsxDEV2("div", {
|
|
87
|
+
className: "border-sidebar-border border-b px-4 py-3",
|
|
88
|
+
children: /* @__PURE__ */ jsxDEV2("p", {
|
|
89
|
+
className: "font-koran text-xl font-black tracking-tight",
|
|
90
|
+
children: title
|
|
91
|
+
}, undefined, false, undefined, this)
|
|
92
|
+
}, undefined, false, undefined, this),
|
|
93
|
+
/* @__PURE__ */ jsxDEV2("nav", {
|
|
94
|
+
className: "flex flex-col gap-4 p-3",
|
|
95
|
+
children: groups.map((group) => /* @__PURE__ */ jsxDEV2("section", {
|
|
96
|
+
children: /* @__PURE__ */ jsxDEV2("details", {
|
|
97
|
+
className: "group",
|
|
98
|
+
open: hasActive(group.items) || undefined,
|
|
99
|
+
children: [
|
|
100
|
+
/* @__PURE__ */ jsxDEV2("summary", {
|
|
101
|
+
className: "font-koran border-sidebar-border flex cursor-pointer list-none items-center justify-between border-b px-2 pb-1 text-base font-bold tracking-tight [&::-webkit-details-marker]:hidden",
|
|
102
|
+
children: [
|
|
103
|
+
/* @__PURE__ */ jsxDEV2("span", {
|
|
104
|
+
children: group.name
|
|
105
|
+
}, undefined, false, undefined, this),
|
|
106
|
+
/* @__PURE__ */ jsxDEV2("span", {
|
|
107
|
+
"aria-hidden": "true",
|
|
108
|
+
className: "text-text-muted text-xs transition group-open:rotate-180",
|
|
109
|
+
children: "▾"
|
|
110
|
+
}, undefined, false, undefined, this)
|
|
111
|
+
]
|
|
112
|
+
}, undefined, true, undefined, this),
|
|
113
|
+
/* @__PURE__ */ jsxDEV2("ul", {
|
|
114
|
+
className: "border-sidebar-border mt-1 ml-2 flex flex-col gap-0.5 border-l py-1 pl-2",
|
|
115
|
+
children: group.items.map((item) => /* @__PURE__ */ jsxDEV2(SidebarItemView, {
|
|
116
|
+
item
|
|
117
|
+
}, item.id, false, undefined, this))
|
|
118
|
+
}, undefined, false, undefined, this)
|
|
119
|
+
]
|
|
120
|
+
}, undefined, true, undefined, this)
|
|
121
|
+
}, group.name, false, undefined, this))
|
|
122
|
+
}, undefined, false, undefined, this)
|
|
123
|
+
]
|
|
124
|
+
}, undefined, true, undefined, this);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/react/page/Dashboard.tsx
|
|
128
|
+
import { jsxDEV as jsxDEV3 } from "react/jsx-dev-runtime";
|
|
129
|
+
function itemLabel(r) {
|
|
130
|
+
return r.metadata.pluralLabel ?? r.metadata.label ?? r.metadata.id;
|
|
131
|
+
}
|
|
132
|
+
function buildGroups(racks, selectedId) {
|
|
133
|
+
const byGroup = new Map;
|
|
134
|
+
for (const r of racks) {
|
|
135
|
+
if (r.metadata.hidden)
|
|
136
|
+
continue;
|
|
137
|
+
const name = r.metadata.group ?? "General";
|
|
138
|
+
let group = byGroup.get(name);
|
|
139
|
+
if (!group) {
|
|
140
|
+
group = { name, items: [] };
|
|
141
|
+
byGroup.set(name, group);
|
|
142
|
+
}
|
|
143
|
+
group.items.push({
|
|
144
|
+
id: r.metadata.id,
|
|
145
|
+
label: itemLabel(r),
|
|
146
|
+
icon: r.metadata.icon,
|
|
147
|
+
href: `?resource=${encodeURIComponent(r.metadata.id)}`,
|
|
148
|
+
active: r.metadata.id === selectedId
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const groups = [...byGroup.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
152
|
+
for (const group of groups) {
|
|
153
|
+
const orderOf = (id) => racks.find((r) => r.metadata.id === id)?.metadata.order ?? 0;
|
|
154
|
+
group.items.sort((a, b) => orderOf(a.id) - orderOf(b.id) || a.label.localeCompare(b.label));
|
|
155
|
+
}
|
|
156
|
+
return groups;
|
|
157
|
+
}
|
|
158
|
+
function toSidebarItem(node, selectedId) {
|
|
159
|
+
return {
|
|
160
|
+
id: node.metadata.id,
|
|
161
|
+
label: itemLabel(node),
|
|
162
|
+
icon: node.metadata.icon,
|
|
163
|
+
href: `?resource=${encodeURIComponent(node.metadata.id)}`,
|
|
164
|
+
active: node.metadata.id === selectedId,
|
|
165
|
+
children: node.children.filter((c) => !c.metadata.hidden).map((c) => toSidebarItem(c, selectedId))
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function buildGroupsFromTree(tree, selectedId) {
|
|
169
|
+
const byGroup = new Map;
|
|
170
|
+
for (const root of tree) {
|
|
171
|
+
if (root.metadata.hidden)
|
|
172
|
+
continue;
|
|
173
|
+
const name = root.metadata.group ?? "General";
|
|
174
|
+
let group = byGroup.get(name);
|
|
175
|
+
if (!group) {
|
|
176
|
+
group = { name, items: [] };
|
|
177
|
+
byGroup.set(name, group);
|
|
178
|
+
}
|
|
179
|
+
group.items.push(toSidebarItem(root, selectedId));
|
|
180
|
+
}
|
|
181
|
+
return [...byGroup.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
182
|
+
}
|
|
183
|
+
async function Dashboard(props) {
|
|
184
|
+
const tree = props.racks !== undefined ? buildRackTree(props.racks.filter((r) => !r.metadata.hidden)) : getRackTree().filter((r) => !r.metadata.hidden);
|
|
185
|
+
const flat = props.racks !== undefined ? props.racks.filter((r) => !r.metadata.hidden) : flatRackTree(tree).filter((r) => !r.metadata.hidden);
|
|
186
|
+
const selected = flat.find((r) => r.metadata.id === props.resource) ?? flat[0];
|
|
187
|
+
const groups = tree.length ? buildGroupsFromTree(tree, selected?.metadata.id) : buildGroups(flat, selected?.metadata.id);
|
|
188
|
+
const trail = [
|
|
189
|
+
{ label: "Dashboard", href: "?" },
|
|
190
|
+
...selected?.metadata.group ? [{ label: selected.metadata.group }] : [],
|
|
191
|
+
...selected ? [{ label: itemLabel(selected) }] : []
|
|
192
|
+
];
|
|
193
|
+
return /* @__PURE__ */ jsxDEV3("div", {
|
|
194
|
+
className: "koran-paper font-koran-body text-foreground flex flex-col min-h-screen",
|
|
195
|
+
children: /* @__PURE__ */ jsxDEV3("div", {
|
|
196
|
+
className: "flex min-h-0 flex-1 gap-6 p-4 sm:p-6 h-full",
|
|
197
|
+
children: [
|
|
198
|
+
/* @__PURE__ */ jsxDEV3(Sidebar, {
|
|
199
|
+
title: props.name ?? "Panel",
|
|
200
|
+
groups
|
|
201
|
+
}, undefined, false, undefined, this),
|
|
202
|
+
/* @__PURE__ */ jsxDEV3("main", {
|
|
203
|
+
className: "flex min-w-0 flex-1 flex-col",
|
|
204
|
+
children: [
|
|
205
|
+
/* @__PURE__ */ jsxDEV3(Breadcrumb, {
|
|
206
|
+
trail
|
|
207
|
+
}, undefined, false, undefined, this),
|
|
208
|
+
selected ? /* @__PURE__ */ jsxDEV3("iframe", {
|
|
209
|
+
src: selected.path,
|
|
210
|
+
title: itemLabel(selected),
|
|
211
|
+
sandbox: "allow-scripts allow-same-origin allow-forms",
|
|
212
|
+
className: "bg-card min-h-0 w-full flex-1 rounded-lg"
|
|
213
|
+
}, undefined, false, undefined, this) : /* @__PURE__ */ jsxDEV3("p", {
|
|
214
|
+
className: "text-text-muted border-border rounded-lg border px-4 py-10 text-center",
|
|
215
|
+
children: "No resources registered. Mount a resource with rack() first."
|
|
216
|
+
}, undefined, false, undefined, this)
|
|
217
|
+
]
|
|
218
|
+
}, undefined, true, undefined, this)
|
|
219
|
+
]
|
|
220
|
+
}, undefined, true, undefined, this)
|
|
221
|
+
}, undefined, false, undefined, this);
|
|
222
|
+
}
|
|
223
|
+
export {
|
|
224
|
+
Dashboard as default
|
|
225
|
+
};
|