elysia-rack 0.5.4 → 0.5.8

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
@@ -231,6 +231,9 @@ new Elysia().use(
231
231
  );
232
232
 
233
233
  // override panel.css (file path, raw CSS string, or { path } / { content })
234
+ // Custom CSS is appended after the default panel.css, so it cascades over
235
+ // the default instead of replacing it — useful for changing only a few
236
+ // tokens or rules.
234
237
  new Elysia().use(reactPlugin({ css: "./my-panel.css" }));
235
238
  ```
236
239
 
package/dist/panel-app.js CHANGED
@@ -233,25 +233,64 @@ function actionButton(action, id) {
233
233
  const target = esc(action.href.replaceAll(":id", encodeURIComponent(id)));
234
234
  const cls = "text-link hover:text-link-hover ml-3 px-1 text-sm font-medium hover:underline";
235
235
  if (action.method) {
236
- const confirm = action.confirm ? ` data-rack-confirm="${esc(action.confirm)}"` : "";
237
- return `<button type="button" data-rack-href="${target}" data-rack-method="${esc(action.method)}"${confirm} class="${cls}">${esc(action.label)}</button>`;
236
+ const btn = document.createElement("button");
237
+ btn.type = "button";
238
+ btn.dataset.rackHref = target;
239
+ btn.dataset.rackMethod = esc(action.method);
240
+ btn.className = cls;
241
+ if (action.confirm) {
242
+ btn.dataset.rackConfirm = esc(action.confirm);
243
+ }
244
+ btn.textContent = esc(action.label);
245
+ return btn;
238
246
  }
239
- return `<a href="${target}" class="${cls}">${esc(action.label)}</a>`;
247
+ const a = document.createElement("a");
248
+ a.href = target;
249
+ a.className = cls;
250
+ a.textContent = esc(action.label);
251
+ return a;
240
252
  }
241
253
  function actionCell(id, cfg, deleted) {
242
- let inner = "";
254
+ const td = document.createElement("td");
255
+ td.className = "border-table-border border-t px-3 py-2.5 whitespace-nowrap text-right";
256
+ const cls = "text-link hover:text-link-hover px-1 text-sm font-medium hover:underline";
243
257
  if (deleted) {
244
- if (cfg.restorable)
245
- inner = `<button type="button" data-restore-id="${esc(id)}" class="text-link hover:text-link-hover px-1 text-sm font-medium hover:underline">Restore</button>`;
258
+ if (cfg.restorable) {
259
+ const btn = document.createElement("button");
260
+ btn.type = "button";
261
+ btn.dataset.restoreId = esc(id);
262
+ btn.className = cls;
263
+ btn.textContent = "Restore";
264
+ td.appendChild(btn);
265
+ }
246
266
  } else {
247
- if (cfg.viewable)
248
- inner += `<button type="button" data-view-id="${esc(id)}" class="text-link hover:text-link-hover px-1 text-sm font-medium hover:underline">View</button>`;
249
- if (cfg.editable)
250
- inner += `<button type="button" data-edit-id="${esc(id)}" class="text-link hover:text-link-hover ${cfg.viewable ? "ml-3 " : ""}px-1 text-sm font-medium hover:underline">Edit</button>` + `<button type="button" data-delete-id="${esc(id)}" class="text-destructive ml-3 px-1 text-sm font-medium hover:underline">Delete</button>`;
251
- for (const action of cfg.actions)
252
- inner += actionButton(action, id);
267
+ if (cfg.viewable) {
268
+ const btn = document.createElement("button");
269
+ btn.type = "button";
270
+ btn.dataset.viewId = esc(id);
271
+ btn.className = cls;
272
+ btn.textContent = "View";
273
+ td.appendChild(btn);
274
+ }
275
+ if (cfg.editable) {
276
+ const editBtn = document.createElement("button");
277
+ editBtn.type = "button";
278
+ editBtn.dataset.editId = esc(id);
279
+ editBtn.className = `text-link hover:text-link-hover${cfg.viewable ? " ml-3" : ""} px-1 text-sm font-medium hover:underline`;
280
+ editBtn.textContent = "Edit";
281
+ td.appendChild(editBtn);
282
+ const delBtn = document.createElement("button");
283
+ delBtn.type = "button";
284
+ delBtn.dataset.deleteId = esc(id);
285
+ delBtn.className = "text-destructive ml-3 px-1 text-sm font-medium hover:underline";
286
+ delBtn.textContent = "Delete";
287
+ td.appendChild(delBtn);
288
+ }
289
+ for (const action of cfg.actions) {
290
+ td.appendChild(actionButton(action, id));
291
+ }
253
292
  }
254
- return `<td class="border-table-border border-t px-3 py-2.5 whitespace-nowrap text-right">${inner}</td>`;
293
+ return td;
255
294
  }
256
295
  function numericCols(rows, cols) {
257
296
  const out = new Set;
@@ -263,37 +302,68 @@ function numericCols(rows, cols) {
263
302
  }
264
303
  return out;
265
304
  }
266
- function cellHtml(field, value) {
305
+ function cellText(field, value) {
267
306
  if (value === null || value === undefined)
268
307
  return "";
269
- const text = esc(displayValue(field, value));
270
- if (field?.format === "badge" || field?.name === "status")
271
- return `<span class="rounded-sm px-1.5 py-0.5 text-xs font-medium ${badgeClass(value)}">${text}</span>`;
272
- return text;
308
+ return esc(displayValue(field, value));
273
309
  }
274
310
  function paintRows(tbody, cols, rows, cfg) {
275
311
  const showActions = cfg.editable || cfg.viewable || cfg.softDelete || cfg.actions.length > 0;
276
312
  const span = cols.length + (cfg.selectable ? 1 : 0) + (showActions ? 1 : 0);
277
- if (!rows.length) {
278
- tbody.innerHTML = `<tr><td colspan="${span || 1}" class="text-text-muted px-4 py-10 text-center">No records found.</td></tr>`;
279
- return;
280
- }
281
313
  const numeric = numericCols(rows, cols);
282
- tbody.innerHTML = rows.map((row) => {
314
+ if (!rows.length) {
315
+ tbody.replaceChildren();
316
+ const tr = document.createElement("tr");
317
+ const td = document.createElement("td");
318
+ td.colSpan = span || 1;
319
+ td.className = "text-text-muted px-4 py-10 text-center";
320
+ td.textContent = "No records found.";
321
+ tr.appendChild(td);
322
+ tbody.appendChild(tr);
323
+ return;
324
+ }
325
+ tbody.replaceChildren();
326
+ for (const row of rows) {
283
327
  const id = row[cfg.pk] == null ? "" : String(row[cfg.pk]);
284
328
  const deleted = cfg.softDelete && row[cfg.deletedAt] != null;
285
- let html = `<tr class="hover:bg-table-row-hover">`;
286
- if (cfg.selectable)
287
- html += `<td class="border-table-border border-t px-3 py-2.5"><input type="checkbox" data-select-row value="${esc(id)}" aria-label="Select row ${esc(id)}" class="accent-primary size-4"></td>`;
288
- html += cols.map((col) => {
329
+ const tr = document.createElement("tr");
330
+ tr.className = "hover:bg-table-row-hover";
331
+ if (cfg.selectable) {
332
+ const td = document.createElement("td");
333
+ td.className = "border-table-border border-t px-3 py-2.5";
334
+ const input = document.createElement("input");
335
+ input.type = "checkbox";
336
+ input.dataset.selectRow = "";
337
+ input.value = id;
338
+ input.setAttribute("aria-label", `Select row ${esc(id)}`);
339
+ input.className = "accent-primary size-4";
340
+ td.appendChild(input);
341
+ tr.appendChild(td);
342
+ }
343
+ for (const col of cols) {
289
344
  const field = cfg.byName.get(col);
290
345
  const align = numeric.has(col) || field && isNumericField(field) ? " text-right tabular-nums" : "";
291
- return `<td class="border-table-border border-t px-3 py-2.5${align}">${cellHtml(field, row[col])}</td>`;
292
- }).join("");
293
- if (showActions)
294
- html += actionCell(id, cfg, Boolean(deleted));
295
- return html + "</tr>";
296
- }).join("");
346
+ const td = document.createElement("td");
347
+ td.className = `border-table-border border-t px-3 py-2.5${align}`;
348
+ const value = row[col];
349
+ const badge = field?.format === "badge" || field?.name === "status";
350
+ if (value === null || value === undefined) {
351
+ td.textContent = "";
352
+ } else if (badge) {
353
+ const span2 = document.createElement("span");
354
+ span2.className = `rounded-sm px-1.5 py-0.5 text-xs font-medium ${badgeClass(value)}`;
355
+ span2.textContent = displayValue(field, value);
356
+ td.appendChild(span2);
357
+ } else {
358
+ td.textContent = cellText(field, value);
359
+ }
360
+ tr.appendChild(td);
361
+ }
362
+ if (showActions) {
363
+ tr.appendChild(actionCell(id, cfg, Boolean(deleted)));
364
+ }
365
+ tbody.appendChild(tr);
366
+ }
297
367
  }
298
368
  function paintPages(box, params, page, totalPages) {
299
369
  const linkCls = "border-border rounded-md border px-2.5 py-1 text-xs hover:bg-navigation-hover";
@@ -501,14 +571,36 @@ async function openDetail(root, id) {
501
571
  if (label)
502
572
  label.textContent = `#${id}`;
503
573
  const body = dlg.querySelector("[data-detail-body]");
504
- if (body)
505
- body.innerHTML = renderDetail(root, row);
574
+ if (body) {
575
+ body.replaceChildren(renderDetail(root, row));
576
+ }
506
577
  dlg.showModal?.();
507
578
  }
508
579
  function renderDetail(root, row) {
509
580
  const cfg = tableConfig(root);
510
- const items = cfg.fields.filter((field) => row[field.name] !== undefined && !field.hideInTable).map((field) => `<div class="flex flex-col gap-0.5"><dt class="text-text-muted text-xs font-medium">${esc(field.label)}</dt><dd class="text-foreground text-sm">${esc(displayValue(field, row[field.name])) || "—"}</dd></div>`);
511
- return items.join("") || `<p class="text-text-muted text-sm">No fields</p>`;
581
+ const frag = document.createDocumentFragment();
582
+ const visibleFields = cfg.fields.filter((field) => row[field.name] !== undefined && !field.hideInTable);
583
+ if (!visibleFields.length) {
584
+ const p = document.createElement("p");
585
+ p.className = "text-text-muted text-sm";
586
+ p.textContent = "No fields";
587
+ frag.appendChild(p);
588
+ return frag;
589
+ }
590
+ for (const field of visibleFields) {
591
+ const wrapper = document.createElement("div");
592
+ wrapper.className = "flex flex-col gap-0.5";
593
+ const dt = document.createElement("dt");
594
+ dt.className = "text-text-muted text-xs font-medium";
595
+ dt.textContent = String(field.label ?? "");
596
+ const dd = document.createElement("dd");
597
+ dd.className = "text-foreground text-sm";
598
+ dd.textContent = displayValue(field, row[field.name]) || "—";
599
+ wrapper.appendChild(dt);
600
+ wrapper.appendChild(dd);
601
+ frag.appendChild(wrapper);
602
+ }
603
+ return frag;
512
604
  }
513
605
  async function deleteOne(root, id) {
514
606
  if (!window.confirm(`Delete record ${id}?`))
@@ -1,5 +1,6 @@
1
1
  import Elysia from "elysia";
2
2
  import { ReactRack } from "./types";
3
+ export declare const lazyPage: typeof ReactRack.lazyPage;
3
4
  export declare function page<Props = Record<string, never>>(path: string, props?: Props): ReactRack.PageDescriptor<Props>;
4
5
  export declare const pages: ReactRack.PageRegistry;
5
6
  export declare function reactPlugin(options?: ReactRack.ReactPluginOptions): Elysia<"", {
@@ -149,6 +149,7 @@ function createRenderer(pages) {
149
149
  }
150
150
 
151
151
  // src/react/index.ts
152
+ var lazyPage = ReactRack.lazyPage;
152
153
  var PANEL_ASSETS = {
153
154
  "panel.css": "text/css; charset=utf-8",
154
155
  "panel-theme.js": "text/javascript; charset=utf-8",
@@ -260,14 +261,34 @@ function reactPlugin(options = {}) {
260
261
  return status(404, "Panel asset not found");
261
262
  if (params.name === "panel.css") {
262
263
  const custom = await loadCustomPanelCss();
264
+ const dir2 = resolveDistDir();
265
+ let defaultCss = null;
266
+ if (dir2) {
267
+ try {
268
+ defaultCss = await readFile2(join2(dir2, "panel.css"), "utf-8");
269
+ } catch {}
270
+ }
263
271
  if (custom !== null) {
264
- return new Response(custom, {
272
+ const combined = defaultCss ? `${defaultCss}
273
+
274
+ /* Custom panel.css overrides */
275
+ ${custom}` : custom;
276
+ return new Response(combined, {
265
277
  headers: {
266
278
  "Content-Type": type,
267
279
  "Cache-Control": "public, max-age=3600"
268
280
  }
269
281
  });
270
282
  }
283
+ if (defaultCss) {
284
+ return new Response(defaultCss, {
285
+ headers: {
286
+ "Content-Type": type,
287
+ "Cache-Control": "public, max-age=3600"
288
+ }
289
+ });
290
+ }
291
+ return status(404, "Panel asset not found");
271
292
  }
272
293
  const dir = resolveDistDir();
273
294
  if (!dir)
@@ -298,5 +319,6 @@ function reactPlugin(options = {}) {
298
319
  export {
299
320
  reactPlugin,
300
321
  pages,
301
- page
322
+ page,
323
+ lazyPage
302
324
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elysia-rack",
3
- "version": "0.5.4",
3
+ "version": "0.5.8",
4
4
  "description": "Model-driven CRUD rack for Elysia: REST + QUERY routes, validation, authorization, OpenAPI metadata, and React panel rendering.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -59,7 +59,8 @@
59
59
  "build:js": "bun build ./src/rack/index.ts ./src/react/index.ts --outdir ./dist --target node --format esm --splitting --external elysia --external react --external react-dom --external lucide-react",
60
60
  "build:client": "bun build src/react/page/client/panel.ts --outfile dist/panel-app.js --target browser --format esm && bun build src/react/page/client/theme.ts --outfile dist/panel-theme.js --target browser --format esm && mkdir -p dist && cp src/react/app.html dist/app.html 2>/dev/null || true",
61
61
  "build:dts": "tsc -p tsconfig.build.json",
62
- "prepublishOnly": "bun run typecheck && bun run build"
62
+ "prepublishOnly": "bun run typecheck && bun run build",
63
+ "docs:build": "vitepress build docs"
63
64
  },
64
65
  "peerDependencies": {
65
66
  "elysia": "^1.4.0",
@@ -77,6 +78,7 @@
77
78
  "@tailwindcss/cli": "4.3.3",
78
79
  "@types/bun": "latest",
79
80
  "@types/react": "^19.2.18",
81
+ "vitepress": "1",
80
82
  "@types/react-dom": "^19.2.5",
81
83
  "drizzle-orm": "^0.45.2",
82
84
  "elysia": "^1.4.30",