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.
@@ -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
+ })()