elysia-rack 0.1.2 → 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/dist/panel-app.js CHANGED
@@ -1 +1,498 @@
1
- function e(e,t={}){let n=new URLSearchParams;for(let[r,i]of Object.entries({...e,...t}))i!=null&&i!==``&&n.set(r,Array.isArray(i)?i.join(`,`):String(i));let r=n.toString();return r?`?${r}`:`?`}function t(e,t){let n=[...new Set([1,t,e-1,e,e+1])].filter(e=>e>=1&&e<=t).sort((e,t)=>e-t),r=[],i=0;for(let e of n)e-i>1&&r.push(`…`),r.push(e),i=e;return r}function n(e){let t={},n=e.elements||[];for(let e=0;e<n.length;e++){let r=n[e];if(r&&r.name&&!r.disabled){if(r.type===`checkbox`){r.checked&&(t[r.name]=!0);continue}if(r.value!==``&&r.value!==void 0&&r.value!==null){if(r.hasAttribute&&r.hasAttribute(`data-json`)){try{t[r.name]=JSON.parse(r.value)}catch{return{error:`Invalid JSON in ${r.name}`}}continue}t[r.name]=r.value}}}return{body:t}}function r(e,t){let n=e.elements||[];for(let e=0;e<n.length;e++){let r=n[e];if(!r||!r.name)continue;let i=t[r.name];if(i!=null){if(r.type===`checkbox`){r.checked=i===!0||i===`true`||i===1;continue}if(r.type===`datetime-local`&&typeof i==`string`){r.value=i.slice(0,16);continue}r.value=typeof i==`object`?JSON.stringify(i):String(i)}}}function i(e){return String(e??``).split(`&`).join(`&amp;`).split(`<`).join(`&lt;`).split(`>`).join(`&gt;`).split(`"`).join(`&quot;`)}function a(e){return e==null?``:typeof e==`object`?JSON.stringify(e):String(e)}function o(e,t){if(e!==`status`||typeof t!=`string`)return null;let n=t.toLowerCase();return n===`active`?`bg-success-muted text-success-muted-foreground`:n===`archived`?`bg-warning-muted text-warning-muted-foreground`:`bg-badge-background text-badge-foreground`}function s(e){let t={};return e.forEach((e,n)=>{t[n]=e}),t}function c(e){let t=e.getAttribute(`data-query-url`)||``;return t.slice(-5)===`/data`?t.slice(0,-5):t||`?`}function l(){try{if(window.crypto?.randomUUID)return window.crypto.randomUUID()}catch{}return`k-${Date.now().toString(36)}-${Math.floor(Math.random()*1e9).toString(36)}`}function u(e,t){let n=e.querySelector(`[data-form-error]`);n&&(n.textContent=t)}function d(e){g(e,new URLSearchParams(window.location.search))}function f(e){return`<td class="border-table-border border-t px-4 py-2 whitespace-nowrap"><div class="border-table-border inline-flex overflow-hidden rounded-md border"><button type="button" data-edit-id="${i(e)}" class="text-link hover:bg-table-row-hover px-2 py-1 text-sm font-semibold">Edit</button><button type="button" data-delete-id="${i(e)}" class="border-table-border text-destructive hover:bg-table-row-hover border-l px-2 py-1 text-sm font-semibold">Delete</button></div></td>`}function p(e,t,n,r,s,c){if(!n.length){e.innerHTML=`<tr><td colspan="${t.length}" class="text-text-muted px-4 py-10 text-center">No records found.</td></tr>`;return}e.innerHTML=n.map(e=>{let n=e[r]==null?``:String(e[r]),l=`<tr class="hover:bg-table-row-hover">`;return s&&(l+=`<td class="border-table-border border-t px-4 py-2"><input type="checkbox" data-select-row value="${i(n)}" aria-label="Select row ${i(n)}"></td>`),l+=t.map(t=>{let n=o(t,e[t]);return`<td class="border-table-border border-t px-4 py-2">${n?`<span class="rounded-full px-2 py-0.5 text-xs font-semibold ${n}">${i(a(e[t]))}</span>`:i(a(e[t]))}</td>`}).join(``),c&&(l+=f(n)),l+`</tr>`}).join(``)}function m(n,r,a,o){let s=`border-border rounded-md border px-3 py-1 text-sm hover:bg-navigation-hover`,c=`border-border text-text-disabled rounded-md border px-3 py-1 text-sm`,l=a>1?`<a class="${s}" data-qlink href="${i(e(r,{page:a-1}))}">← Previous</a>`:`<span class="${c}">← Previous</span>`;for(let n of t(a,o))l+=n===`…`?`<span class="text-text-muted px-1">…</span>`:n===a?`<span class="bg-primary text-primary-foreground rounded-md px-3 py-1 text-sm font-bold">${n}</span>`:`<a class="${s}" data-qlink href="${i(e(r,{page:n}))}">${n}</a>`;l+=a<o?`<a class="${s}" data-qlink href="${i(e(r,{page:a+1}))}">Next →</a>`:`<span class="${c}">Next →</span>`,n.innerHTML=l}function h(t,n,r){let i=s(r),a=[];Array.prototype.forEach.call(t.querySelectorAll(`thead th[data-col]`),e=>{a.push(e.getAttribute(`data-col`))});let o=n.query??{},c=o.page??1,l=o.limit??20,u=n.total??0,d=Math.max(1,Math.ceil(u/l)),f=t.querySelector(`table`),h=!!f?.hasAttribute(`data-selectable`),g=!!f?.hasAttribute(`data-editable`),_=f?.getAttribute(`data-pk`)||`id`,y=t.querySelector(`[data-panel-rows]`);y&&p(y,a,n.data??[],_,h,g);let b=t.querySelector(`[data-panel-count]`);b&&(b.textContent=`Page ${c} of ${d} — ${u===0?0:(c-1)*l+1}–${Math.min(c*l,u)} of ${u} records`);let x=t.querySelector(`[data-panel-pages]`);x&&m(x,i,c,d);let S=o.sort??{};Array.prototype.forEach.call(t.querySelectorAll(`a[data-sort-link]`),t=>{let n=t.getAttribute(`data-sort-link`),r=S.field===n,a=r&&S.direction===`asc`?`desc`:`asc`;t.setAttribute(`href`,e(i,{sort:n,order:a}));let o=t.querySelector(`[data-sort-ind]`);o&&(o.textContent=r?S.direction===`asc`?` ▲`:` ▼`:``)});try{window.history.replaceState(null,``,e(i,{}))}catch{}v(t)}async function g(e,t){let n=e.getAttribute(`data-query-url`)||``,r=t.toString(),i=await fetch(n+(r?`?${r}`:``),{method:`QUERY`,headers:{Accept:`application/json`}});i.ok&&h(e,await i.json(),t)}function _(e){let t=[];return Array.prototype.forEach.call(e.querySelectorAll(`[data-select-row]:checked`),e=>{t.push(e.value)}),t}function v(e){let t=_(e),n=e.querySelector(`[data-bulk-bar]`);n&&(n.style.display=t.length?``:`none`);let r=e.querySelector(`[data-bulk-count]`);r&&(r.textContent=`${t.length} selected`);let i=e.querySelectorAll(`[data-select-row]`),a=e.querySelector(`[data-select-all]`);a&&(a.checked=i.length>0&&t.length===i.length)}async function y(e,t){u(t,``);let r=t.querySelector(`[data-create-json]`);if(r){let n;try{n=JSON.parse(r.value)}catch{u(t,`Invalid JSON body`);return}let i=await fetch(c(e),{method:`POST`,headers:{"Content-Type":`application/json`,"Idempotency-Key":l()},body:JSON.stringify(n)});if(!i.ok){u(t,`Error ${i.status}: ${(await i.text()).slice(0,200)}`);return}}else{let r=n(t);if(r.error){u(t,r.error);return}let i=await fetch(c(e),{method:`POST`,headers:{"Content-Type":`application/json`,"Idempotency-Key":l()},body:JSON.stringify(r.body)});if(!i.ok){u(t,`Error ${i.status}: ${(await i.text()).slice(0,200)}`);return}}let i=t.closest(`dialog`);t.reset(),i&&i.close(),d(e)}async function b(e,t){u(t,``);let r=t.dataset?.id||``;if(!r){u(t,`Missing record id`);return}let i=n(t);if(i.error){u(t,i.error);return}let a=await fetch(`${c(e)}/${encodeURIComponent(r)}`,{method:`PATCH`,headers:{"Content-Type":`application/json`},body:JSON.stringify(i.body)});if(!a.ok){u(t,`Error ${a.status}: ${(await a.text()).slice(0,200)}`);return}let o=t.closest(`dialog`);o&&o.close(),d(e)}async function x(e,t){let n=await fetch(`${c(e)}/data/${encodeURIComponent(t)}`,{method:`QUERY`,headers:{Accept:`application/json`}});if(!n.ok)return;let i=(await n.json()).data??{},a=document.getElementById(`rack-edit`);if(!a)return;let o=a.querySelector(`[data-edit-form]`);if(!o)return;o.dataset.id=t;let s=a.querySelector(`[data-edit-id-label]`);s&&(s.textContent=`#${t}`),r(o,i),u(o,``),a.showModal?.()}async function S(e,t){window.confirm(`Delete record ${t}?`)&&(await fetch(`${c(e)}/${encodeURIComponent(t)}`,{method:`DELETE`})).ok&&d(e)}async function C(e){let t=_(e);if(!t.length||!window.confirm(`Delete ${t.length} records?`))return;let n=c(e);for(let e of t)await fetch(`${n}/${encodeURIComponent(e)}`,{method:`DELETE`});d(e)}async function w(e){let t=e.querySelector(`[data-bulk-field]`),n=e.querySelector(`[data-bulk-value]`),r=_(e);if(!t?.value||!n||!r.length)return;let i=c(e),a=JSON.stringify({[t.value]:n.value});for(let e of r)await fetch(`${i}/${encodeURIComponent(e)}`,{method:`PATCH`,headers:{"Content-Type":`application/json`},body:a});n.value=``,d(e)}document.addEventListener(`click`,e=>{let t=e.target;if(!t?.closest)return;let n=t.closest(`[data-query-url]`);if(!n)return;let r=t.closest(`[data-close]`);if(r){r.closest(`dialog`)?.close();return}if(t.closest(`[data-open-create]`)){let e=document.getElementById(`rack-create`);if(e){let t=e.querySelector(`[data-create-form]`);t&&(t.reset(),u(t,``)),e.showModal?.()}return}let i=t.closest(`[data-edit-id]`);if(i){x(n,i.getAttribute(`data-edit-id`));return}let a=t.closest(`[data-delete-id]`);if(a){S(n,a.getAttribute(`data-delete-id`));return}if(t.closest(`[data-bulk-delete]`)){C(n);return}if(t.closest(`[data-bulk-apply]`)){w(n);return}if(t.closest(`[data-bulk-clear]`)){Array.prototype.forEach.call(n.querySelectorAll(`[data-select-row],[data-select-all]`),e=>{e.checked=!1}),v(n);return}let o=t.closest(`a[data-qlink]`);o&&(e.preventDefault(),g(n,new URLSearchParams(o.getAttribute(`href`).replace(/^\?/,``))))}),document.addEventListener(`submit`,e=>{let t=e.target;if(!t||t.tagName!==`FORM`)return;let n=t.closest?.(`[data-query-url]`);if(!n)return;if(t.hasAttribute(`data-create-form`)){e.preventDefault(),y(n,t);return}if(t.hasAttribute(`data-edit-form`)){e.preventDefault(),b(n,t);return}e.preventDefault();let r=new URLSearchParams,i=t.elements??[];for(let e=0;e<i.length;e++){let t=i[e];t.name&&!t.disabled&&(t.type!==`checkbox`&&t.type!==`radio`||t.checked)&&t.value!==``&&r.append(t.name,t.value)}g(n,r)}),document.addEventListener(`change`,e=>{let t=e.target;if(!t?.closest)return;let n=t.closest(`[data-query-url]`);if(!n)return;let r=t.hasAttribute(`data-select-all`);(r||t.hasAttribute(`data-select-row`))&&(r&&Array.prototype.forEach.call(n.querySelectorAll(`[data-select-row]`),e=>{e.checked=t.checked}),v(n))});
1
+ // src/react/page/components/href.ts
2
+ function href(params, over = {}) {
3
+ const query = new URLSearchParams;
4
+ for (const [key, value] of Object.entries({ ...params, ...over })) {
5
+ if (value === undefined || value === null || value === "")
6
+ continue;
7
+ query.set(key, Array.isArray(value) ? value.join(",") : String(value));
8
+ }
9
+ const str = query.toString();
10
+ return str ? `?${str}` : "?";
11
+ }
12
+ function pageWindow(page, total) {
13
+ const keep = new Set([1, total, page - 1, page, page + 1]);
14
+ const sorted = [...keep].filter((p) => p >= 1 && p <= total).sort((a, b) => a - b);
15
+ const out = [];
16
+ let prev = 0;
17
+ for (const p of sorted) {
18
+ if (p - prev > 1)
19
+ out.push("…");
20
+ out.push(p);
21
+ prev = p;
22
+ }
23
+ return out;
24
+ }
25
+
26
+ // src/react/page/components/panelForm.ts
27
+ function collectFormBody(form) {
28
+ const body = {};
29
+ const els = form.elements || [];
30
+ for (let i = 0;i < els.length; i++) {
31
+ const el = els[i];
32
+ if (!el || !el.name || el.disabled)
33
+ continue;
34
+ if (el.type === "checkbox") {
35
+ if (el.checked)
36
+ body[el.name] = true;
37
+ continue;
38
+ }
39
+ if (el.value === "" || el.value === undefined || el.value === null)
40
+ continue;
41
+ if (el.hasAttribute && el.hasAttribute("data-json")) {
42
+ try {
43
+ body[el.name] = JSON.parse(el.value);
44
+ } catch {
45
+ return { error: `Invalid JSON in ${el.name}` };
46
+ }
47
+ continue;
48
+ }
49
+ body[el.name] = el.value;
50
+ }
51
+ return { body };
52
+ }
53
+ function fillForm(form, row) {
54
+ const els = form.elements || [];
55
+ for (let i = 0;i < els.length; i++) {
56
+ const el = els[i];
57
+ if (!el || !el.name)
58
+ continue;
59
+ const v = row[el.name];
60
+ if (v === undefined || v === null)
61
+ continue;
62
+ if (el.type === "checkbox") {
63
+ el.checked = v === true || v === "true" || v === 1;
64
+ continue;
65
+ }
66
+ if (el.type === "datetime-local" && typeof v === "string") {
67
+ el.value = v.slice(0, 16);
68
+ continue;
69
+ }
70
+ el.value = typeof v === "object" ? JSON.stringify(v) : String(v);
71
+ }
72
+ }
73
+
74
+ // src/react/page/client/panel.ts
75
+ function debounce(fn, wait) {
76
+ let id;
77
+ return (...args) => {
78
+ clearTimeout(id);
79
+ id = setTimeout(() => fn(...args), wait);
80
+ };
81
+ }
82
+ function esc(value) {
83
+ return String(value ?? "").split("&").join("&amp;").split("<").join("&lt;").split(">").join("&gt;").split('"').join("&quot;");
84
+ }
85
+ function cellText(value) {
86
+ if (value === null || value === undefined)
87
+ return "";
88
+ if (typeof value === "object")
89
+ return JSON.stringify(value);
90
+ return String(value);
91
+ }
92
+ function badgeClass(col, value) {
93
+ if (col !== "status" || typeof value !== "string")
94
+ return null;
95
+ const text = value.toLowerCase();
96
+ if (text === "active")
97
+ return "bg-success-muted text-success-muted-foreground";
98
+ if (text === "archived")
99
+ return "bg-warning-muted text-warning-muted-foreground";
100
+ return "bg-badge-background text-badge-foreground";
101
+ }
102
+ function toParams(sp) {
103
+ const out = {};
104
+ sp.forEach((v, k) => {
105
+ out[k] = v;
106
+ });
107
+ return out;
108
+ }
109
+ function baseUrl(root) {
110
+ const q = root.getAttribute("data-query-url") || "";
111
+ return q.slice(-5) === "/data" ? q.slice(0, -5) : q || "?";
112
+ }
113
+ function uid() {
114
+ try {
115
+ if (window.crypto?.randomUUID)
116
+ return window.crypto.randomUUID();
117
+ } catch {}
118
+ return `k-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
119
+ }
120
+ function setErr(form, msg) {
121
+ const el = form.querySelector("[data-form-error]");
122
+ if (el)
123
+ el.textContent = msg;
124
+ }
125
+ function refresh(root) {
126
+ run(root, new URLSearchParams(window.location.search));
127
+ }
128
+ function actionCell(id) {
129
+ return `<td class="border-table-border border-t px-4 py-2 whitespace-nowrap">` + `<div class="border-table-border inline-flex overflow-hidden rounded-md border">` + `<button type="button" data-edit-id="${esc(id)}" class="text-link hover:bg-table-row-hover px-2 py-1 text-sm font-semibold">Edit</button>` + `<button type="button" data-delete-id="${esc(id)}" class="border-table-border text-destructive hover:bg-table-row-hover border-l px-2 py-1 text-sm font-semibold">Delete</button>` + `</div></td>`;
130
+ }
131
+ function paintRows(tbody, cols, rows, pk, selectable, editable) {
132
+ if (!rows.length) {
133
+ tbody.innerHTML = `<tr><td colspan="${cols.length}" class="text-text-muted px-4 py-10 text-center">No records found.</td></tr>`;
134
+ return;
135
+ }
136
+ tbody.innerHTML = rows.map((row) => {
137
+ const id = row[pk] == null ? "" : String(row[pk]);
138
+ let html = `<tr class="hover:bg-table-row-hover">`;
139
+ if (selectable)
140
+ html += `<td class="border-table-border border-t px-4 py-2"><input type="checkbox" data-select-row value="${esc(id)}" aria-label="Select row ${esc(id)}"></td>`;
141
+ html += cols.map((col) => {
142
+ const cls = badgeClass(col, row[col]);
143
+ const inner = cls ? `<span class="rounded-full px-2 py-0.5 text-xs font-semibold ${cls}">${esc(cellText(row[col]))}</span>` : esc(cellText(row[col]));
144
+ return `<td class="border-table-border border-t px-4 py-2">${inner}</td>`;
145
+ }).join("");
146
+ if (editable)
147
+ html += actionCell(id);
148
+ return html + "</tr>";
149
+ }).join("");
150
+ }
151
+ function paintPages(box, params, page, totalPages) {
152
+ const linkCls = "border-border rounded-md border px-3 py-1 text-sm hover:bg-navigation-hover";
153
+ const offCls = "border-border text-text-disabled rounded-md border px-3 py-1 text-sm";
154
+ let html = page > 1 ? `<a class="${linkCls}" data-qlink href="${esc(href(params, { page: page - 1 }))}">← Previous</a>` : `<span class="${offCls}">← Previous</span>`;
155
+ for (const p of pageWindow(page, totalPages)) {
156
+ if (p === "…")
157
+ html += `<span class="text-text-muted px-1">…</span>`;
158
+ else if (p === page)
159
+ html += `<span class="bg-primary text-primary-foreground rounded-md px-3 py-1 text-sm font-bold">${p}</span>`;
160
+ else
161
+ html += `<a class="${linkCls}" data-qlink href="${esc(href(params, { page: p }))}">${p}</a>`;
162
+ }
163
+ html += page < totalPages ? `<a class="${linkCls}" data-qlink href="${esc(href(params, { page: page + 1 }))}">Next →</a>` : `<span class="${offCls}">Next →</span>`;
164
+ box.innerHTML = html;
165
+ }
166
+ function paint(root, json, sp) {
167
+ const params = toParams(sp);
168
+ const cols = [];
169
+ Array.prototype.forEach.call(root.querySelectorAll("thead th[data-col]"), (th) => {
170
+ cols.push(th.getAttribute("data-col"));
171
+ });
172
+ const q = json.query ?? {};
173
+ const page = q.page ?? 1;
174
+ const limit = q.limit ?? 20;
175
+ const total = json.total ?? 0;
176
+ const totalPages = Math.max(1, Math.ceil(total / limit));
177
+ const tbl = root.querySelector("table");
178
+ const selectable = !!tbl?.hasAttribute("data-selectable");
179
+ const editable = !!tbl?.hasAttribute("data-editable");
180
+ const pk = tbl?.getAttribute("data-pk") || "id";
181
+ const tbody = root.querySelector("[data-panel-rows]");
182
+ if (tbody)
183
+ paintRows(tbody, cols, json.data ?? [], pk, selectable, editable);
184
+ const count = root.querySelector("[data-panel-count]");
185
+ if (count) {
186
+ const from = total === 0 ? 0 : (page - 1) * limit + 1;
187
+ count.textContent = `Page ${page} of ${totalPages} — ${from}–${Math.min(page * limit, total)} of ${total} records`;
188
+ }
189
+ const box = root.querySelector("[data-panel-pages]");
190
+ if (box)
191
+ paintPages(box, params, page, totalPages);
192
+ const sort = q.sort ?? {};
193
+ Array.prototype.forEach.call(root.querySelectorAll("a[data-sort-link]"), (a) => {
194
+ const col = a.getAttribute("data-sort-link");
195
+ const active = sort.field === col;
196
+ const order = active && sort.direction === "asc" ? "desc" : "asc";
197
+ a.setAttribute("href", href(params, { sort: col, order }));
198
+ const ind = a.querySelector("[data-sort-ind]");
199
+ if (ind)
200
+ ind.textContent = active ? sort.direction === "asc" ? " ▲" : " ▼" : "";
201
+ });
202
+ try {
203
+ window.history.replaceState(null, "", href(params, {}));
204
+ } catch {}
205
+ syncBulk(root);
206
+ }
207
+ async function run(root, sp) {
208
+ const base = root.getAttribute("data-query-url") || "";
209
+ const search = sp.toString();
210
+ const res = await fetch(base + (search ? `?${search}` : ""), {
211
+ method: "QUERY",
212
+ headers: { Accept: "application/json" }
213
+ });
214
+ if (!res.ok)
215
+ return;
216
+ paint(root, await res.json(), sp);
217
+ }
218
+ function selectedIds(root) {
219
+ const ids = [];
220
+ Array.prototype.forEach.call(root.querySelectorAll("[data-select-row]:checked"), (c) => {
221
+ ids.push(c.value);
222
+ });
223
+ return ids;
224
+ }
225
+ function syncBulk(root) {
226
+ const ids = selectedIds(root);
227
+ const bar = root.querySelector("[data-bulk-bar]");
228
+ if (bar)
229
+ bar.style.display = ids.length ? "" : "none";
230
+ const count = root.querySelector("[data-bulk-count]");
231
+ if (count)
232
+ count.textContent = `${ids.length} selected`;
233
+ const boxes = root.querySelectorAll("[data-select-row]");
234
+ const all = root.querySelector("[data-select-all]");
235
+ if (all)
236
+ all.checked = boxes.length > 0 && ids.length === boxes.length;
237
+ }
238
+ async function createRecord(root, form) {
239
+ setErr(form, "");
240
+ const raw = form.querySelector("[data-create-json]");
241
+ if (raw) {
242
+ let body;
243
+ try {
244
+ body = JSON.parse(raw.value);
245
+ } catch {
246
+ setErr(form, "Invalid JSON body");
247
+ return;
248
+ }
249
+ const res = await fetch(baseUrl(root), {
250
+ method: "POST",
251
+ headers: { "Content-Type": "application/json", "Idempotency-Key": uid() },
252
+ body: JSON.stringify(body)
253
+ });
254
+ if (!res.ok) {
255
+ setErr(form, `Error ${res.status}: ${(await res.text()).slice(0, 200)}`);
256
+ return;
257
+ }
258
+ } else {
259
+ const collected = collectFormBody(form);
260
+ if (collected.error) {
261
+ setErr(form, collected.error);
262
+ return;
263
+ }
264
+ const res = await fetch(baseUrl(root), {
265
+ method: "POST",
266
+ headers: { "Content-Type": "application/json", "Idempotency-Key": uid() },
267
+ body: JSON.stringify(collected.body)
268
+ });
269
+ if (!res.ok) {
270
+ setErr(form, `Error ${res.status}: ${(await res.text()).slice(0, 200)}`);
271
+ return;
272
+ }
273
+ }
274
+ const dlg = form.closest("dialog");
275
+ form.reset();
276
+ if (dlg)
277
+ dlg.close();
278
+ refresh(root);
279
+ }
280
+ async function updateRecord(root, form) {
281
+ setErr(form, "");
282
+ const id = form.dataset?.id || "";
283
+ if (!id) {
284
+ setErr(form, "Missing record id");
285
+ return;
286
+ }
287
+ const collected = collectFormBody(form);
288
+ if (collected.error) {
289
+ setErr(form, collected.error);
290
+ return;
291
+ }
292
+ const res = await fetch(`${baseUrl(root)}/${encodeURIComponent(id)}`, {
293
+ method: "PATCH",
294
+ headers: { "Content-Type": "application/json" },
295
+ body: JSON.stringify(collected.body)
296
+ });
297
+ if (!res.ok) {
298
+ setErr(form, `Error ${res.status}: ${(await res.text()).slice(0, 200)}`);
299
+ return;
300
+ }
301
+ const dlg = form.closest("dialog");
302
+ if (dlg)
303
+ dlg.close();
304
+ refresh(root);
305
+ }
306
+ async function openEdit(root, id) {
307
+ const res = await fetch(`${baseUrl(root)}/data/${encodeURIComponent(id)}`, {
308
+ method: "QUERY",
309
+ headers: { Accept: "application/json" }
310
+ });
311
+ if (!res.ok)
312
+ return;
313
+ const row = (await res.json()).data ?? {};
314
+ const dlg = document.getElementById("rack-edit");
315
+ if (!dlg)
316
+ return;
317
+ const form = dlg.querySelector("[data-edit-form]");
318
+ if (!form)
319
+ return;
320
+ form.dataset.id = id;
321
+ const label = dlg.querySelector("[data-edit-id-label]");
322
+ if (label)
323
+ label.textContent = `#${id}`;
324
+ fillForm(form, row);
325
+ setErr(form, "");
326
+ dlg.showModal?.();
327
+ }
328
+ async function deleteOne(root, id) {
329
+ if (!window.confirm(`Delete record ${id}?`))
330
+ return;
331
+ const res = await fetch(`${baseUrl(root)}/${encodeURIComponent(id)}`, {
332
+ method: "DELETE"
333
+ });
334
+ if (res.ok)
335
+ refresh(root);
336
+ }
337
+ async function bulkDelete(root) {
338
+ const ids = selectedIds(root);
339
+ if (!ids.length)
340
+ return;
341
+ if (!window.confirm(`Delete ${ids.length} records?`))
342
+ return;
343
+ const base = baseUrl(root);
344
+ for (const id of ids) {
345
+ await fetch(`${base}/${encodeURIComponent(id)}`, { method: "DELETE" });
346
+ }
347
+ refresh(root);
348
+ }
349
+ async function bulkApply(root) {
350
+ const field = root.querySelector("[data-bulk-field]");
351
+ const input = root.querySelector("[data-bulk-value]");
352
+ const ids = selectedIds(root);
353
+ if (!field?.value || !input || !ids.length)
354
+ return;
355
+ const base = baseUrl(root);
356
+ const payload = JSON.stringify({ [field.value]: input.value });
357
+ for (const id of ids) {
358
+ await fetch(`${base}/${encodeURIComponent(id)}`, {
359
+ method: "PATCH",
360
+ headers: { "Content-Type": "application/json" },
361
+ body: payload
362
+ });
363
+ }
364
+ input.value = "";
365
+ refresh(root);
366
+ }
367
+ document.addEventListener("click", (e) => {
368
+ const t = e.target;
369
+ if (!t?.closest)
370
+ return;
371
+ const root = t.closest("[data-query-url]");
372
+ if (!root)
373
+ return;
374
+ const closer = t.closest("[data-close]");
375
+ if (closer) {
376
+ closer.closest("dialog")?.close();
377
+ return;
378
+ }
379
+ if (t.closest("[data-open-create]")) {
380
+ const dlg = document.getElementById("rack-create");
381
+ if (dlg) {
382
+ const cf = dlg.querySelector("[data-create-form]");
383
+ if (cf) {
384
+ cf.reset();
385
+ setErr(cf, "");
386
+ }
387
+ dlg.showModal?.();
388
+ }
389
+ return;
390
+ }
391
+ const eb = t.closest("[data-edit-id]");
392
+ if (eb) {
393
+ openEdit(root, eb.getAttribute("data-edit-id"));
394
+ return;
395
+ }
396
+ const dbtn = t.closest("[data-delete-id]");
397
+ if (dbtn) {
398
+ deleteOne(root, dbtn.getAttribute("data-delete-id"));
399
+ return;
400
+ }
401
+ if (t.closest("[data-bulk-delete]")) {
402
+ bulkDelete(root);
403
+ return;
404
+ }
405
+ if (t.closest("[data-bulk-apply]")) {
406
+ bulkApply(root);
407
+ return;
408
+ }
409
+ if (t.closest("[data-bulk-clear]")) {
410
+ Array.prototype.forEach.call(root.querySelectorAll("[data-select-row],[data-select-all]"), (c) => {
411
+ c.checked = false;
412
+ });
413
+ syncBulk(root);
414
+ return;
415
+ }
416
+ const a = t.closest("a[data-qlink]");
417
+ if (!a)
418
+ return;
419
+ e.preventDefault();
420
+ run(root, new URLSearchParams(a.getAttribute("href").replace(/^\?/, "")));
421
+ });
422
+ document.addEventListener("submit", (e) => {
423
+ const f = e.target;
424
+ if (!f || f.tagName !== "FORM")
425
+ return;
426
+ const root = f.closest?.("[data-query-url]");
427
+ if (!root)
428
+ return;
429
+ if (f.hasAttribute("data-create-form")) {
430
+ e.preventDefault();
431
+ createRecord(root, f);
432
+ return;
433
+ }
434
+ if (f.hasAttribute("data-edit-form")) {
435
+ e.preventDefault();
436
+ updateRecord(root, f);
437
+ return;
438
+ }
439
+ e.preventDefault();
440
+ const clean = new URLSearchParams;
441
+ const els = f.elements ?? [];
442
+ for (let i = 0;i < els.length; i++) {
443
+ const el = els[i];
444
+ if (!el.name || el.disabled)
445
+ continue;
446
+ if ((el.type === "checkbox" || el.type === "radio") && !el.checked)
447
+ continue;
448
+ if (el.value === "")
449
+ continue;
450
+ clean.append(el.name, el.value);
451
+ }
452
+ run(root, clean);
453
+ });
454
+ var debouncedRunForSearch = debounce((form, root) => {
455
+ const clean = new URLSearchParams;
456
+ const els = form.elements ?? [];
457
+ for (let i = 0;i < els.length; i++) {
458
+ const el = els[i];
459
+ if (!el.name || el.disabled)
460
+ continue;
461
+ if ((el.type === "checkbox" || el.type === "radio") && !el.checked)
462
+ continue;
463
+ if (el.value === "")
464
+ continue;
465
+ clean.append(el.name, el.value);
466
+ }
467
+ run(root, clean);
468
+ }, 350);
469
+ document.addEventListener("input", (e) => {
470
+ const t = e.target;
471
+ if (!t || !t.closest)
472
+ return;
473
+ const isSearch = t.getAttribute?.("data-search-input") !== null || t.getAttribute?.("name") === "search";
474
+ if (!isSearch)
475
+ return;
476
+ const form = t.closest("form");
477
+ const root = t.closest("[data-query-url]") || form?.closest?.("[data-query-url]");
478
+ if (!form || !root)
479
+ return;
480
+ debouncedRunForSearch(form, root);
481
+ });
482
+ document.addEventListener("change", (e) => {
483
+ const t = e.target;
484
+ if (!t?.closest)
485
+ return;
486
+ const root = t.closest("[data-query-url]");
487
+ if (!root)
488
+ return;
489
+ const all = t.hasAttribute("data-select-all");
490
+ if (!all && !t.hasAttribute("data-select-row"))
491
+ return;
492
+ if (all) {
493
+ Array.prototype.forEach.call(root.querySelectorAll("[data-select-row]"), (c) => {
494
+ c.checked = t.checked;
495
+ });
496
+ }
497
+ syncBulk(root);
498
+ });
@@ -1 +1,38 @@
1
- var e=`koran-theme`;function t(){try{let t=localStorage.getItem(e);return t===`dark`||t===`light`?t:null}catch{return null}}function n(){try{return typeof window.matchMedia==`function`&&window.matchMedia(`(prefers-color-scheme: dark)`).matches}catch{return!1}}function r(){let t=document.documentElement,n=t.classList.toggle(`dark`);t.classList.toggle(`light`,!n);try{localStorage.setItem(e,n?`dark`:`light`)}catch{}}function i(){let e=t();e?document.documentElement.classList.add(e):n()&&document.documentElement.classList.add(`dark`),document.getElementById(`koran-theme-toggle`)?.addEventListener(`click`,r),window.__koranToggle=r}i();
1
+ // src/react/page/client/theme.ts
2
+ var STORAGE_KEY = "koran-theme";
3
+ function storedTheme() {
4
+ try {
5
+ const value = localStorage.getItem(STORAGE_KEY);
6
+ return value === "dark" || value === "light" ? value : null;
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+ function systemDark() {
12
+ try {
13
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-color-scheme: dark)").matches;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+ function toggleTheme() {
19
+ const root = document.documentElement;
20
+ const dark = root.classList.toggle("dark");
21
+ root.classList.toggle("light", !dark);
22
+ try {
23
+ localStorage.setItem(STORAGE_KEY, dark ? "dark" : "light");
24
+ } catch {}
25
+ }
26
+ function init() {
27
+ const stored = storedTheme();
28
+ if (stored)
29
+ document.documentElement.classList.add(stored);
30
+ else if (systemDark())
31
+ document.documentElement.classList.add("dark");
32
+ document.getElementById("koran-theme-toggle")?.addEventListener("click", toggleTheme);
33
+ window.__koranToggle = toggleTheme;
34
+ }
35
+ init();
36
+ export {
37
+ toggleTheme
38
+ };
@@ -686,6 +686,9 @@ function isNull(value) {
686
686
  function like(column, value) {
687
687
  return sql`${column} like ${value}`;
688
688
  }
689
+ function ilike(column, value) {
690
+ return sql`${column} ilike ${value}`;
691
+ }
689
692
 
690
693
  // node_modules/drizzle-orm/sql/expressions/select.js
691
694
  function asc(column) {
@@ -763,7 +766,16 @@ function whereFor(table, query, opts, withSoftDelete) {
763
766
  }
764
767
  if (query.search && query.search.fields.length > 0) {
765
768
  const pattern = `%${escapeLike(query.search.value)}%`;
766
- const ors = query.search.fields.map((f) => table[f] ? like(table[f], pattern) : undefined).filter((c) => c !== undefined);
769
+ const ors = query.search.fields.map((f) => {
770
+ const col = table[f];
771
+ if (!col)
772
+ return;
773
+ try {
774
+ return typeof ilike === "function" ? ilike(col, pattern) : like(col, pattern);
775
+ } catch {
776
+ return like(col, pattern);
777
+ }
778
+ }).filter((c) => c !== undefined);
767
779
  if (ors.length > 0)
768
780
  conds.push(or(...ors));
769
781
  }
@@ -2,7 +2,7 @@ import Elysia from "elysia";
2
2
  import { ReactRack } from "./types";
3
3
  export declare function page<Props = Record<string, never>>(path: string, props?: Props): ReactRack.PageDescriptor<Props>;
4
4
  export declare const pages: ReactRack.PageRegistry;
5
- export declare function reactPlugin(options: ReactRack.ReactPluginOptions): Elysia<"", {
5
+ export declare function reactPlugin(options?: ReactRack.ReactPluginOptions): Elysia<"", {
6
6
  decorator: {
7
7
  react: {
8
8
  render: (path: string, props: unknown) => Promise<Response>;
@@ -152,11 +152,19 @@ function page(path, props) {
152
152
  };
153
153
  }
154
154
  var pages = {
155
- "/dashboard": () => import("../Dashboard-2j561p7c.js"),
156
- "/panel": () => import("../Panel-fbp3wrsw.js")
155
+ "/dashboard": () => import("../Dashboard-s8nfyf7s.js"),
156
+ "/panel": () => import("../Panel-b7jhgvnm.js")
157
157
  };
158
- function reactPlugin(options) {
159
- const render = createRenderer(options.pages);
158
+ var defaultPages = pages;
159
+ function mergeRegistries(base, extra) {
160
+ if (!extra)
161
+ return { ...base };
162
+ const list = Array.isArray(extra) ? extra : [extra];
163
+ return list.reduce((acc, r) => ({ ...acc, ...r }), { ...base });
164
+ }
165
+ function reactPlugin(options = {}) {
166
+ const registry = mergeRegistries(defaultPages, options.pages);
167
+ const render = createRenderer(registry);
160
168
  const panelCssOverride = pickPanelCssOption(options);
161
169
  let cachedCustomCss = null;
162
170
  let cachedCustomCssLoaded = false;
@@ -1,7 +1,4 @@
1
- import { type RackRegistration } from "../../rack/registry";
2
1
  export interface DashboardProps {
3
2
  name?: string;
4
- resource?: string;
5
- racks?: RackRegistration[];
6
3
  }
7
4
  export default function Dashboard(props: DashboardProps): Promise<import("react").JSX.Element>;
@@ -16,7 +16,7 @@ export declare namespace ReactRack {
16
16
  content?: string;
17
17
  };
18
18
  interface ReactPluginOptions {
19
- pages: PageRegistry;
19
+ pages?: PageRegistry | PageRegistry[];
20
20
  /**
21
21
  * Override `panel.css` served at `/__rack/panel.css`.
22
22
  * - `string`: file path if the file exists, otherwise raw CSS string.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elysia-rack",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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",
@@ -54,8 +54,9 @@
54
54
  "dev": "bun run src/playground.ts",
55
55
  "test": "bun test --coverage",
56
56
  "typecheck": "tsc --noEmit",
57
- "build": "bun run build:js && bun run build:dts && mkdir -p dist && cp src/react/app.html dist/app.html 2>/dev/null || true",
57
+ "build": "bun run build:js && bun run build:dts && bun run build:client",
58
58
  "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",
59
+ "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",
59
60
  "build:dts": "tsc -p tsconfig.build.json",
60
61
  "prepublishOnly": "bun run typecheck && bun run build"
61
62
  },