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 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 (sidebar, breadcrumb, iframe)
154
+ ## Dashboard
155
155
 
156
- Every `rack()` call registers its path, metadata and operations in a
157
- registry (`listRacks()` / `clearRacks()` from `elysia-rack`). The default
158
- `/dashboard` page builds a sidebar (grouped by `metadata.group`, ordered
159
- by `metadata.order`, skipping `hidden`), a breadcrumb
160
- (Dashboard / Group / Label), and shows the selected resource's `GET`
161
- panel inside an `<iframe>`:
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, pages, reactPlugin } from "elysia-rack/react";
186
+ import { page, reactPlugin } from "elysia-rack/react";
165
187
 
166
188
  const app = new Elysia()
167
- .use(reactPlugin({ pages }))
189
+ .use(reactPlugin())
168
190
  .use(rack("/catalog/products", { model, metadata: { ... } }))
169
- .get("/", ({ query }) =>
170
- page("/dashboard", {
171
- name: "My Shop",
172
- resource: typeof query.resource === "string" ? query.resource : undefined,
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
- Sidebar links navigate with `?resource=<id>` (full reload, no JavaScript
178
- needed). Without a selection the first registered resource is shown.
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
- React panel rendering via the `./react` subpath:
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 { page, pages, reactPlugin } from "elysia-rack/react";
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({ pages }))
187
- .get("/", () => page("/dashboard", { name: "Ada" }));
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
- ## Panel assets (Vite)
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
- CSS, Tailwind and browser JavaScript go through Vite (`vite.config.ts`,
193
- `bun run build:assets`):
400
+ Static assets are served under `/__rack/*`:
194
401
 
195
- - `src/react/page/panel.css` — stylesheet entry (Tailwind v4) → `dist/panel.css`
196
- - `src/react/page/client/theme.ts` — dark/light toggle → `dist/panel-theme.js`
197
- - `src/react/page/client/panel.ts` — live query + actions → `dist/panel-app.js`
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`, served by `reactPlugin` from `dist/` (run the
202
- build before `dev`; CI and `prepublishOnly` already chain it).
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, asset build, test, build, tag-vs-version check, `npm publish --provenance`.
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
+ };