elysia-rack 0.1.2 → 0.2.2

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,5 +1,7 @@
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
 
5
7
  ## Install
@@ -153,25 +155,25 @@ Conventions:
153
155
 
154
156
  ## Dashboard
155
157
 
156
- Recommended setup uses `react()`, `dashboard()`, and `rack()`. Each `rack()`
157
- stores its metadata tree in memory (registry) and `dashboard()` loads it at
158
- render time via `getRackTree()`.
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.
159
+
160
+ Recommended setup:
159
161
 
160
162
  ```ts
161
163
  import { dashboard, rack } from "elysia-rack";
162
- import { pages, reactPlugin } from "elysia-rack/react";
164
+ import { reactPlugin } from "elysia-rack/react";
163
165
 
164
166
  const app = new Elysia()
165
- .use(reactPlugin({ pages }))
166
- .use(dashboard({ title: "My Panel", path: "/" }))
167
+ .use(reactPlugin()) // pages are optional — defaults to dashboard + panel
168
+ .use(dashboard({ title: "My Panel", path: "/" })) // → Coming Soon at /
167
169
  .use(rack("/catalog/products", { model, metadata: { id: "products", group: "Catalog", order: 1 } }))
168
170
  .use(rack("/catalog/variants", { model, metadata: { id: "variants", group: "Catalog", order: 2, parent: "products" } }));
171
+ // → Panel at /catalog/products now shows sidebar with Catalog > Products > Variants
169
172
  ```
170
173
 
171
174
  ### Tree metadata
172
175
 
173
- `metadata.parent` builds a hierarchy. Children are rendered nested under their
174
- parent in the sidebar and kept ordered by `order` then label.
176
+ `metadata.parent` builds a hierarchy. Children are rendered nested under their parent in `panel`’s sidebar and kept ordered by `order` then label.
175
177
 
176
178
  ```ts
177
179
  rack("/catalog/products", { metadata: { id: "products", group: "Catalog", order: 1 } });
@@ -180,25 +182,182 @@ rack("/catalog/variants", { metadata: { id: "variants", parent: "products", grou
180
182
 
181
183
  Helpers: `listRacks()`, `getRack(id)`, `getRackTree()`, `flatRackTree()`, `buildRackTree(racks)`, `clearRacks()` from `elysia-rack`.
182
184
 
183
- The dashboard page itself can also be used manually:
185
+ ### Donate — disable on website
186
+ Donate link (Ko-fi) tampil di Dashboard & Panel footer. Disable via `dashboard({ donate: false })` atau global `reactPlugin({ donate: false })` (default `true`).
187
+
188
+ You can still render the dashboard manually:
184
189
 
185
190
  ```ts
186
- import { page, pages, reactPlugin } from "elysia-rack/react";
191
+ import { page, reactPlugin } from "elysia-rack/react";
187
192
 
188
193
  const app = new Elysia()
189
- .use(reactPlugin({ pages }))
194
+ .use(reactPlugin())
190
195
  .use(rack("/catalog/products", { model, metadata: { ... } }))
191
- .get("/", ({ query }) =>
192
- page("/dashboard", {
193
- name: "My Shop",
194
- resource: typeof query.resource === "string" ? query.resource : undefined,
195
- }),
196
- );
196
+ .get("/", () => page("/dashboard", { name: "My Shop" }));
197
+ ```
198
+
199
+ `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.
200
+
201
+ ## Tutorial — make it yours
202
+
203
+ This guide walks you through `rack()`, `dashboard()` and `react()` the way you would build a real app. Copy, run, tweak.
204
+
205
+ ### 1) `rack()` — describe a resource, get an API + panel
206
+
207
+ Start with the only required thing: which table/model to use.
208
+
209
+ ```ts
210
+ import { rack } from "elysia-rack";
211
+
212
+ rack("/catalog/products", {
213
+ model: { drizzle: { db, table: products } } // or { prisma: { client: prisma, model: prisma.product } }
214
+ });
215
+ ```
216
+
217
+ You already get `GET /`, `QUERY /data`, `POST /`, `PUT/PATCH/DELETE /:id` and a panel. Now make it yours step by step.
218
+
219
+ **Give it a name and a place in the menu.** `metadata` is what the dashboard reads from memory.
220
+
221
+ ```ts
222
+ rack("/catalog/products", {
223
+ model: { drizzle: { db, table: products } },
224
+ metadata: {
225
+ id: "products", // unique id, defaults to path
226
+ label: "Product", // singular
227
+ pluralLabel: "Products", // shown in sidebar
228
+ group: "Catalog", // sidebar group
229
+ order: 1, // smaller = higher
230
+ icon: "📦",
231
+ parent: "catalog-root", // nest under another id
232
+ hidden: false
233
+ }
234
+ });
235
+ ```
236
+
237
+ **Decide what users can do.** Turn operations on/off. If you disable `create`, the “+ New” button disappears automatically.
238
+
239
+ ```ts
240
+ rack("/catalog/products", {
241
+ model,
242
+ operations: { list: true, detail: true, create: true, replace: true, update: true, delete: false }
243
+ });
244
+ ```
245
+
246
+ **Shape the list experience.** Which fields can be searched, filtered, sorted? What’s the default?
247
+
248
+ ```ts
249
+ rack("/catalog/products", {
250
+ model,
251
+ query: {
252
+ searchable: ["name", "sku"],
253
+ filterable: ["status"],
254
+ sortable: ["name", "price", "createdAt"],
255
+ defaultSort: { field: "createdAt", direction: "desc" },
256
+ pagination: { default: 20, max: 100 }
257
+ }
258
+ });
197
259
  ```
198
260
 
199
- The sidebar groups by `metadata.group`, sorts by `order`, skips `hidden`,
200
- and shows the selected resource's panel inside an `<iframe>`. Links use
201
- `?resource=<id>` (no JavaScript required).
261
+ **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`.
262
+
263
+ ```ts
264
+ import { t } from "elysia";
265
+
266
+ rack("/catalog/products", {
267
+ model,
268
+ validation: {
269
+ create: t.Object({ name: t.String({ minLength: 3 }) }),
270
+ update: t.Partial(t.Object({ name: t.String() })),
271
+ params: t.Object({ id: t.String() }),
272
+ query: t.Object({ search: t.Optional(t.String()) })
273
+ },
274
+ authorization: {
275
+ list: true,
276
+ delete: async ({ request }) => request.headers.get("x-api-key") === "secret"
277
+ // or: delete: "products.delete"
278
+ }
279
+ });
280
+ ```
281
+
282
+ **Tweak the small things** when you need them:
283
+
284
+ ```ts
285
+ rack("/catalog/products", {
286
+ model,
287
+ page: { enabled: true, path: "/panel" }, // change or disable GET / panel
288
+ settings: { primaryKey: "slug", softDelete: true, deletedAtField: "deletedAt", returning: true },
289
+ openapi: { tags: ["Catalog"], description: "Products", operations: { list: { summary: "List products" } } },
290
+ idempotency: { required: true, header: "Idempotency-Key", ttl: 3600, store: myRedisStore }
291
+ });
292
+ ```
293
+
294
+ That’s the whole `rack()` surface. Start with `model`, then add `metadata` → `query` → `validation/authorization` as your app grows.
295
+
296
+ ### 2) `dashboard()` — one line, now Coming Soon
297
+
298
+ `dashboard()` mounts `GET /` that renders a *Coming Soon* page. The tree you registered with `rack()` is not shown here — it lives in `panel`.
299
+
300
+ ```ts
301
+ import { dashboard, rack } from "elysia-rack";
302
+ import { reactPlugin } from "elysia-rack/react";
303
+
304
+ const app = new Elysia()
305
+ .use(reactPlugin()) // 1) UI engine — pages optional
306
+ .use(dashboard({ title: "My Shop", path: "/" })) // 2) Coming Soon at /
307
+ .use(rack("/catalog/products", { model, metadata: { id: "products", group: "Catalog", order: 1 } }))
308
+ .use(rack("/catalog/variants", { model, metadata: { id: "variants", parent: "products", group: "Catalog", order: 2 } }));
309
+ // visit /catalog/products → panel shows sidebar with Catalog > Products > Variants
310
+ ```
311
+
312
+ Options — all optional:
313
+
314
+ - `path` (default `"/"`) — where the dashboard lives
315
+ - `title` (default `"Panel"`) — header in the sidebar
316
+ - `pagePath` (default `"/dashboard"`) — which React page to render
317
+
318
+ Need it without the helper? The helper is just sugar for:
319
+
320
+ ```ts
321
+ import { page } from "elysia-rack/react";
322
+ .get("/", ({ query }) => page("/dashboard", { name: "My Shop", resource: query.resource as string }))
323
+ ```
324
+
325
+ Helpers to inspect the tree yourself: `listRacks()`, `getRack(id)`, `getRackTree()`, `flatRackTree()`, `buildRackTree(racks)`, `clearRacks()`.
326
+
327
+ ### 3) `react()` — pages and look
328
+
329
+ `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
+
331
+ ```ts
332
+ import { pages, reactPlugin, page } from "elysia-rack/react";
333
+
334
+ // Option A: just use defaults
335
+ new Elysia().use(reactPlugin());
336
+
337
+ // Option B: extend defaults with your own page
338
+ const myPages = { "/hello": () => import("./Hello") };
339
+ new Elysia().use(reactPlugin({ pages: myPages })); // merged with defaults
340
+
341
+ // Option C: merge multiple registries — array will be merged
342
+ const extra = { "/admin": () => import("./Admin") };
343
+ new Elysia().use(reactPlugin({ pages: [pages, myPages, extra] }));
344
+
345
+ // With a style override
346
+ new Elysia().use(reactPlugin({
347
+ pages: myPages, // or [pages, myPages]
348
+ css: "./my-panel.css", // file path
349
+ // css: { path: "./my-panel.css" },
350
+ // css: { content: ":root{--color-primary:red}" },
351
+ // panelCss: "body{...}" // alias
352
+ })).get("/hello", () => page("/hello", { name: "Ada" }));
353
+ ```
354
+
355
+ What each option does:
356
+
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.
358
+ - `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
+
360
+ 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-->`).
202
361
 
203
362
  ## HTML template
204
363
 
@@ -227,13 +386,15 @@ Override the template by editing `src/react/app.html` (and `dist/app.html` for t
227
386
  Override `panel.css` at `react()` config without forking:
228
387
 
229
388
  ```ts
230
- import { pages, reactPlugin } from "elysia-rack/react";
231
-
232
- // file path (resolved from cwd/dist), raw string, or object
233
- reactPlugin({ pages, css: "./my-panel.css" });
234
- reactPlugin({ pages, css: { path: "./my-panel.css" } });
235
- reactPlugin({ pages, css: { content: ":root{--color-primary:red}" } });
236
- reactPlugin({ pages, panelCss: "body{...}" }); // alias
389
+ import { reactPlugin } from "elysia-rack/react";
390
+
391
+ // file path (resolved from cwd/dist), raw string, or object — pages optional
392
+ reactPlugin({ css: "./my-panel.css" });
393
+ reactPlugin({ css: { path: "./my-panel.css" } });
394
+ reactPlugin({ css: { content: ":root{--color-primary:red}" } });
395
+ reactPlugin({ panelCss: "body{...}" }); // alias
396
+ // with custom pages: reactPlugin({ pages: myPages, css: "./my.css" })
397
+ // or array: reactPlugin({ pages: [pages, myPages], css: "./my.css" })
237
398
  ```
238
399
 
239
400
  If a file path exists it is served; otherwise a raw CSS string is served.
@@ -286,6 +447,16 @@ bun run typecheck # tsc --noEmit
286
447
  bun run build # dist/ (js via bun build + .d.ts via tsc)
287
448
  ```
288
449
 
450
+ ## Donate
451
+
452
+ Support pengembangan elysia-rack — traktir kopi via Ko-fi:
453
+
454
+ [![Ko-fi](https://img.shields.io/badge/Ko--fi-Donate-ff5e5b?logo=ko-fi&logoColor=white)](https://ko-fi.com/notoofly_manu)
455
+
456
+ > https://ko-fi.com/notoofly_manu
457
+
458
+ Terima kasih sudah mendukung open-source!
459
+
289
460
  ## License
290
461
 
291
462
  MIT © notoofly
@@ -0,0 +1,225 @@
1
+ import {
2
+ Breadcrumb,
3
+ Masthead,
4
+ Sidebar
5
+ } from "./index-xsfqad54.js";
6
+ import {
7
+ getRackTree
8
+ } from "./index-4wxvmd8a.js";
9
+ import"./index-37x76zdn.js";
10
+
11
+ // src/react/page/Dashboard.tsx
12
+ import { jsxDEV } from "react/jsx-dev-runtime";
13
+ function itemLabel(r) {
14
+ return r.metadata.pluralLabel ?? r.metadata.label ?? r.metadata.id;
15
+ }
16
+ function toSidebarItem(node, activeId) {
17
+ return {
18
+ id: node.metadata.id,
19
+ label: itemLabel(node),
20
+ icon: node.metadata.icon,
21
+ href: node.path,
22
+ active: node.metadata.id === activeId,
23
+ children: node.children.filter((c) => !c.metadata.hidden).map((c) => toSidebarItem(c, activeId))
24
+ };
25
+ }
26
+ function buildGroupsFromTree(tree, activeId) {
27
+ const byGroup = new Map;
28
+ for (const root of tree) {
29
+ if (root.metadata.hidden)
30
+ continue;
31
+ const name = root.metadata.group ?? "General";
32
+ let group = byGroup.get(name);
33
+ if (!group) {
34
+ group = { name, items: [] };
35
+ byGroup.set(name, group);
36
+ }
37
+ group.items.push(toSidebarItem(root, activeId));
38
+ }
39
+ return [...byGroup.values()].sort((a, b) => a.name.localeCompare(b.name));
40
+ }
41
+ async function Dashboard(props) {
42
+ const tree = getRackTree().filter((r) => !r.metadata.hidden);
43
+ const groups = buildGroupsFromTree(tree, props.resource);
44
+ const totalResources = tree.length + tree.reduce((a, n) => a + n.children.length, 0);
45
+ const totalGroups = groups.length;
46
+ return /* @__PURE__ */ jsxDEV("div", {
47
+ className: "koran-paper font-koran-body text-foreground flex min-h-screen flex-col",
48
+ children: /* @__PURE__ */ jsxDEV("div", {
49
+ className: "flex flex-1 gap-6 p-4 sm:p-6",
50
+ children: [
51
+ /* @__PURE__ */ jsxDEV(Sidebar, {
52
+ title: props.name ?? "Panel",
53
+ groups
54
+ }, undefined, false, undefined, this),
55
+ /* @__PURE__ */ jsxDEV("main", {
56
+ className: "flex min-w-0 flex-1 flex-col",
57
+ children: [
58
+ /* @__PURE__ */ jsxDEV(Breadcrumb, {
59
+ trail: [{ label: "Dashboard" }]
60
+ }, undefined, false, undefined, this),
61
+ /* @__PURE__ */ jsxDEV("div", {
62
+ className: "mx-auto w-full max-w-5xl px-4 pb-10 sm:px-6",
63
+ children: [
64
+ /* @__PURE__ */ jsxDEV(Masthead, {
65
+ title: props.name ?? "ERP Demo",
66
+ group: "Dashboard",
67
+ resource: "dashboard",
68
+ total: totalResources
69
+ }, undefined, false, undefined, this),
70
+ /* @__PURE__ */ jsxDEV("div", {
71
+ className: "grid grid-cols-2 gap-3 pb-6 lg:grid-cols-4",
72
+ children: [
73
+ /* @__PURE__ */ jsxDEV("div", {
74
+ className: "bg-card border-border rounded-lg border px-4 py-3",
75
+ children: [
76
+ /* @__PURE__ */ jsxDEV("p", {
77
+ className: "text-text-muted text-xs font-semibold tracking-widest uppercase",
78
+ children: "Resources"
79
+ }, undefined, false, undefined, this),
80
+ /* @__PURE__ */ jsxDEV("p", {
81
+ className: "font-koran text-2xl font-black",
82
+ children: totalResources
83
+ }, undefined, false, undefined, this),
84
+ /* @__PURE__ */ jsxDEV("p", {
85
+ className: "text-text-muted text-xs",
86
+ children: [
87
+ totalGroups,
88
+ " groups"
89
+ ]
90
+ }, undefined, true, undefined, this)
91
+ ]
92
+ }, undefined, true, undefined, this),
93
+ /* @__PURE__ */ jsxDEV("div", {
94
+ className: "bg-card border-border rounded-lg border px-4 py-3",
95
+ children: [
96
+ /* @__PURE__ */ jsxDEV("p", {
97
+ className: "text-text-muted text-xs font-semibold tracking-widest uppercase",
98
+ children: "Modules"
99
+ }, undefined, false, undefined, this),
100
+ /* @__PURE__ */ jsxDEV("p", {
101
+ className: "font-koran text-2xl font-black",
102
+ children: totalGroups
103
+ }, undefined, false, undefined, this),
104
+ /* @__PURE__ */ jsxDEV("p", {
105
+ className: "text-text-muted text-xs",
106
+ children: "Core → Governance"
107
+ }, undefined, false, undefined, this)
108
+ ]
109
+ }, undefined, true, undefined, this),
110
+ /* @__PURE__ */ jsxDEV("div", {
111
+ className: "bg-card border-border rounded-lg border px-4 py-3",
112
+ children: [
113
+ /* @__PURE__ */ jsxDEV("p", {
114
+ className: "text-text-muted text-xs font-semibold tracking-widest uppercase",
115
+ children: "Status"
116
+ }, undefined, false, undefined, this),
117
+ /* @__PURE__ */ jsxDEV("p", {
118
+ className: "font-koran text-2xl font-black",
119
+ children: "Ready"
120
+ }, undefined, false, undefined, this),
121
+ /* @__PURE__ */ jsxDEV("p", {
122
+ className: "text-text-muted text-xs",
123
+ children: "PGlite in-memory"
124
+ }, undefined, false, undefined, this)
125
+ ]
126
+ }, undefined, true, undefined, this),
127
+ /* @__PURE__ */ jsxDEV("div", {
128
+ className: "bg-card border-border rounded-lg border px-4 py-3",
129
+ children: [
130
+ /* @__PURE__ */ jsxDEV("p", {
131
+ className: "text-text-muted text-xs font-semibold tracking-widest uppercase",
132
+ children: "Tree"
133
+ }, undefined, false, undefined, this),
134
+ /* @__PURE__ */ jsxDEV("p", {
135
+ className: "font-koran text-2xl font-black",
136
+ children: tree.length
137
+ }, undefined, false, undefined, this),
138
+ /* @__PURE__ */ jsxDEV("p", {
139
+ className: "text-text-muted text-xs",
140
+ children: "root nodes"
141
+ }, undefined, false, undefined, this)
142
+ ]
143
+ }, undefined, true, undefined, this)
144
+ ]
145
+ }, undefined, true, undefined, this),
146
+ /* @__PURE__ */ jsxDEV("div", {
147
+ className: "border-border bg-card rounded-lg border",
148
+ children: [
149
+ /* @__PURE__ */ jsxDEV("div", {
150
+ className: "border-border flex items-center justify-between border-b px-4 py-3",
151
+ children: [
152
+ /* @__PURE__ */ jsxDEV("h2", {
153
+ className: "text-sm font-semibold tracking-widest uppercase",
154
+ children: "Resource Tree"
155
+ }, undefined, false, undefined, this),
156
+ /* @__PURE__ */ jsxDEV("span", {
157
+ className: "text-text-muted text-xs",
158
+ children: [
159
+ totalResources,
160
+ " resources"
161
+ ]
162
+ }, undefined, true, undefined, this)
163
+ ]
164
+ }, undefined, true, undefined, this),
165
+ /* @__PURE__ */ jsxDEV("div", {
166
+ className: "divide-border divide-y",
167
+ children: [
168
+ groups.map((g) => /* @__PURE__ */ jsxDEV("div", {
169
+ className: "px-4 py-3",
170
+ children: [
171
+ /* @__PURE__ */ jsxDEV("p", {
172
+ className: "text-xs font-semibold tracking-widest uppercase text-form-label",
173
+ children: g.name
174
+ }, undefined, false, undefined, this),
175
+ /* @__PURE__ */ jsxDEV("ul", {
176
+ className: "mt-2 flex flex-wrap gap-2",
177
+ children: g.items.map((it) => /* @__PURE__ */ jsxDEV("li", {
178
+ children: [
179
+ /* @__PURE__ */ jsxDEV("a", {
180
+ href: it.href,
181
+ className: "bg-secondary text-secondary-foreground inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium hover:underline",
182
+ children: [
183
+ /* @__PURE__ */ jsxDEV("span", {
184
+ children: it.icon ?? "•"
185
+ }, undefined, false, undefined, this),
186
+ " ",
187
+ it.label
188
+ ]
189
+ }, undefined, true, undefined, this),
190
+ (it.children?.length ?? 0) > 0 && /* @__PURE__ */ jsxDEV("span", {
191
+ className: "text-text-muted ml-1 text-xs",
192
+ children: [
193
+ "+",
194
+ it.children?.length ?? 0,
195
+ " child"
196
+ ]
197
+ }, undefined, true, undefined, this)
198
+ ]
199
+ }, it.id, true, undefined, this))
200
+ }, undefined, false, undefined, this)
201
+ ]
202
+ }, g.name, true, undefined, this)),
203
+ groups.length === 0 && /* @__PURE__ */ jsxDEV("p", {
204
+ className: "text-text-muted px-4 py-6 text-center text-sm",
205
+ children: "Belum ada resource terdaftar — pastikan rack() sudah dipanggil sebelum dashboard render."
206
+ }, undefined, false, undefined, this)
207
+ ]
208
+ }, undefined, true, undefined, this)
209
+ ]
210
+ }, undefined, true, undefined, this),
211
+ /* @__PURE__ */ jsxDEV("footer", {
212
+ className: "border-border text-text-muted mt-6 border-t pt-3 text-center text-xs tracking-widest uppercase",
213
+ children: "Printed by elysia-rack — ERP blueprint"
214
+ }, undefined, false, undefined, this)
215
+ ]
216
+ }, undefined, true, 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
+ };