aiplang 2.10.0 → 2.10.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/bin/aiplang.js +1 -1
- package/package.json +1 -1
- package/runtime/aiplang-hydrate.js +330 -31
- package/server/server.js +39 -8
package/bin/aiplang.js
CHANGED
|
@@ -5,7 +5,7 @@ const fs = require('fs')
|
|
|
5
5
|
const path = require('path')
|
|
6
6
|
const http = require('http')
|
|
7
7
|
|
|
8
|
-
const VERSION = '2.10.
|
|
8
|
+
const VERSION = '2.10.2'
|
|
9
9
|
const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
|
|
10
10
|
const cmd = process.argv[2]
|
|
11
11
|
const args = process.argv.slice(3)
|
package/package.json
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* aiplang-hydrate.js — aiplang Hydration Runtime v2.1
|
|
3
|
-
* Handles: state, queries, table, list, form, if, edit, delete, btn, select
|
|
4
|
-
*/
|
|
1
|
+
|
|
5
2
|
|
|
6
3
|
(function () {
|
|
7
4
|
'use strict'
|
|
@@ -9,7 +6,6 @@
|
|
|
9
6
|
const cfg = window.__AIPLANG_PAGE__
|
|
10
7
|
if (!cfg) return
|
|
11
8
|
|
|
12
|
-
// ── State ────────────────────────────────────────────────────────
|
|
13
9
|
const _state = {}
|
|
14
10
|
const _watchers = {}
|
|
15
11
|
|
|
@@ -31,7 +27,26 @@ function watch(key, cb) {
|
|
|
31
27
|
return () => { _watchers[key] = _watchers[key].filter(f => f !== cb) }
|
|
32
28
|
}
|
|
33
29
|
|
|
30
|
+
const _pending = new Set()
|
|
31
|
+
let _batchScheduled = false
|
|
32
|
+
|
|
33
|
+
function flushBatch() {
|
|
34
|
+
_batchScheduled = false
|
|
35
|
+
for (const key of _pending) {
|
|
36
|
+
;(_watchers[key] || []).forEach(cb => cb(_state[key]))
|
|
37
|
+
}
|
|
38
|
+
_pending.clear()
|
|
39
|
+
}
|
|
40
|
+
|
|
34
41
|
function notify(key) {
|
|
42
|
+
_pending.add(key)
|
|
43
|
+
if (!_batchScheduled) {
|
|
44
|
+
_batchScheduled = true
|
|
45
|
+
requestAnimationFrame(flushBatch)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function notifySync(key) {
|
|
35
50
|
;(_watchers[key] || []).forEach(cb => cb(_state[key]))
|
|
36
51
|
}
|
|
37
52
|
|
|
@@ -45,12 +60,10 @@ function resolve(str) {
|
|
|
45
60
|
})
|
|
46
61
|
}
|
|
47
62
|
|
|
48
|
-
// Resolve path with row data: /api/users/{id} + {id:1} → /api/users/1
|
|
49
63
|
function resolvePath(tmpl, row) {
|
|
50
64
|
return tmpl.replace(/\{([^}]+)\}/g, (_, k) => row?.[k] ?? get(k) ?? '')
|
|
51
65
|
}
|
|
52
66
|
|
|
53
|
-
// ── Query Engine ─────────────────────────────────────────────────
|
|
54
67
|
const _intervals = []
|
|
55
68
|
|
|
56
69
|
async function runQuery(q) {
|
|
@@ -78,11 +91,11 @@ function applyAction(data, target, action) {
|
|
|
78
91
|
if (pm) { set(pm[1], [...(get(pm[1]) || []), data]); return }
|
|
79
92
|
const fm = action.match(/^@([a-zA-Z_]+)\.filter\((.+)\)$/)
|
|
80
93
|
if (fm) {
|
|
81
|
-
|
|
94
|
+
|
|
82
95
|
try {
|
|
83
96
|
const expr = fm[2].trim()
|
|
84
97
|
const filtered = (get(fm[1]) || []).filter(item => {
|
|
85
|
-
|
|
98
|
+
|
|
86
99
|
const eq = expr.match(/^([a-zA-Z_.]+)\s*(!?=)\s*(.+)$/)
|
|
87
100
|
if (eq) {
|
|
88
101
|
const [, field, op, val] = eq
|
|
@@ -114,7 +127,6 @@ function mountQueries() {
|
|
|
114
127
|
}
|
|
115
128
|
}
|
|
116
129
|
|
|
117
|
-
// ── HTTP helper ──────────────────────────────────────────────────
|
|
118
130
|
async function http(method, path, body) {
|
|
119
131
|
const opts = { method, headers: { 'Content-Type': 'application/json' } }
|
|
120
132
|
if (body) opts.body = JSON.stringify(body)
|
|
@@ -123,7 +135,6 @@ async function http(method, path, body) {
|
|
|
123
135
|
return { ok: res.ok, status: res.status, data }
|
|
124
136
|
}
|
|
125
137
|
|
|
126
|
-
// ── Toast notifications ──────────────────────────────────────────
|
|
127
138
|
function toast(msg, type) {
|
|
128
139
|
const t = document.createElement('div')
|
|
129
140
|
t.textContent = msg
|
|
@@ -140,7 +151,6 @@ function toast(msg, type) {
|
|
|
140
151
|
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300) }, 2500)
|
|
141
152
|
}
|
|
142
153
|
|
|
143
|
-
// ── Confirm modal ────────────────────────────────────────────────
|
|
144
154
|
function confirm(msg) {
|
|
145
155
|
return new Promise(resolve => {
|
|
146
156
|
const overlay = document.createElement('div')
|
|
@@ -161,7 +171,6 @@ function confirm(msg) {
|
|
|
161
171
|
})
|
|
162
172
|
}
|
|
163
173
|
|
|
164
|
-
// ── Edit modal ───────────────────────────────────────────────────
|
|
165
174
|
function editModal(row, cols, path, method, stateKey) {
|
|
166
175
|
return new Promise(resolve => {
|
|
167
176
|
const overlay = document.createElement('div')
|
|
@@ -210,21 +219,19 @@ function editModal(row, cols, path, method, stateKey) {
|
|
|
210
219
|
})
|
|
211
220
|
}
|
|
212
221
|
|
|
213
|
-
// ── Hydrate tables with CRUD ─────────────────────────────────────
|
|
214
222
|
function hydrateTables() {
|
|
215
223
|
document.querySelectorAll('[data-fx-table]').forEach(tbl => {
|
|
216
224
|
const binding = tbl.getAttribute('data-fx-table')
|
|
217
225
|
const colsJSON = tbl.getAttribute('data-fx-cols')
|
|
218
|
-
const editPath = tbl.getAttribute('data-fx-edit')
|
|
226
|
+
const editPath = tbl.getAttribute('data-fx-edit')
|
|
219
227
|
const editMethod= tbl.getAttribute('data-fx-edit-method') || 'PUT'
|
|
220
|
-
const delPath = tbl.getAttribute('data-fx-delete')
|
|
228
|
+
const delPath = tbl.getAttribute('data-fx-delete')
|
|
221
229
|
const delKey = tbl.getAttribute('data-fx-delete-key') || 'id'
|
|
222
230
|
|
|
223
231
|
const cols = colsJSON ? JSON.parse(colsJSON) : []
|
|
224
232
|
const tbody = tbl.querySelector('tbody')
|
|
225
233
|
if (!tbody) return
|
|
226
234
|
|
|
227
|
-
// Add action column headers if needed
|
|
228
235
|
if ((editPath || delPath) && tbl.querySelector('thead tr')) {
|
|
229
236
|
const thead = tbl.querySelector('thead tr')
|
|
230
237
|
if (!thead.querySelector('.fx-th-actions')) {
|
|
@@ -251,11 +258,71 @@ function hydrateTables() {
|
|
|
251
258
|
return
|
|
252
259
|
}
|
|
253
260
|
|
|
254
|
-
|
|
261
|
+
const VIRTUAL_THRESHOLD = 80
|
|
262
|
+
const OVERSCAN = 8
|
|
263
|
+
const colSpanTotal = cols.length + (editPath || delPath ? 1 : 0)
|
|
264
|
+
const useVirtual = rows.length >= VIRTUAL_THRESHOLD
|
|
265
|
+
let rowHeights = null, totalHeight = 0, scrollListener = null
|
|
266
|
+
|
|
267
|
+
if (useVirtual) {
|
|
268
|
+
const wrapDiv = tbl.closest('.fx-table-wrap') || tbl.parentElement
|
|
269
|
+
wrapDiv.style.cssText += ';max-height:520px;overflow-y:auto;position:relative'
|
|
270
|
+
|
|
271
|
+
const measureRow = rows[0]
|
|
272
|
+
const tempTr = document.createElement('tr')
|
|
273
|
+
tempTr.style.visibility = 'hidden'
|
|
274
|
+
cols.forEach(col => {
|
|
275
|
+
const td = document.createElement('td'); td.className = 'fx-td'
|
|
276
|
+
td.textContent = measureRow[col.key] || ''; tempTr.appendChild(td)
|
|
277
|
+
})
|
|
278
|
+
tbody.appendChild(tempTr)
|
|
279
|
+
const rowH = Math.max(tempTr.getBoundingClientRect().height, 40) || 44
|
|
280
|
+
tbody.removeChild(tempTr)
|
|
281
|
+
|
|
282
|
+
const viewH = wrapDiv.clientHeight || 480
|
|
283
|
+
const visibleCount = Math.ceil(viewH / rowH) + OVERSCAN * 2
|
|
284
|
+
|
|
285
|
+
const renderVirtual = () => {
|
|
286
|
+
const scrollTop = wrapDiv.scrollTop
|
|
287
|
+
const startRaw = Math.floor(scrollTop / rowH)
|
|
288
|
+
const start = Math.max(0, startRaw - OVERSCAN)
|
|
289
|
+
const end = Math.min(rows.length - 1, start + visibleCount)
|
|
290
|
+
const paddingTop = start * rowH
|
|
291
|
+
const paddingBot = Math.max(0, (rows.length - end - 1) * rowH)
|
|
292
|
+
|
|
293
|
+
tbody.innerHTML = ''
|
|
294
|
+
|
|
295
|
+
if (paddingTop > 0) {
|
|
296
|
+
const tr = document.createElement('tr')
|
|
297
|
+
const td = document.createElement('td')
|
|
298
|
+
td.colSpan = colSpanTotal; td.style.cssText = 'height:'+paddingTop+'px;padding:0;border:none'
|
|
299
|
+
tr.appendChild(td); tbody.appendChild(tr)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (let i = start; i <= end; i++) renderRow(rows[i], i)
|
|
303
|
+
|
|
304
|
+
if (paddingBot > 0) {
|
|
305
|
+
const tr = document.createElement('tr')
|
|
306
|
+
const td = document.createElement('td')
|
|
307
|
+
td.colSpan = colSpanTotal; td.style.cssText = 'height:'+paddingBot+'px;padding:0;border:none'
|
|
308
|
+
tr.appendChild(td); tbody.appendChild(tr)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let rafPending = false
|
|
313
|
+
scrollListener = () => {
|
|
314
|
+
if (rafPending) return; rafPending = true
|
|
315
|
+
requestAnimationFrame(() => { rafPending = false; renderVirtual() })
|
|
316
|
+
}
|
|
317
|
+
wrapDiv.addEventListener('scroll', scrollListener, { passive: true })
|
|
318
|
+
renderVirtual()
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function renderRow(row, idx) {
|
|
255
323
|
const tr = document.createElement('tr')
|
|
256
324
|
tr.className = 'fx-tr'
|
|
257
325
|
|
|
258
|
-
// Data cells
|
|
259
326
|
for (const col of cols) {
|
|
260
327
|
const td = document.createElement('td')
|
|
261
328
|
td.className = 'fx-td'
|
|
@@ -263,7 +330,6 @@ function hydrateTables() {
|
|
|
263
330
|
tr.appendChild(td)
|
|
264
331
|
}
|
|
265
332
|
|
|
266
|
-
// Action cell
|
|
267
333
|
if (editPath || delPath) {
|
|
268
334
|
const td = document.createElement('td')
|
|
269
335
|
td.className = 'fx-td fx-td-actions'
|
|
@@ -307,7 +373,9 @@ function hydrateTables() {
|
|
|
307
373
|
tr.appendChild(td)
|
|
308
374
|
}
|
|
309
375
|
tbody.appendChild(tr)
|
|
310
|
-
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
rows.forEach((row, idx) => renderRow(row, idx))
|
|
311
379
|
}
|
|
312
380
|
|
|
313
381
|
const stateKey = binding.startsWith('@') ? binding.slice(1) : binding
|
|
@@ -316,7 +384,6 @@ function hydrateTables() {
|
|
|
316
384
|
})
|
|
317
385
|
}
|
|
318
386
|
|
|
319
|
-
// ── Hydrate lists ────────────────────────────────────────────────
|
|
320
387
|
function hydrateLists() {
|
|
321
388
|
document.querySelectorAll('[data-fx-list]').forEach(wrap => {
|
|
322
389
|
const binding = wrap.getAttribute('data-fx-list')
|
|
@@ -345,7 +412,6 @@ function hydrateLists() {
|
|
|
345
412
|
})
|
|
346
413
|
}
|
|
347
414
|
|
|
348
|
-
// ── Hydrate forms ─────────────────────────────────────────────────
|
|
349
415
|
function hydrateForms() {
|
|
350
416
|
document.querySelectorAll('[data-fx-form]').forEach(form => {
|
|
351
417
|
const path = form.getAttribute('data-fx-form')
|
|
@@ -380,8 +446,6 @@ function hydrateForms() {
|
|
|
380
446
|
})
|
|
381
447
|
}
|
|
382
448
|
|
|
383
|
-
// ── Hydrate btns ──────────────────────────────────────────────────
|
|
384
|
-
// <button data-fx-btn="/api/path" data-fx-method="POST" data-fx-action="...">
|
|
385
449
|
function hydrateBtns() {
|
|
386
450
|
document.querySelectorAll('[data-fx-btn]').forEach(btn => {
|
|
387
451
|
const path = btn.getAttribute('data-fx-btn')
|
|
@@ -408,8 +472,6 @@ function hydrateBtns() {
|
|
|
408
472
|
})
|
|
409
473
|
}
|
|
410
474
|
|
|
411
|
-
// ── Hydrate select dropdowns ──────────────────────────────────────
|
|
412
|
-
// <select data-fx-model="@filter"> sets @filter on change
|
|
413
475
|
function hydrateSelects() {
|
|
414
476
|
document.querySelectorAll('[data-fx-model]').forEach(sel => {
|
|
415
477
|
const binding = sel.getAttribute('data-fx-model')
|
|
@@ -420,7 +482,6 @@ function hydrateSelects() {
|
|
|
420
482
|
})
|
|
421
483
|
}
|
|
422
484
|
|
|
423
|
-
// ── Hydrate text bindings ─────────────────────────────────────────
|
|
424
485
|
function hydrateBindings() {
|
|
425
486
|
document.querySelectorAll('[data-fx-bind]').forEach(el => {
|
|
426
487
|
const expr = el.getAttribute('data-fx-bind')
|
|
@@ -431,7 +492,6 @@ function hydrateBindings() {
|
|
|
431
492
|
})
|
|
432
493
|
}
|
|
433
494
|
|
|
434
|
-
// ── Hydrate conditionals ──────────────────────────────────────────
|
|
435
495
|
function hydrateIfs() {
|
|
436
496
|
document.querySelectorAll('[data-fx-if]').forEach(wrap => {
|
|
437
497
|
const cond = wrap.getAttribute('data-fx-if')
|
|
@@ -452,7 +512,127 @@ function hydrateIfs() {
|
|
|
452
512
|
})
|
|
453
513
|
}
|
|
454
514
|
|
|
455
|
-
|
|
515
|
+
function initAnimations() {
|
|
516
|
+
|
|
517
|
+
const style = document.createElement('style')
|
|
518
|
+
style.textContent = `
|
|
519
|
+
@keyframes fx-blur-in { from{opacity:0;filter:blur(8px);transform:translateY(8px)} to{opacity:1;filter:blur(0);transform:none} }
|
|
520
|
+
@keyframes fx-fade-up { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:none} }
|
|
521
|
+
@keyframes fx-fade-in { from{opacity:0} to{opacity:1} }
|
|
522
|
+
@keyframes fx-slide-up { from{opacity:0;transform:translateY(40px)} to{opacity:1;transform:none} }
|
|
523
|
+
@keyframes fx-slide-left{ from{opacity:0;transform:translateX(30px)} to{opacity:1;transform:none} }
|
|
524
|
+
@keyframes fx-scale-in { from{opacity:0;transform:scale(.92)} to{opacity:1;transform:scale(1)} }
|
|
525
|
+
@keyframes fx-bounce { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-8px)} }
|
|
526
|
+
@keyframes fx-shake { 0%,100%{transform:translateX(0)} 25%{transform:translateX(-6px)} 75%{transform:translateX(6px)} }
|
|
527
|
+
@keyframes fx-pulse-ring{ 0%{box-shadow:0 0 0 0 rgba(99,102,241,.4)} 70%{box-shadow:0 0 0 12px transparent} 100%{box-shadow:0 0 0 0 transparent} }
|
|
528
|
+
@keyframes fx-count { from{opacity:0;transform:translateY(4px)} to{opacity:1;transform:none} }
|
|
529
|
+
|
|
530
|
+
[class*="fx-anim-"] { opacity: 0 }
|
|
531
|
+
[class*="fx-anim-"].fx-visible { animation-fill-mode: both; animation-timing-function: cubic-bezier(.4,0,.2,1) }
|
|
532
|
+
.fx-visible.fx-anim-blur-in { animation: fx-blur-in .7s both }
|
|
533
|
+
.fx-visible.fx-anim-fade-up { animation: fx-fade-up .6s both }
|
|
534
|
+
.fx-visible.fx-anim-fade-in { animation: fx-fade-in .5s both }
|
|
535
|
+
.fx-visible.fx-anim-slide-up { animation: fx-slide-up .65s both }
|
|
536
|
+
.fx-visible.fx-anim-slide-left{ animation: fx-slide-left .6s both }
|
|
537
|
+
.fx-visible.fx-anim-scale-in { animation: fx-scale-in .5s both }
|
|
538
|
+
.fx-visible.fx-anim-stagger > * { animation: fx-fade-up .5s both }
|
|
539
|
+
.fx-visible.fx-anim-stagger > *:nth-child(1) { animation-delay: 0s }
|
|
540
|
+
.fx-visible.fx-anim-stagger > *:nth-child(2) { animation-delay: .1s }
|
|
541
|
+
.fx-visible.fx-anim-stagger > *:nth-child(3) { animation-delay: .2s }
|
|
542
|
+
.fx-visible.fx-anim-stagger > *:nth-child(4) { animation-delay: .3s }
|
|
543
|
+
.fx-visible.fx-anim-stagger > *:nth-child(5) { animation-delay: .4s }
|
|
544
|
+
.fx-visible.fx-anim-stagger > *:nth-child(6) { animation-delay: .5s }
|
|
545
|
+
.fx-anim-bounce { animation: fx-bounce 1.5s ease-in-out infinite !important; opacity: 1 !important }
|
|
546
|
+
.fx-anim-pulse { animation: fx-pulse-ring 2s ease infinite !important; opacity: 1 !important }
|
|
547
|
+
`
|
|
548
|
+
document.head.appendChild(style)
|
|
549
|
+
|
|
550
|
+
const observer = new IntersectionObserver((entries) => {
|
|
551
|
+
entries.forEach(entry => {
|
|
552
|
+
if (entry.isIntersecting) {
|
|
553
|
+
entry.target.classList.add('fx-visible')
|
|
554
|
+
observer.unobserve(entry.target)
|
|
555
|
+
}
|
|
556
|
+
})
|
|
557
|
+
}, { threshold: 0.12, rootMargin: '0px 0px -30px 0px' })
|
|
558
|
+
|
|
559
|
+
document.querySelectorAll('[class*="fx-anim-"]').forEach(el => {
|
|
560
|
+
|
|
561
|
+
if (el.classList.contains('fx-anim-bounce') || el.classList.contains('fx-anim-pulse')) {
|
|
562
|
+
el.classList.add('fx-visible'); return
|
|
563
|
+
}
|
|
564
|
+
observer.observe(el)
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
window.aiplang = window.aiplang || {}
|
|
568
|
+
window.aiplang.spring = function(el, prop, from, to, opts = {}) {
|
|
569
|
+
const k = opts.stiffness || 180
|
|
570
|
+
const b = opts.damping || 22
|
|
571
|
+
const m = opts.mass || 1
|
|
572
|
+
let pos = from, vel = 0
|
|
573
|
+
const dt = 1/60
|
|
574
|
+
let raf
|
|
575
|
+
|
|
576
|
+
const tick = () => {
|
|
577
|
+
const F = -k * (pos - to) - b * vel
|
|
578
|
+
vel += (F / m) * dt
|
|
579
|
+
pos += vel * dt
|
|
580
|
+
if (Math.abs(pos - to) < 0.01 && Math.abs(vel) < 0.01) {
|
|
581
|
+
pos = to
|
|
582
|
+
el.style[prop] = pos + (opts.unit || 'px')
|
|
583
|
+
return
|
|
584
|
+
}
|
|
585
|
+
el.style[prop] = pos + (opts.unit || 'px')
|
|
586
|
+
raf = requestAnimationFrame(tick)
|
|
587
|
+
}
|
|
588
|
+
cancelAnimationFrame(raf)
|
|
589
|
+
requestAnimationFrame(tick)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const springObs = new IntersectionObserver(entries => {
|
|
593
|
+
entries.forEach(entry => {
|
|
594
|
+
if (!entry.isIntersecting) return
|
|
595
|
+
const el = entry.target
|
|
596
|
+
if (el.classList.contains('fx-anim-spring')) {
|
|
597
|
+
el.style.opacity = '1'
|
|
598
|
+
el.style.transform = 'translateY(0px)'
|
|
599
|
+
window.aiplang.spring(el, '--spring-y', 24, 0, { stiffness: 200, damping: 20, unit: 'px' })
|
|
600
|
+
springObs.unobserve(el)
|
|
601
|
+
}
|
|
602
|
+
})
|
|
603
|
+
}, { threshold: 0.1 })
|
|
604
|
+
|
|
605
|
+
document.querySelectorAll('.fx-anim-spring').forEach(el => {
|
|
606
|
+
el.style.opacity = '0'
|
|
607
|
+
el.style.transform = 'translateY(24px)'
|
|
608
|
+
springObs.observe(el)
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
document.querySelectorAll('.fx-stat-val').forEach(el => {
|
|
612
|
+
const target = parseFloat(el.textContent)
|
|
613
|
+
if (isNaN(target) || target === 0) return
|
|
614
|
+
const isFloat = el.textContent.includes('.')
|
|
615
|
+
let hasAnimated = false
|
|
616
|
+
const obs = new IntersectionObserver(([entry]) => {
|
|
617
|
+
if (!entry.isIntersecting || hasAnimated) return
|
|
618
|
+
hasAnimated = true
|
|
619
|
+
obs.unobserve(el)
|
|
620
|
+
const dur = Math.min(1200, Math.max(600, target * 2))
|
|
621
|
+
const start = Date.now()
|
|
622
|
+
const tick = () => {
|
|
623
|
+
const elapsed = Date.now() - start
|
|
624
|
+
const progress = Math.min(elapsed / dur, 1)
|
|
625
|
+
const eased = 1 - Math.pow(1 - progress, 3)
|
|
626
|
+
const current = target * eased
|
|
627
|
+
el.textContent = isFloat ? current.toFixed(1) : Math.round(current).toLocaleString()
|
|
628
|
+
if (progress < 1) requestAnimationFrame(tick)
|
|
629
|
+
}
|
|
630
|
+
requestAnimationFrame(tick)
|
|
631
|
+
}, { threshold: 0.5 })
|
|
632
|
+
obs.observe(el)
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
|
|
456
636
|
function injectActionCSS() {
|
|
457
637
|
const style = document.createElement('style')
|
|
458
638
|
style.textContent = `
|
|
@@ -471,19 +651,138 @@ function injectActionCSS() {
|
|
|
471
651
|
document.head.appendChild(style)
|
|
472
652
|
}
|
|
473
653
|
|
|
474
|
-
|
|
654
|
+
function loadSSRData() {
|
|
655
|
+
const ssr = window.__SSR_DATA__
|
|
656
|
+
if (!ssr) return
|
|
657
|
+
for (const [key, value] of Object.entries(ssr)) {
|
|
658
|
+
_state[key] = value
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function hydrateOptimistic() {
|
|
663
|
+
document.querySelectorAll('[data-fx-optimistic]').forEach(form => {
|
|
664
|
+
const action = form.getAttribute('data-fx-action') || ''
|
|
665
|
+
const pm = action.match(/^@([a-zA-Z_]+)\.push\(\$result\)$/)
|
|
666
|
+
if (!pm) return
|
|
667
|
+
const key = pm[1]
|
|
668
|
+
|
|
669
|
+
form.addEventListener('submit', (e) => {
|
|
670
|
+
|
|
671
|
+
const body = {}
|
|
672
|
+
form.querySelectorAll('input,select,textarea').forEach(inp => {
|
|
673
|
+
if (inp.name) body[inp.name] = inp.value
|
|
674
|
+
})
|
|
675
|
+
const tempId = '__temp_' + Date.now()
|
|
676
|
+
const optimisticItem = { ...body, id: tempId, _optimistic: true }
|
|
677
|
+
const current = [...(get(key) || [])]
|
|
678
|
+
set(key, [...current, optimisticItem])
|
|
679
|
+
|
|
680
|
+
const origAction = form.getAttribute('data-fx-action')
|
|
681
|
+
form.setAttribute('data-fx-action-orig', origAction)
|
|
682
|
+
form.setAttribute('data-fx-action', `@${key}._rollback_${tempId}`)
|
|
683
|
+
|
|
684
|
+
setTimeout(() => {
|
|
685
|
+
form.setAttribute('data-fx-action', origAction)
|
|
686
|
+
|
|
687
|
+
setTimeout(() => {
|
|
688
|
+
const arr = get(key) || []
|
|
689
|
+
const hasReal = arr.some(i => !i._optimistic)
|
|
690
|
+
if (hasReal) set(key, arr.filter(i => !i._optimistic || i.id !== tempId))
|
|
691
|
+
}, 500)
|
|
692
|
+
}, 50)
|
|
693
|
+
}, true)
|
|
694
|
+
})
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function hydrateTableErrors() {
|
|
698
|
+
document.querySelectorAll('[data-fx-fallback]').forEach(tbl => {
|
|
699
|
+
const fallback = tbl.getAttribute('data-fx-fallback')
|
|
700
|
+
const retryPath = tbl.getAttribute('data-fx-retry')
|
|
701
|
+
const binding = tbl.getAttribute('data-fx-table')
|
|
702
|
+
if (!fallback) return
|
|
703
|
+
|
|
704
|
+
const tbody = tbl.querySelector('tbody')
|
|
705
|
+
const originalEmpty = tbl.getAttribute('data-fx-empty') || 'No data.'
|
|
706
|
+
|
|
707
|
+
const key = binding?.replace(/^@/, '') || ''
|
|
708
|
+
if (key) {
|
|
709
|
+
const cleanup = watch(key, (val) => {
|
|
710
|
+
if (val === '__error__') {
|
|
711
|
+
if (tbody) {
|
|
712
|
+
const cols = JSON.parse(tbl.getAttribute('data-fx-cols') || '[]')
|
|
713
|
+
tbody.innerHTML = `<tr><td colspan="${cols.length + 2}" class="fx-td-empty" style="color:#f87171">
|
|
714
|
+
${fallback}
|
|
715
|
+
${retryPath ? `<button onclick="window.__aiplang_retry('${binding}','${retryPath}')" style="margin-left:.75rem;padding:.3rem .75rem;background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.3);color:#f87171;border-radius:.375rem;cursor:pointer;font-size:.75rem">↻ Retry</button>` : ''}
|
|
716
|
+
</td></tr>`
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
})
|
|
720
|
+
}
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
window.__aiplang_retry = (binding, path) => {
|
|
724
|
+
const key = binding.replace(/^@/, '')
|
|
725
|
+
set(key, [])
|
|
726
|
+
runQuery({ method: 'GET', path, target: binding })
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
475
730
|
function boot() {
|
|
731
|
+
loadSSRData()
|
|
476
732
|
injectActionCSS()
|
|
733
|
+
initAnimations()
|
|
477
734
|
hydrateBindings()
|
|
478
735
|
hydrateTables()
|
|
736
|
+
hydrateTableErrors()
|
|
479
737
|
hydrateLists()
|
|
480
738
|
hydrateForms()
|
|
739
|
+
hydrateOptimistic()
|
|
481
740
|
hydrateBtns()
|
|
482
741
|
hydrateSelects()
|
|
483
742
|
hydrateIfs()
|
|
743
|
+
hydrateEach()
|
|
484
744
|
mountQueries()
|
|
485
745
|
}
|
|
486
746
|
|
|
747
|
+
function hydrateEach() {
|
|
748
|
+
document.querySelectorAll('[data-fx-each]').forEach(wrap => {
|
|
749
|
+
const binding = wrap.getAttribute('data-fx-each')
|
|
750
|
+
const tpl = wrap.getAttribute('data-fx-tpl') || ''
|
|
751
|
+
const key = binding.startsWith('@') ? binding.slice(1) : binding
|
|
752
|
+
|
|
753
|
+
const render = () => {
|
|
754
|
+
let items = get(key)
|
|
755
|
+
if (!Array.isArray(items)) items = []
|
|
756
|
+
wrap.innerHTML = ''
|
|
757
|
+
|
|
758
|
+
if (!items.length) {
|
|
759
|
+
const empty = document.createElement('div')
|
|
760
|
+
empty.className = 'fx-each-empty fx-td-empty'
|
|
761
|
+
empty.textContent = wrap.getAttribute('data-fx-empty') || 'No items.'
|
|
762
|
+
wrap.appendChild(empty)
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
items.forEach(item => {
|
|
767
|
+
const div = document.createElement('div')
|
|
768
|
+
div.className = 'fx-each-item'
|
|
769
|
+
|
|
770
|
+
const html = tpl.replace(/\{item\.([^}]+)\}/g, (_, field) => {
|
|
771
|
+
const parts = field.split('.')
|
|
772
|
+
let val = item
|
|
773
|
+
for (const p of parts) val = val?.[p]
|
|
774
|
+
return val != null ? String(val) : ''
|
|
775
|
+
})
|
|
776
|
+
div.textContent = html || (item.name || item.title || item.label || JSON.stringify(item))
|
|
777
|
+
wrap.appendChild(div)
|
|
778
|
+
})
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
watch(key, render)
|
|
782
|
+
render()
|
|
783
|
+
})
|
|
784
|
+
}
|
|
785
|
+
|
|
487
786
|
if (document.readyState === 'loading') {
|
|
488
787
|
document.addEventListener('DOMContentLoaded', boot)
|
|
489
788
|
} else {
|
package/server/server.js
CHANGED
|
@@ -613,7 +613,18 @@ function parseApp(src) {
|
|
|
613
613
|
else if (line.startsWith('~belongs '))curModel.relationships.push({ type:'belongsTo', model:line.slice(9).trim() })
|
|
614
614
|
else if (line.startsWith('~hook ')) curModel.hooks.push(line.slice(6).trim())
|
|
615
615
|
else if (line === '~soft-delete') curModel.softDelete = true
|
|
616
|
-
else if (line && line !== '{')
|
|
616
|
+
else if (line && line !== '{') {
|
|
617
|
+
// Support both multi-line and compact single-line field defs
|
|
618
|
+
if (line.startsWith('~')) {
|
|
619
|
+
if (line === '~soft-delete') curModel.softDelete = true
|
|
620
|
+
else if (line.startsWith('~belongs ')) curModel.relationships.push({type:'belongsTo',model:line.slice(9).trim()})
|
|
621
|
+
} else if (!line.includes(' ') && line.includes(':')) {
|
|
622
|
+
// Compact: "email:text:unique:required"
|
|
623
|
+
curModel.fields.push(parseFieldCompact(line))
|
|
624
|
+
} else {
|
|
625
|
+
curModel.fields.push(parseField(line))
|
|
626
|
+
}
|
|
627
|
+
}
|
|
617
628
|
i++; continue
|
|
618
629
|
}
|
|
619
630
|
|
|
@@ -621,8 +632,12 @@ function parseApp(src) {
|
|
|
621
632
|
if (inAPI && curAPI) app.apis.push(curAPI)
|
|
622
633
|
const braceIdx = line.indexOf('{')
|
|
623
634
|
const closeBraceIdx = line.lastIndexOf('}')
|
|
624
|
-
const
|
|
625
|
-
|
|
635
|
+
const rawHead = line.slice(4, braceIdx).trim()
|
|
636
|
+
// Shorthand: api GET /path => auth,admin { — arrow guard syntax
|
|
637
|
+
const arrowM = rawHead.match(/^(\S+)\s+(\S+)\s*=>\s*([\w,]+)\s*$/)
|
|
638
|
+
const pts = (arrowM ? rawHead.slice(0, rawHead.indexOf('=>')).trim() : rawHead).split(/\s+/)
|
|
639
|
+
const inlineGuards = arrowM ? arrowM[3].split(',').map(g=>g.trim()) : []
|
|
640
|
+
curAPI = { method:pts[0], path:pts[1], guards:[...inlineGuards], validate:[], query:[], body:[], return:null }
|
|
626
641
|
// Inline api: "api GET /path { ops }" — entire api on one line
|
|
627
642
|
if (braceIdx !== -1 && closeBraceIdx > braceIdx) {
|
|
628
643
|
const inlineBody = line.slice(braceIdx+1, closeBraceIdx).trim()
|
|
@@ -648,7 +663,7 @@ function parseApp(src) {
|
|
|
648
663
|
}
|
|
649
664
|
|
|
650
665
|
function parseEnvLine(s) { const p=s.split(/\s+/); const ev={name:'',required:false,default:null}; for(const x of p){if(x==='required')ev.required=true;else if(x.includes('=')){const[k,v]=x.split('=');ev.name=k;ev.default=v}else ev.name=x}; return ev }
|
|
651
|
-
function parseDBLine(s) { const p=s.split(/\s+/);
|
|
666
|
+
function parseDBLine(s) { const p=s.split(/\s+/); const d=p[0]||'sqlite'; return{driver:d==='pg'||d==='psql'?'postgres':d,dsn:p[1]||'./app.db'} }
|
|
652
667
|
function parseAuthLine(s) { const p=s.split(/\s+/); const a={provider:'jwt',secret:p[1]||'$JWT_SECRET',expire:'7d',refresh:'30d'}; for(const x of p){if(x.startsWith('expire='))a.expire=x.slice(7);if(x.startsWith('refresh='))a.refresh=x.slice(8);if(x==='google')a.oauth=['google'];if(x==='github')a.oauth=[...(a.oauth||[]),'google']}; return a }
|
|
653
668
|
function parseMailLine(s) { const parts=s.split(/\s+/); const m={driver:parts[0]||'smtp'}; for(const x of parts.slice(1)){const[k,v]=x.split('='); m[k]=v}; return m }
|
|
654
669
|
function parseStripeLine(s) {
|
|
@@ -708,7 +723,6 @@ function parseEventLine(s) { const m=s.match(/^(\S+)\s*=>\s*(.+)$/); return{even
|
|
|
708
723
|
function parseField(line) {
|
|
709
724
|
const p=line.split(':').map(s=>s.trim())
|
|
710
725
|
const f={name:p[0],type:p[1]||'text',modifiers:[],enumVals:[],default:null}
|
|
711
|
-
// If type is enum, p[2] contains comma-separated values directly
|
|
712
726
|
if (f.type === 'enum' && p[2] && !p[2].startsWith('default=') && !['required','unique','hashed','pk','auto','index'].includes(p[2])) {
|
|
713
727
|
f.enumVals = p[2].split(',').map(v=>v.trim()).filter(Boolean)
|
|
714
728
|
for(let j=3;j<p.length;j++){const x=p[j];if(x.startsWith('default='))f.default=x.slice(8);else if(x)f.modifiers.push(x)}
|
|
@@ -717,6 +731,19 @@ function parseField(line) {
|
|
|
717
731
|
}
|
|
718
732
|
return f
|
|
719
733
|
}
|
|
734
|
+
|
|
735
|
+
// Compact model field: "email:text:unique:required" single-line
|
|
736
|
+
function parseFieldCompact(def) {
|
|
737
|
+
const parts = def.trim().split(':').map(s=>s.trim()).filter(Boolean)
|
|
738
|
+
const f = {name:parts[0], type:parts[1]||'text', modifiers:[], enumVals:[], default:null}
|
|
739
|
+
for (let i=2; i<parts.length; i++) {
|
|
740
|
+
const x = parts[i]
|
|
741
|
+
if (x.startsWith('default=')) f.default = x.slice(8)
|
|
742
|
+
else if (/^[a-z]+,[a-z]/.test(x)) f.enumVals = x.split(',').map(v=>v.trim())
|
|
743
|
+
else f.modifiers.push(x)
|
|
744
|
+
}
|
|
745
|
+
return f
|
|
746
|
+
}
|
|
720
747
|
function parseAPILine(line, route) {
|
|
721
748
|
if(line.startsWith('~guard ')) route.guards=line.slice(7).split('|').map(s=>s.trim())
|
|
722
749
|
else if(line.startsWith('~validate ')) line.slice(10).split('|').forEach(v=>{const p=v.trim().split(/\s+/);if(p[0])route.validate.push({field:p[0],rules:p.slice(1)})})
|
|
@@ -727,10 +754,14 @@ function parseFrontPage(src) {
|
|
|
727
754
|
const lines=src.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('#'))
|
|
728
755
|
const p={id:'page',theme:'dark',route:'/',themeVars:null,state:{},queries:[],blocks:[]}
|
|
729
756
|
for(const line of lines){
|
|
730
|
-
if(line.startsWith('%')){const pts=line.slice(1).trim().split(/\s+/);p.id=pts[0]||'page';p.route=pts[2]||'/';const rt=pts[1]||'dark';if(rt.includes('#')){const c=rt.split(',');p.theme='custom';p.customTheme={bg:c[0],text:c[1]||'#f1f5f9',accent:c[2]||'#2563eb'}}else p.theme=rt}
|
|
757
|
+
if(line.startsWith('%')){const pts=line.slice(1).trim().split(/\s+/);p.id=pts[0]||'page';p.route=pts[2]||'/';const cachePt=pts.find(x=>x.startsWith('cache='));if(cachePt)p.cacheTTL=parseInt(cachePt.slice(6));const rt=pts[1]||'dark';if(rt.includes('#')){const c=rt.split(',');p.theme='custom';p.customTheme={bg:c[0],text:c[1]||'#f1f5f9',accent:c[2]||'#2563eb'}}else p.theme=rt}
|
|
731
758
|
else if(line.startsWith('~theme ')){p.themeVars=p.themeVars||{};line.slice(7).trim().split(/\s+/).forEach(pair=>{const eq=pair.indexOf('=');if(eq!==-1)p.themeVars[pair.slice(0,eq)]=pair.slice(eq+1)})}
|
|
732
759
|
else if(line.startsWith('@')&&line.includes('=')){const eq=line.indexOf('=');p.state[line.slice(1,eq).trim()]=line.slice(eq+1).trim()}
|
|
733
|
-
else if(line.startsWith('~')){const pts=line.slice(1).trim().split(/\s+/);const ai=pts.indexOf('=>');if(pts[0]==='mount')
|
|
760
|
+
else if(line.startsWith('~')){const pts=line.slice(1).trim().split(/\s+/);const ai=pts.indexOf('=>');if(pts[0]==='mount'){
|
|
761
|
+
// Auto-detect target from path if not specified: ~mount GET /api/users → @users
|
|
762
|
+
const autoTarget = pts[3] || ('@' + (pts[2]?.split('/').filter(Boolean).pop()?.split('?')[0]||'data'))
|
|
763
|
+
p.queries.push({trigger:'mount',method:pts[1],path:pts[2],target:ai===-1?autoTarget:null,action:ai!==-1?pts.slice(ai+1).join(' '):null})
|
|
764
|
+
} else if(pts[0]==='interval')p.queries.push({trigger:'interval',interval:parseInt(pts[1]),method:pts[2],path:pts[3],target:ai===-1?pts[4]:null,action:ai!==-1?pts.slice(ai+1).join(' '):null})}
|
|
734
765
|
else p.blocks.push({kind:blockKind(line),rawLine:line})
|
|
735
766
|
}
|
|
736
767
|
return p
|
|
@@ -1797,7 +1828,7 @@ async function startServer(aipFile, port = 3000) {
|
|
|
1797
1828
|
|
|
1798
1829
|
// Health
|
|
1799
1830
|
srv.addRoute('GET', '/health', (req, res) => res.json(200, {
|
|
1800
|
-
status:'ok', version:'2.10.
|
|
1831
|
+
status:'ok', version:'2.10.2',
|
|
1801
1832
|
models: app.models.map(m=>m.name),
|
|
1802
1833
|
routes: app.apis.length, pages: app.pages.length,
|
|
1803
1834
|
admin: app.admin?.prefix || null,
|