aiplang 1.0.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/FLUX-PROJECT-KNOWLEDGE.md +234 -0
- package/README.md +215 -0
- package/bin/aiplang.js +572 -0
- package/bin/flux.js +572 -0
- package/package.json +38 -0
- package/runtime/aiplang-hydrate.js +473 -0
- package/runtime/aiplang-runtime.js +1100 -0
- package/runtime/flux-hydrate.js +473 -0
- package/runtime/flux-runtime.js +1100 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* flux-hydrate.js — FLUX Hydration Runtime v1.1
|
|
3
|
+
* Handles: state, queries, table, list, form, if, edit, delete, btn, select
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict'
|
|
8
|
+
|
|
9
|
+
const cfg = window.__FLUX_PAGE__
|
|
10
|
+
if (!cfg) return
|
|
11
|
+
|
|
12
|
+
// ── State ────────────────────────────────────────────────────────
|
|
13
|
+
const _state = {}
|
|
14
|
+
const _watchers = {}
|
|
15
|
+
|
|
16
|
+
for (const [k, v] of Object.entries(cfg.state || {})) {
|
|
17
|
+
try { _state[k] = JSON.parse(v) } catch { _state[k] = v }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function get(key) { return _state[key] }
|
|
21
|
+
|
|
22
|
+
function set(key, value) {
|
|
23
|
+
if (JSON.stringify(_state[key]) === JSON.stringify(value)) return
|
|
24
|
+
_state[key] = value
|
|
25
|
+
notify(key)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function watch(key, cb) {
|
|
29
|
+
if (!_watchers[key]) _watchers[key] = []
|
|
30
|
+
_watchers[key].push(cb)
|
|
31
|
+
return () => { _watchers[key] = _watchers[key].filter(f => f !== cb) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function notify(key) {
|
|
35
|
+
;(_watchers[key] || []).forEach(cb => cb(_state[key]))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolve(str) {
|
|
39
|
+
if (!str || (!str.includes('@') && !str.includes('$'))) return str
|
|
40
|
+
return str.replace(/[@$][a-zA-Z_][a-zA-Z0-9_.]*/g, m => {
|
|
41
|
+
const path = m.slice(1).split('.')
|
|
42
|
+
let val = _state[path[0]]
|
|
43
|
+
for (let i = 1; i < path.length; i++) val = val?.[path[i]]
|
|
44
|
+
return val == null ? '' : String(val)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Resolve path with row data: /api/users/{id} + {id:1} → /api/users/1
|
|
49
|
+
function resolvePath(tmpl, row) {
|
|
50
|
+
return tmpl.replace(/\{([^}]+)\}/g, (_, k) => row?.[k] ?? get(k) ?? '')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Query Engine ─────────────────────────────────────────────────
|
|
54
|
+
const _intervals = []
|
|
55
|
+
|
|
56
|
+
async function runQuery(q) {
|
|
57
|
+
const path = resolve(q.path)
|
|
58
|
+
const opts = { method: q.method, headers: { 'Content-Type': 'application/json' } }
|
|
59
|
+
if (q.body) opts.body = JSON.stringify(q.body)
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(path, opts)
|
|
62
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
63
|
+
const data = await res.json()
|
|
64
|
+
applyAction(data, q.target, q.action)
|
|
65
|
+
return data
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.warn('[FLUX]', q.method, path, e.message)
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function applyAction(data, target, action) {
|
|
73
|
+
if (!target && !action) return
|
|
74
|
+
if (action) {
|
|
75
|
+
if (action.startsWith('redirect ')) { window.location.href = action.slice(9).trim(); return }
|
|
76
|
+
if (action === 'reload') { window.location.reload(); return }
|
|
77
|
+
const pm = action.match(/^@([a-zA-Z_]+)\.push\(\$result\)$/)
|
|
78
|
+
if (pm) { set(pm[1], [...(get(pm[1]) || []), data]); return }
|
|
79
|
+
const fm = action.match(/^@([a-zA-Z_]+)\.filter\((.+)\)$/)
|
|
80
|
+
if (fm) { try { set(fm[1], (get(fm[1])||[]).filter(new Function('item', `return (${fm[2]})(item)`))) } catch {} return }
|
|
81
|
+
const am = action.match(/^@([a-zA-Z_]+)\s*=\s*\$result$/)
|
|
82
|
+
if (am) { set(am[1], data); return }
|
|
83
|
+
}
|
|
84
|
+
if (target && target.startsWith('@')) set(target.slice(1), data)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function mountQueries() {
|
|
88
|
+
for (const q of cfg.queries || []) {
|
|
89
|
+
if (q.trigger === 'mount') { runQuery(q) }
|
|
90
|
+
else if (q.trigger === 'interval') {
|
|
91
|
+
runQuery(q)
|
|
92
|
+
_intervals.push(setInterval(() => runQuery(q), q.interval))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── HTTP helper ──────────────────────────────────────────────────
|
|
98
|
+
async function http(method, path, body) {
|
|
99
|
+
const opts = { method, headers: { 'Content-Type': 'application/json' } }
|
|
100
|
+
if (body) opts.body = JSON.stringify(body)
|
|
101
|
+
const res = await fetch(path, opts)
|
|
102
|
+
const data = await res.json().catch(() => ({}))
|
|
103
|
+
return { ok: res.ok, status: res.status, data }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Toast notifications ──────────────────────────────────────────
|
|
107
|
+
function toast(msg, type) {
|
|
108
|
+
const t = document.createElement('div')
|
|
109
|
+
t.textContent = msg
|
|
110
|
+
t.style.cssText = `
|
|
111
|
+
position:fixed;bottom:1.5rem;right:1.5rem;z-index:9999;
|
|
112
|
+
padding:.75rem 1.25rem;border-radius:.625rem;font-size:.8125rem;font-weight:600;
|
|
113
|
+
font-family:-apple-system,'Segoe UI',system-ui,sans-serif;
|
|
114
|
+
box-shadow:0 8px 24px rgba(0,0,0,.3);
|
|
115
|
+
transition:opacity .3s;
|
|
116
|
+
background:${type === 'ok' ? '#22c55e' : type === 'err' ? '#ef4444' : '#334155'};
|
|
117
|
+
color:#fff;
|
|
118
|
+
`
|
|
119
|
+
document.body.appendChild(t)
|
|
120
|
+
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300) }, 2500)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Confirm modal ────────────────────────────────────────────────
|
|
124
|
+
function confirm(msg) {
|
|
125
|
+
return new Promise(resolve => {
|
|
126
|
+
const overlay = document.createElement('div')
|
|
127
|
+
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:9998;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px)'
|
|
128
|
+
const box = document.createElement('div')
|
|
129
|
+
box.style.cssText = 'background:#0f172a;border:1px solid #1e293b;border-radius:1rem;padding:2rem;max-width:320px;width:90%;text-align:center;font-family:-apple-system,system-ui,sans-serif'
|
|
130
|
+
box.innerHTML = `
|
|
131
|
+
<p style="color:#f1f5f9;font-size:.9375rem;margin-bottom:1.5rem;line-height:1.6">${msg}</p>
|
|
132
|
+
<div style="display:flex;gap:.75rem;justify-content:center">
|
|
133
|
+
<button id="fx-cancel" style="flex:1;padding:.75rem;border:1px solid #1e293b;background:transparent;color:#94a3b8;border-radius:.5rem;cursor:pointer;font-size:.875rem">Cancel</button>
|
|
134
|
+
<button id="fx-confirm" style="flex:1;padding:.75rem;border:none;background:#ef4444;color:#fff;border-radius:.5rem;cursor:pointer;font-size:.875rem;font-weight:700">Delete</button>
|
|
135
|
+
</div>
|
|
136
|
+
`
|
|
137
|
+
overlay.appendChild(box)
|
|
138
|
+
document.body.appendChild(overlay)
|
|
139
|
+
box.querySelector('#fx-cancel').onclick = () => { overlay.remove(); resolve(false) }
|
|
140
|
+
box.querySelector('#fx-confirm').onclick = () => { overlay.remove(); resolve(true) }
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Edit modal ───────────────────────────────────────────────────
|
|
145
|
+
function editModal(row, cols, path, method, stateKey) {
|
|
146
|
+
return new Promise(resolve => {
|
|
147
|
+
const overlay = document.createElement('div')
|
|
148
|
+
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:9998;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px)'
|
|
149
|
+
const box = document.createElement('div')
|
|
150
|
+
box.style.cssText = 'background:#0f172a;border:1px solid #1e293b;border-radius:1rem;padding:2rem;max-width:400px;width:90%;font-family:-apple-system,system-ui,sans-serif'
|
|
151
|
+
|
|
152
|
+
const fields = cols.map(col => `
|
|
153
|
+
<div style="margin-bottom:1rem">
|
|
154
|
+
<label style="display:block;font-size:.8rem;color:#94a3b8;font-weight:600;margin-bottom:.4rem">${col.label}</label>
|
|
155
|
+
<input name="${col.key}" value="${row[col.key] || ''}"
|
|
156
|
+
style="width:100%;padding:.75rem 1rem;background:#020617;border:1px solid #1e293b;color:#f1f5f9;border-radius:.5rem;font-size:.875rem;outline:none;box-sizing:border-box">
|
|
157
|
+
</div>
|
|
158
|
+
`).join('')
|
|
159
|
+
|
|
160
|
+
box.innerHTML = `
|
|
161
|
+
<h3 style="color:#f1f5f9;font-size:1rem;font-weight:700;margin-bottom:1.5rem">Edit record</h3>
|
|
162
|
+
${fields}
|
|
163
|
+
<div id="fx-edit-msg" style="font-size:.8rem;min-height:1.25rem;margin-bottom:.75rem;text-align:center"></div>
|
|
164
|
+
<div style="display:flex;gap:.75rem">
|
|
165
|
+
<button id="fx-edit-cancel" style="flex:1;padding:.75rem;border:1px solid #1e293b;background:transparent;color:#94a3b8;border-radius:.5rem;cursor:pointer;font-size:.875rem">Cancel</button>
|
|
166
|
+
<button id="fx-edit-save" style="flex:1;padding:.75rem;border:none;background:#2563eb;color:#fff;border-radius:.5rem;cursor:pointer;font-size:.875rem;font-weight:700">Save</button>
|
|
167
|
+
</div>
|
|
168
|
+
`
|
|
169
|
+
overlay.appendChild(box)
|
|
170
|
+
document.body.appendChild(overlay)
|
|
171
|
+
|
|
172
|
+
box.querySelector('#fx-edit-cancel').onclick = () => { overlay.remove(); resolve(null) }
|
|
173
|
+
box.querySelector('#fx-edit-save').onclick = async () => {
|
|
174
|
+
const btn = box.querySelector('#fx-edit-save')
|
|
175
|
+
const msg = box.querySelector('#fx-edit-msg')
|
|
176
|
+
btn.disabled = true; btn.textContent = 'Saving...'
|
|
177
|
+
const body = {}
|
|
178
|
+
box.querySelectorAll('input').forEach(inp => body[inp.name] = inp.value)
|
|
179
|
+
const { ok, data } = await http(method, resolvePath(path, row), body)
|
|
180
|
+
if (ok) {
|
|
181
|
+
overlay.remove()
|
|
182
|
+
resolve(data)
|
|
183
|
+
toast('Saved', 'ok')
|
|
184
|
+
} else {
|
|
185
|
+
msg.style.color = '#f87171'
|
|
186
|
+
msg.textContent = data.message || data.error || 'Error saving'
|
|
187
|
+
btn.disabled = false; btn.textContent = 'Save'
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Hydrate tables with CRUD ─────────────────────────────────────
|
|
194
|
+
function hydrateTables() {
|
|
195
|
+
document.querySelectorAll('[data-fx-table]').forEach(tbl => {
|
|
196
|
+
const binding = tbl.getAttribute('data-fx-table')
|
|
197
|
+
const colsJSON = tbl.getAttribute('data-fx-cols')
|
|
198
|
+
const editPath = tbl.getAttribute('data-fx-edit') // e.g. /api/users/{id}
|
|
199
|
+
const editMethod= tbl.getAttribute('data-fx-edit-method') || 'PUT'
|
|
200
|
+
const delPath = tbl.getAttribute('data-fx-delete') // e.g. /api/users/{id}
|
|
201
|
+
const delKey = tbl.getAttribute('data-fx-delete-key') || 'id'
|
|
202
|
+
|
|
203
|
+
const cols = colsJSON ? JSON.parse(colsJSON) : []
|
|
204
|
+
const tbody = tbl.querySelector('tbody')
|
|
205
|
+
if (!tbody) return
|
|
206
|
+
|
|
207
|
+
// Add action column headers if needed
|
|
208
|
+
if ((editPath || delPath) && tbl.querySelector('thead tr')) {
|
|
209
|
+
const thead = tbl.querySelector('thead tr')
|
|
210
|
+
if (!thead.querySelector('.fx-th-actions')) {
|
|
211
|
+
const th = document.createElement('th')
|
|
212
|
+
th.className = 'fx-th fx-th-actions'
|
|
213
|
+
th.textContent = 'Actions'
|
|
214
|
+
thead.appendChild(th)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const render = () => {
|
|
219
|
+
const key = binding.startsWith('@') ? binding.slice(1) : binding
|
|
220
|
+
let rows = get(key)
|
|
221
|
+
if (!Array.isArray(rows)) rows = []
|
|
222
|
+
tbody.innerHTML = ''
|
|
223
|
+
|
|
224
|
+
if (!rows.length) {
|
|
225
|
+
const tr = document.createElement('tr')
|
|
226
|
+
const td = document.createElement('td')
|
|
227
|
+
td.colSpan = cols.length + (editPath || delPath ? 1 : 0)
|
|
228
|
+
td.className = 'fx-td-empty'
|
|
229
|
+
td.textContent = tbl.getAttribute('data-fx-empty') || 'No data.'
|
|
230
|
+
tr.appendChild(td); tbody.appendChild(tr)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
rows.forEach((row, idx) => {
|
|
235
|
+
const tr = document.createElement('tr')
|
|
236
|
+
tr.className = 'fx-tr'
|
|
237
|
+
|
|
238
|
+
// Data cells
|
|
239
|
+
for (const col of cols) {
|
|
240
|
+
const td = document.createElement('td')
|
|
241
|
+
td.className = 'fx-td'
|
|
242
|
+
td.textContent = row[col.key] != null ? row[col.key] : ''
|
|
243
|
+
tr.appendChild(td)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Action cell
|
|
247
|
+
if (editPath || delPath) {
|
|
248
|
+
const td = document.createElement('td')
|
|
249
|
+
td.className = 'fx-td fx-td-actions'
|
|
250
|
+
td.style.cssText = 'white-space:nowrap'
|
|
251
|
+
|
|
252
|
+
if (editPath) {
|
|
253
|
+
const btn = document.createElement('button')
|
|
254
|
+
btn.className = 'fx-action-btn fx-edit-btn'
|
|
255
|
+
btn.textContent = '✎ Edit'
|
|
256
|
+
btn.onclick = async () => {
|
|
257
|
+
const updated = await editModal(row, cols, editPath, editMethod, binding.slice(1))
|
|
258
|
+
if (!updated) return
|
|
259
|
+
const key = binding.slice(1)
|
|
260
|
+
const arr = [...(get(key) || [])]
|
|
261
|
+
arr[idx] = { ...row, ...updated }
|
|
262
|
+
set(key, arr)
|
|
263
|
+
}
|
|
264
|
+
td.appendChild(btn)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (delPath) {
|
|
268
|
+
const btn = document.createElement('button')
|
|
269
|
+
btn.className = 'fx-action-btn fx-delete-btn'
|
|
270
|
+
btn.textContent = '✕ Delete'
|
|
271
|
+
btn.onclick = async () => {
|
|
272
|
+
const ok = await confirm('Delete this record? This cannot be undone.')
|
|
273
|
+
if (!ok) return
|
|
274
|
+
const path = resolvePath(delPath, row)
|
|
275
|
+
const { ok: success, data } = await http('DELETE', path, null)
|
|
276
|
+
if (success) {
|
|
277
|
+
const key = binding.slice(1)
|
|
278
|
+
set(key, (get(key) || []).filter((_, i) => i !== idx))
|
|
279
|
+
toast('Deleted', 'ok')
|
|
280
|
+
} else {
|
|
281
|
+
toast(data.message || 'Error deleting', 'err')
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
td.appendChild(btn)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
tr.appendChild(td)
|
|
288
|
+
}
|
|
289
|
+
tbody.appendChild(tr)
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const stateKey = binding.startsWith('@') ? binding.slice(1) : binding
|
|
294
|
+
watch(stateKey, render)
|
|
295
|
+
render()
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Hydrate lists ────────────────────────────────────────────────
|
|
300
|
+
function hydrateLists() {
|
|
301
|
+
document.querySelectorAll('[data-fx-list]').forEach(wrap => {
|
|
302
|
+
const binding = wrap.getAttribute('data-fx-list')
|
|
303
|
+
const colsJSON = wrap.getAttribute('data-fx-cols')
|
|
304
|
+
const cols = colsJSON ? JSON.parse(colsJSON) : []
|
|
305
|
+
|
|
306
|
+
const render = () => {
|
|
307
|
+
let items = get(binding.startsWith('@') ? binding.slice(1) : binding)
|
|
308
|
+
if (!Array.isArray(items)) items = []
|
|
309
|
+
wrap.innerHTML = ''
|
|
310
|
+
for (const item of items) {
|
|
311
|
+
const card = document.createElement('div')
|
|
312
|
+
card.className = 'fx-list-item'
|
|
313
|
+
for (const col of cols) {
|
|
314
|
+
const p = document.createElement('p')
|
|
315
|
+
p.className = 'fx-list-field'
|
|
316
|
+
p.textContent = item[col] || ''
|
|
317
|
+
card.appendChild(p)
|
|
318
|
+
}
|
|
319
|
+
wrap.appendChild(card)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
watch(binding.slice(1), render)
|
|
324
|
+
render()
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Hydrate forms ─────────────────────────────────────────────────
|
|
329
|
+
function hydrateForms() {
|
|
330
|
+
document.querySelectorAll('[data-fx-form]').forEach(form => {
|
|
331
|
+
const path = form.getAttribute('data-fx-form')
|
|
332
|
+
const method = form.getAttribute('data-fx-method') || 'POST'
|
|
333
|
+
const action = form.getAttribute('data-fx-action') || ''
|
|
334
|
+
const msg = form.querySelector('.fx-form-msg')
|
|
335
|
+
const btn = form.querySelector('button[type="submit"]')
|
|
336
|
+
|
|
337
|
+
form.addEventListener('submit', async e => {
|
|
338
|
+
e.preventDefault()
|
|
339
|
+
if (btn) { btn.disabled = true; btn.textContent = 'Loading...' }
|
|
340
|
+
if (msg) { msg.className = 'fx-form-msg'; msg.textContent = '' }
|
|
341
|
+
|
|
342
|
+
const body = {}
|
|
343
|
+
for (const inp of form.querySelectorAll('input,select,textarea')) {
|
|
344
|
+
if (inp.name) body[inp.name] = inp.value
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const { ok, data } = await http(method, resolve(path), body)
|
|
348
|
+
if (ok) {
|
|
349
|
+
if (action) applyAction(data, null, action)
|
|
350
|
+
if (msg) { msg.className = 'fx-form-msg fx-form-ok'; msg.textContent = 'Done!' }
|
|
351
|
+
toast('Saved successfully', 'ok')
|
|
352
|
+
form.reset()
|
|
353
|
+
} else {
|
|
354
|
+
const errMsg = data.message || data.error || 'Error. Try again.'
|
|
355
|
+
if (msg) { msg.className = 'fx-form-msg fx-form-err'; msg.textContent = errMsg }
|
|
356
|
+
toast(errMsg, 'err')
|
|
357
|
+
}
|
|
358
|
+
if (btn) { btn.disabled = false; btn.textContent = 'Submit' }
|
|
359
|
+
})
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Hydrate btns ──────────────────────────────────────────────────
|
|
364
|
+
// <button data-fx-btn="/api/path" data-fx-method="POST" data-fx-action="...">
|
|
365
|
+
function hydrateBtns() {
|
|
366
|
+
document.querySelectorAll('[data-fx-btn]').forEach(btn => {
|
|
367
|
+
const path = btn.getAttribute('data-fx-btn')
|
|
368
|
+
const method = btn.getAttribute('data-fx-method') || 'POST'
|
|
369
|
+
const action = btn.getAttribute('data-fx-action') || ''
|
|
370
|
+
const confirm_msg = btn.getAttribute('data-fx-confirm')
|
|
371
|
+
const origText = btn.textContent
|
|
372
|
+
|
|
373
|
+
btn.addEventListener('click', async () => {
|
|
374
|
+
if (confirm_msg) {
|
|
375
|
+
const ok = await confirm(confirm_msg)
|
|
376
|
+
if (!ok) return
|
|
377
|
+
}
|
|
378
|
+
btn.disabled = true; btn.textContent = 'Loading...'
|
|
379
|
+
const { ok, data } = await http(method, resolve(path), null)
|
|
380
|
+
if (ok) {
|
|
381
|
+
if (action) applyAction(data, null, action)
|
|
382
|
+
toast('Done', 'ok')
|
|
383
|
+
} else {
|
|
384
|
+
toast(data.message || data.error || 'Error', 'err')
|
|
385
|
+
}
|
|
386
|
+
btn.disabled = false; btn.textContent = origText
|
|
387
|
+
})
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── Hydrate select dropdowns ──────────────────────────────────────
|
|
392
|
+
// <select data-fx-model="@filter"> sets @filter on change
|
|
393
|
+
function hydrateSelects() {
|
|
394
|
+
document.querySelectorAll('[data-fx-model]').forEach(sel => {
|
|
395
|
+
const binding = sel.getAttribute('data-fx-model')
|
|
396
|
+
const key = binding.replace(/^[@$]/, '')
|
|
397
|
+
sel.value = get(key) || ''
|
|
398
|
+
sel.addEventListener('change', () => set(key, sel.value))
|
|
399
|
+
watch(key, v => { if (sel.value !== String(v)) sel.value = v })
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── Hydrate text bindings ─────────────────────────────────────────
|
|
404
|
+
function hydrateBindings() {
|
|
405
|
+
document.querySelectorAll('[data-fx-bind]').forEach(el => {
|
|
406
|
+
const expr = el.getAttribute('data-fx-bind')
|
|
407
|
+
const keys = (expr.match(/[@$][a-zA-Z_][a-zA-Z0-9_.]*/g) || []).map(m => m.slice(1).split('.')[0])
|
|
408
|
+
const update = () => { el.textContent = resolve(expr) }
|
|
409
|
+
for (const key of keys) watch(key, update)
|
|
410
|
+
update()
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── Hydrate conditionals ──────────────────────────────────────────
|
|
415
|
+
function hydrateIfs() {
|
|
416
|
+
document.querySelectorAll('[data-fx-if]').forEach(wrap => {
|
|
417
|
+
const cond = wrap.getAttribute('data-fx-if')
|
|
418
|
+
const neg = cond.startsWith('!')
|
|
419
|
+
const expr = neg ? cond.slice(1) : cond
|
|
420
|
+
|
|
421
|
+
const evalCond = () => {
|
|
422
|
+
const path = expr.replace(/^[@$]/, '').split('.')
|
|
423
|
+
let val = get(path[0])
|
|
424
|
+
for (let i = 1; i < path.length; i++) val = val?.[path[i]]
|
|
425
|
+
const t = Array.isArray(val) ? val.length > 0 : !!val
|
|
426
|
+
return neg ? !t : t
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const update = () => { wrap.style.display = evalCond() ? '' : 'none' }
|
|
430
|
+
watch(expr.replace(/^[@$!]/, '').split('.')[0], update)
|
|
431
|
+
update()
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── Inject action column CSS ──────────────────────────────────────
|
|
436
|
+
function injectActionCSS() {
|
|
437
|
+
const style = document.createElement('style')
|
|
438
|
+
style.textContent = `
|
|
439
|
+
.fx-td-actions { padding: .5rem 1rem !important; }
|
|
440
|
+
.fx-action-btn {
|
|
441
|
+
border: none; cursor: pointer; font-size: .75rem; font-weight: 600;
|
|
442
|
+
padding: .3rem .75rem; border-radius: .375rem; margin-right: .375rem;
|
|
443
|
+
font-family: inherit; transition: opacity .15s, transform .1s;
|
|
444
|
+
}
|
|
445
|
+
.fx-action-btn:hover { opacity: .85; transform: translateY(-1px); }
|
|
446
|
+
.fx-action-btn:disabled { opacity: .4; cursor: not-allowed; transform: none; }
|
|
447
|
+
.fx-edit-btn { background: #1e40af; color: #93c5fd; }
|
|
448
|
+
.fx-delete-btn { background: #7f1d1d; color: #fca5a5; }
|
|
449
|
+
.fx-th-actions { color: #475569 !important; }
|
|
450
|
+
`
|
|
451
|
+
document.head.appendChild(style)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── Boot ──────────────────────────────────────────────────────────
|
|
455
|
+
function boot() {
|
|
456
|
+
injectActionCSS()
|
|
457
|
+
hydrateBindings()
|
|
458
|
+
hydrateTables()
|
|
459
|
+
hydrateLists()
|
|
460
|
+
hydrateForms()
|
|
461
|
+
hydrateBtns()
|
|
462
|
+
hydrateSelects()
|
|
463
|
+
hydrateIfs()
|
|
464
|
+
mountQueries()
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (document.readyState === 'loading') {
|
|
468
|
+
document.addEventListener('DOMContentLoaded', boot)
|
|
469
|
+
} else {
|
|
470
|
+
boot()
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
})()
|