aiplang 2.9.4 → 2.10.1
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 +44 -8
- package/package.json +1 -1
- package/runtime/aiplang-hydrate.js +258 -4
- package/server/server.js +1 -1
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.
|
|
8
|
+
const VERSION = '2.10.1'
|
|
9
9
|
const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
|
|
10
10
|
const cmd = process.argv[2]
|
|
11
11
|
const args = process.argv.slice(3)
|
|
@@ -648,9 +648,15 @@ function parseBlock(line) {
|
|
|
648
648
|
const binding=line.slice(start,idx).trim().replace(/^@/,'@')
|
|
649
649
|
const content=line.slice(idx+1,line.lastIndexOf('}')).trim()
|
|
650
650
|
const em=content.match(/edit\s+(PUT|PATCH)\s+(\S+)/), dm=content.match(/delete\s+(?:DELETE\s+)?(\S+)/)
|
|
651
|
-
const
|
|
651
|
+
const fallbackM=content.match(/fallback\s*:\s*([^|]+)/)
|
|
652
|
+
const retryM=content.match(/retry\s*:\s*(\S+)/)
|
|
653
|
+
const clean=content
|
|
654
|
+
.replace(/edit\s+(PUT|PATCH)\s+\S+/g,'')
|
|
655
|
+
.replace(/delete\s+(?:DELETE\s+)?\S+/g,'')
|
|
656
|
+
.replace(/fallback\s*:[^|]+/g,'')
|
|
657
|
+
.replace(/retry\s*:\s*\S+/g,'')
|
|
652
658
|
const cols=parseCols(clean)
|
|
653
|
-
return{kind:'table',binding,cols:Array.isArray(cols)?cols:[],empty:parseEmpty(clean),editPath:em?.[2]||null,editMethod:em?.[1]||'PUT',deletePath:dm?.[1]||null,deleteKey:'id',extraClass,animate,variant,style,bg}
|
|
659
|
+
return{kind:'table',binding,cols:Array.isArray(cols)?cols:[],empty:parseEmpty(clean),editPath:em?.[2]||null,editMethod:em?.[1]||'PUT',deletePath:dm?.[1]||null,deleteKey:'id',fallback:fallbackM?.[1]?.trim()||null,retry:retryM?.[1]||null,extraClass,animate,variant,style,bg}
|
|
654
660
|
}
|
|
655
661
|
|
|
656
662
|
// ── form ────────────────────────────────────────────────────
|
|
@@ -658,12 +664,17 @@ function parseBlock(line) {
|
|
|
658
664
|
const bi=line.indexOf('{');if(bi===-1) return null
|
|
659
665
|
let head=line.slice(line.startsWith('form{')?4:5,bi).trim()
|
|
660
666
|
const content=line.slice(bi+1,line.lastIndexOf('}')).trim()
|
|
661
|
-
let action=''; const ai=head.indexOf('=>')
|
|
662
|
-
if(ai!==-1){
|
|
667
|
+
let action='', optimistic=false; const ai=head.indexOf('=>')
|
|
668
|
+
if(ai!==-1){
|
|
669
|
+
action=head.slice(ai+2).trim()
|
|
670
|
+
// Optimistic: => @list.optimistic($result)
|
|
671
|
+
if(action.includes('.optimistic(')){optimistic=true;action=action.replace('.optimistic','')}
|
|
672
|
+
head=head.slice(0,ai).trim()
|
|
673
|
+
}
|
|
663
674
|
const parts=head.trim().split(/\s+/)
|
|
664
675
|
const method=parts[0]&&['GET','POST','PUT','PATCH','DELETE'].includes(parts[0].toUpperCase())?parts[0].toUpperCase():'POST'
|
|
665
676
|
const bpath=parts[method===parts[0].toUpperCase()?1:0]||''
|
|
666
|
-
return{kind:'form',method,bpath,action,fields:parseFields(content)||[],extraClass,animate,variant,style,bg}
|
|
677
|
+
return{kind:'form',method,bpath,action,optimistic,fields:parseFields(content)||[],extraClass,animate,variant,style,bg}
|
|
667
678
|
}
|
|
668
679
|
|
|
669
680
|
// ── pricing ─────────────────────────────────────────────────
|
|
@@ -858,11 +869,33 @@ function renderBlock(b, page) {
|
|
|
858
869
|
case 'testimonial': return rTestimonial(b)
|
|
859
870
|
case 'gallery': return rGallery(b)
|
|
860
871
|
case 'raw': return (b.html||'')+'\n'
|
|
872
|
+
case 'html': return `<div class="fx-html">${b.content||''}</div>\n`
|
|
873
|
+
case 'spacer': return `<div class="fx-spacer" style="height:${esc(b.height||'2rem')}"></div>\n`
|
|
874
|
+
case 'divider': return b.label?`<div class="fx-divider"><span class="fx-divider-label">${esc(b.label)}</span></div>\n`:`<hr class="fx-hr">\n`
|
|
875
|
+
case 'badge': return `<div class="fx-badge-row"><span class="fx-badge-tag">${esc(b.content||'')}</span></div>\n`
|
|
876
|
+
case 'card': return rCardBlock(b)
|
|
877
|
+
case 'cols': return rColsBlock(b)
|
|
878
|
+
case 'each': return `<div class="fx-each fx-each-${b.variant||'list'}" data-fx-each="${esc(b.binding||'')}" data-fx-tpl="${esc(b.tpl||'')}"${b.style?` style="${b.style.replace(/,/g,';')}"`:''}>\n<div class="fx-each-empty fx-td-empty">Loading...</div></div>\n`
|
|
861
879
|
case 'if': return `<div class="fx-if-wrap" data-fx-if="${esc(b.cond)}" style="display:none"></div>\n`
|
|
862
880
|
default: return ''
|
|
863
881
|
}
|
|
864
882
|
}
|
|
865
883
|
|
|
884
|
+
function rCardBlock(b) {
|
|
885
|
+
const img=b.img?`<img src="${esc(b.img)}" class="fx-card-img" alt="${esc(b.title||'')}" loading="lazy">`:'';
|
|
886
|
+
const badge=b.badge?`<span class="fx-card-badge">${esc(b.badge)}</span>`:'';
|
|
887
|
+
const title=b.title?`<h3 class="fx-card-title">${esc(b.title)}</h3>`:'';
|
|
888
|
+
const sub=b.subtitle?`<p class="fx-card-body">${esc(b.subtitle)}</p>`:'';
|
|
889
|
+
const link=b.link?`<a href="${esc(b.link.split(':')[0])}" class="fx-card-link">${esc(b.link.split(':')[1]||'View')} →</a>`:'';
|
|
890
|
+
const bg=b.bg?` style="background:${b.bg}"`:b.style?` style="${b.style.replace(/,/g,';')}"`:''
|
|
891
|
+
return`<div class="fx-card"${bg}>${img}${badge}${title}${sub}${link}</div>\n`
|
|
892
|
+
}
|
|
893
|
+
function rColsBlock(b) {
|
|
894
|
+
const cols=(b.items||[]).map(col=>`<div class="fx-col">${col}</div>`).join('')
|
|
895
|
+
const style=b.style?` style="${b.style.replace(/,/g,';')}"`:''
|
|
896
|
+
return`<div class="fx-cols fx-cols-${b.n||2}"${style}>${cols}</div>\n`
|
|
897
|
+
}
|
|
898
|
+
|
|
866
899
|
function rNav(b) {
|
|
867
900
|
if(!b.items?.[0]) return ''
|
|
868
901
|
const it=b.items[0]
|
|
@@ -973,7 +1006,9 @@ function rTable(b) {
|
|
|
973
1006
|
const da=b.deletePath?` data-fx-delete="${esc(b.deletePath)}"`:''
|
|
974
1007
|
const at=(b.editPath||b.deletePath)?'<th class="fx-th fx-th-actions">Actions</th>':''
|
|
975
1008
|
const span=cols.length+((b.editPath||b.deletePath)?1:0)
|
|
976
|
-
|
|
1009
|
+
const fallbackAttr=b.fallback?` data-fx-fallback="${esc(b.fallback)}"`:''
|
|
1010
|
+
const retryAttr=b.retry?` data-fx-retry="${esc(b.retry)}"`:''
|
|
1011
|
+
return `<div class="fx-table-wrap"><table class="fx-table" data-fx-table="${esc(b.binding)}" data-fx-cols='${keys}' data-fx-col-map='${cm}'${ea}${da}${fallbackAttr}${retryAttr}><thead><tr>${ths}${at}</tr></thead><tbody class="fx-tbody"><tr><td colspan="${span}" class="fx-td-empty">${esc(b.empty)}</td></tr></tbody></table></div>\n`
|
|
977
1012
|
}
|
|
978
1013
|
|
|
979
1014
|
function rForm(b) {
|
|
@@ -993,7 +1028,8 @@ function rForm(b) {
|
|
|
993
1028
|
if(v==='minimal') {
|
|
994
1029
|
return `<div class="fx-form-minimal"><form data-fx-form="${esc(b.bpath)}" data-fx-method="${esc(b.method)}" data-fx-action="${esc(b.action)}">${fields}<div class="fx-form-msg"></div><button type="submit" class="fx-btn">${esc(label)}</button></form></div>\n`
|
|
995
1030
|
}
|
|
996
|
-
|
|
1031
|
+
const optAttr=b.optimistic?' data-fx-optimistic="true"':''
|
|
1032
|
+
return `<div class="fx-form-wrap"><form class="fx-form"${bgStyle}${optAttr} data-fx-form="${esc(b.bpath)}" data-fx-method="${esc(b.method)}" data-fx-action="${esc(b.action)}">${fields}<div class="fx-form-msg"></div><button type="submit" class="fx-btn">${esc(label)}</button></form></div>\n`
|
|
997
1033
|
}
|
|
998
1034
|
|
|
999
1035
|
function rBtn(b) {
|
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'
|
|
@@ -31,7 +28,28 @@ function watch(key, cb) {
|
|
|
31
28
|
return () => { _watchers[key] = _watchers[key].filter(f => f !== cb) }
|
|
32
29
|
}
|
|
33
30
|
|
|
31
|
+
// Batched notify — queues all pending updates and flushes in rAF (like React's batching)
|
|
32
|
+
const _pending = new Set()
|
|
33
|
+
let _batchScheduled = false
|
|
34
|
+
|
|
35
|
+
function flushBatch() {
|
|
36
|
+
_batchScheduled = false
|
|
37
|
+
for (const key of _pending) {
|
|
38
|
+
;(_watchers[key] || []).forEach(cb => cb(_state[key]))
|
|
39
|
+
}
|
|
40
|
+
_pending.clear()
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
function notify(key) {
|
|
44
|
+
_pending.add(key)
|
|
45
|
+
if (!_batchScheduled) {
|
|
46
|
+
_batchScheduled = true
|
|
47
|
+
requestAnimationFrame(flushBatch)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Force immediate flush (for critical updates like form submit)
|
|
52
|
+
function notifySync(key) {
|
|
35
53
|
;(_watchers[key] || []).forEach(cb => cb(_state[key]))
|
|
36
54
|
}
|
|
37
55
|
|
|
@@ -251,9 +269,35 @@ function hydrateTables() {
|
|
|
251
269
|
return
|
|
252
270
|
}
|
|
253
271
|
|
|
272
|
+
// Virtual rendering: only render visible rows for large datasets
|
|
273
|
+
const VIRTUAL_THRESHOLD = 100
|
|
274
|
+
const ROW_HEIGHT = 44 // px
|
|
275
|
+
const useVirtual = rows.length >= VIRTUAL_THRESHOLD
|
|
276
|
+
|
|
277
|
+
if (useVirtual) {
|
|
278
|
+
const wrapDiv = tbody.closest('.fx-table-wrap') || tbody.parentElement
|
|
279
|
+
const visible = Math.ceil((wrapDiv.clientHeight || 400) / ROW_HEIGHT) + 10
|
|
280
|
+
const scrollTop = wrapDiv.scrollTop || 0
|
|
281
|
+
const startIdx = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - 5)
|
|
282
|
+
const endIdx = Math.min(rows.length - 1, startIdx + visible)
|
|
283
|
+
|
|
284
|
+
// Spacer before
|
|
285
|
+
if (startIdx > 0) {
|
|
286
|
+
const spacerTr = document.createElement('tr')
|
|
287
|
+
const spacerTd = document.createElement('td')
|
|
288
|
+
spacerTd.colSpan = cols.length + (editPath || delPath ? 1 : 0)
|
|
289
|
+
spacerTd.style.height = (startIdx * ROW_HEIGHT) + 'px'
|
|
290
|
+
spacerTd.style.padding = '0'
|
|
291
|
+
spacerTr.appendChild(spacerTd)
|
|
292
|
+
tbody.appendChild(spacerTr)
|
|
293
|
+
}
|
|
294
|
+
rows = rows.slice(startIdx, endIdx + 1)
|
|
295
|
+
}
|
|
296
|
+
|
|
254
297
|
rows.forEach((row, idx) => {
|
|
255
298
|
const tr = document.createElement('tr')
|
|
256
299
|
tr.className = 'fx-tr'
|
|
300
|
+
if (useVirtual) tr.style.height = ROW_HEIGHT + 'px'
|
|
257
301
|
|
|
258
302
|
// Data cells
|
|
259
303
|
for (const col of cols) {
|
|
@@ -452,6 +496,86 @@ function hydrateIfs() {
|
|
|
452
496
|
})
|
|
453
497
|
}
|
|
454
498
|
|
|
499
|
+
// ── Advanced Animations (scroll-triggered + stagger) ─────────────
|
|
500
|
+
function initAnimations() {
|
|
501
|
+
// Extended animation presets — beyond what React ships by default
|
|
502
|
+
const style = document.createElement('style')
|
|
503
|
+
style.textContent = `
|
|
504
|
+
@keyframes fx-blur-in { from{opacity:0;filter:blur(8px);transform:translateY(8px)} to{opacity:1;filter:blur(0);transform:none} }
|
|
505
|
+
@keyframes fx-fade-up { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:none} }
|
|
506
|
+
@keyframes fx-fade-in { from{opacity:0} to{opacity:1} }
|
|
507
|
+
@keyframes fx-slide-up { from{opacity:0;transform:translateY(40px)} to{opacity:1;transform:none} }
|
|
508
|
+
@keyframes fx-slide-left{ from{opacity:0;transform:translateX(30px)} to{opacity:1;transform:none} }
|
|
509
|
+
@keyframes fx-scale-in { from{opacity:0;transform:scale(.92)} to{opacity:1;transform:scale(1)} }
|
|
510
|
+
@keyframes fx-bounce { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-8px)} }
|
|
511
|
+
@keyframes fx-shake { 0%,100%{transform:translateX(0)} 25%{transform:translateX(-6px)} 75%{transform:translateX(6px)} }
|
|
512
|
+
@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} }
|
|
513
|
+
@keyframes fx-count { from{opacity:0;transform:translateY(4px)} to{opacity:1;transform:none} }
|
|
514
|
+
|
|
515
|
+
[class*="fx-anim-"] { opacity: 0 }
|
|
516
|
+
[class*="fx-anim-"].fx-visible { animation-fill-mode: both; animation-timing-function: cubic-bezier(.4,0,.2,1) }
|
|
517
|
+
.fx-visible.fx-anim-blur-in { animation: fx-blur-in .7s both }
|
|
518
|
+
.fx-visible.fx-anim-fade-up { animation: fx-fade-up .6s both }
|
|
519
|
+
.fx-visible.fx-anim-fade-in { animation: fx-fade-in .5s both }
|
|
520
|
+
.fx-visible.fx-anim-slide-up { animation: fx-slide-up .65s both }
|
|
521
|
+
.fx-visible.fx-anim-slide-left{ animation: fx-slide-left .6s both }
|
|
522
|
+
.fx-visible.fx-anim-scale-in { animation: fx-scale-in .5s both }
|
|
523
|
+
.fx-visible.fx-anim-stagger > * { animation: fx-fade-up .5s both }
|
|
524
|
+
.fx-visible.fx-anim-stagger > *:nth-child(1) { animation-delay: 0s }
|
|
525
|
+
.fx-visible.fx-anim-stagger > *:nth-child(2) { animation-delay: .1s }
|
|
526
|
+
.fx-visible.fx-anim-stagger > *:nth-child(3) { animation-delay: .2s }
|
|
527
|
+
.fx-visible.fx-anim-stagger > *:nth-child(4) { animation-delay: .3s }
|
|
528
|
+
.fx-visible.fx-anim-stagger > *:nth-child(5) { animation-delay: .4s }
|
|
529
|
+
.fx-visible.fx-anim-stagger > *:nth-child(6) { animation-delay: .5s }
|
|
530
|
+
.fx-anim-bounce { animation: fx-bounce 1.5s ease-in-out infinite !important; opacity: 1 !important }
|
|
531
|
+
.fx-anim-pulse { animation: fx-pulse-ring 2s ease infinite !important; opacity: 1 !important }
|
|
532
|
+
`
|
|
533
|
+
document.head.appendChild(style)
|
|
534
|
+
|
|
535
|
+
// Intersection Observer — trigger when element scrolls into view (like Framer whileInView)
|
|
536
|
+
const observer = new IntersectionObserver((entries) => {
|
|
537
|
+
entries.forEach(entry => {
|
|
538
|
+
if (entry.isIntersecting) {
|
|
539
|
+
entry.target.classList.add('fx-visible')
|
|
540
|
+
observer.unobserve(entry.target)
|
|
541
|
+
}
|
|
542
|
+
})
|
|
543
|
+
}, { threshold: 0.12, rootMargin: '0px 0px -30px 0px' })
|
|
544
|
+
|
|
545
|
+
document.querySelectorAll('[class*="fx-anim-"]').forEach(el => {
|
|
546
|
+
// bounce and pulse run immediately
|
|
547
|
+
if (el.classList.contains('fx-anim-bounce') || el.classList.contains('fx-anim-pulse')) {
|
|
548
|
+
el.classList.add('fx-visible'); return
|
|
549
|
+
}
|
|
550
|
+
observer.observe(el)
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
// Counter animation — numbers count up on scroll-in
|
|
554
|
+
document.querySelectorAll('.fx-stat-val').forEach(el => {
|
|
555
|
+
const target = parseFloat(el.textContent)
|
|
556
|
+
if (isNaN(target) || target === 0) return
|
|
557
|
+
const isFloat = el.textContent.includes('.')
|
|
558
|
+
let hasAnimated = false
|
|
559
|
+
const obs = new IntersectionObserver(([entry]) => {
|
|
560
|
+
if (!entry.isIntersecting || hasAnimated) return
|
|
561
|
+
hasAnimated = true
|
|
562
|
+
obs.unobserve(el)
|
|
563
|
+
const dur = Math.min(1200, Math.max(600, target * 2))
|
|
564
|
+
const start = Date.now()
|
|
565
|
+
const tick = () => {
|
|
566
|
+
const elapsed = Date.now() - start
|
|
567
|
+
const progress = Math.min(elapsed / dur, 1)
|
|
568
|
+
const eased = 1 - Math.pow(1 - progress, 3)
|
|
569
|
+
const current = target * eased
|
|
570
|
+
el.textContent = isFloat ? current.toFixed(1) : Math.round(current).toLocaleString()
|
|
571
|
+
if (progress < 1) requestAnimationFrame(tick)
|
|
572
|
+
}
|
|
573
|
+
requestAnimationFrame(tick)
|
|
574
|
+
}, { threshold: 0.5 })
|
|
575
|
+
obs.observe(el)
|
|
576
|
+
})
|
|
577
|
+
}
|
|
578
|
+
|
|
455
579
|
// ── Inject action column CSS ──────────────────────────────────────
|
|
456
580
|
function injectActionCSS() {
|
|
457
581
|
const style = document.createElement('style')
|
|
@@ -471,19 +595,149 @@ function injectActionCSS() {
|
|
|
471
595
|
document.head.appendChild(style)
|
|
472
596
|
}
|
|
473
597
|
|
|
598
|
+
// ── SSR Data Injection — pre-populate state from server data ────────
|
|
599
|
+
// Server can inject window.__SSR_DATA__ = {users: [...], stats: {...}}
|
|
600
|
+
// to avoid loading flash (like Next.js getServerSideProps)
|
|
601
|
+
function loadSSRData() {
|
|
602
|
+
const ssr = window.__SSR_DATA__
|
|
603
|
+
if (!ssr) return
|
|
604
|
+
for (const [key, value] of Object.entries(ssr)) {
|
|
605
|
+
_state[key] = value
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ── Optimistic UI ─────────────────────────────────────────────────
|
|
610
|
+
// form data-fx-optimistic="true": updates state instantly, rolls back on error
|
|
611
|
+
function hydrateOptimistic() {
|
|
612
|
+
document.querySelectorAll('[data-fx-optimistic]').forEach(form => {
|
|
613
|
+
const action = form.getAttribute('data-fx-action') || ''
|
|
614
|
+
const pm = action.match(/^@([a-zA-Z_]+)\.push\(\$result\)$/)
|
|
615
|
+
if (!pm) return
|
|
616
|
+
const key = pm[1]
|
|
617
|
+
|
|
618
|
+
form.addEventListener('submit', (e) => {
|
|
619
|
+
// Inject a temp item optimistically before submit fires
|
|
620
|
+
const body = {}
|
|
621
|
+
form.querySelectorAll('input,select,textarea').forEach(inp => {
|
|
622
|
+
if (inp.name) body[inp.name] = inp.value
|
|
623
|
+
})
|
|
624
|
+
const tempId = '__temp_' + Date.now()
|
|
625
|
+
const optimisticItem = { ...body, id: tempId, _optimistic: true }
|
|
626
|
+
const current = [...(get(key) || [])]
|
|
627
|
+
set(key, [...current, optimisticItem])
|
|
628
|
+
|
|
629
|
+
// After actual submit (handled by hydrateForms), remove temp if error
|
|
630
|
+
const origAction = form.getAttribute('data-fx-action')
|
|
631
|
+
form.setAttribute('data-fx-action-orig', origAction)
|
|
632
|
+
form.setAttribute('data-fx-action', `@${key}._rollback_${tempId}`)
|
|
633
|
+
|
|
634
|
+
// Restore action after tick
|
|
635
|
+
setTimeout(() => {
|
|
636
|
+
form.setAttribute('data-fx-action', origAction)
|
|
637
|
+
// Clean up optimistic item if real item arrived
|
|
638
|
+
setTimeout(() => {
|
|
639
|
+
const arr = get(key) || []
|
|
640
|
+
const hasReal = arr.some(i => !i._optimistic)
|
|
641
|
+
if (hasReal) set(key, arr.filter(i => !i._optimistic || i.id !== tempId))
|
|
642
|
+
}, 500)
|
|
643
|
+
}, 50)
|
|
644
|
+
}, true) // capture phase — before hydrateForms submit handler
|
|
645
|
+
})
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ── Error recovery — fallback + retry ────────────────────────────
|
|
649
|
+
function hydrateTableErrors() {
|
|
650
|
+
document.querySelectorAll('[data-fx-fallback]').forEach(tbl => {
|
|
651
|
+
const fallback = tbl.getAttribute('data-fx-fallback')
|
|
652
|
+
const retryPath = tbl.getAttribute('data-fx-retry')
|
|
653
|
+
const binding = tbl.getAttribute('data-fx-table')
|
|
654
|
+
if (!fallback) return
|
|
655
|
+
|
|
656
|
+
const tbody = tbl.querySelector('tbody')
|
|
657
|
+
const originalEmpty = tbl.getAttribute('data-fx-empty') || 'No data.'
|
|
658
|
+
|
|
659
|
+
// Override runQuery to detect errors for this table's binding
|
|
660
|
+
const key = binding?.replace(/^@/, '') || ''
|
|
661
|
+
if (key) {
|
|
662
|
+
const cleanup = watch(key, (val) => {
|
|
663
|
+
if (val === '__error__') {
|
|
664
|
+
if (tbody) {
|
|
665
|
+
const cols = JSON.parse(tbl.getAttribute('data-fx-cols') || '[]')
|
|
666
|
+
tbody.innerHTML = `<tr><td colspan="${cols.length + 2}" class="fx-td-empty" style="color:#f87171">
|
|
667
|
+
${fallback}
|
|
668
|
+
${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>` : ''}
|
|
669
|
+
</td></tr>`
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
})
|
|
673
|
+
}
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
window.__aiplang_retry = (binding, path) => {
|
|
677
|
+
const key = binding.replace(/^@/, '')
|
|
678
|
+
set(key, [])
|
|
679
|
+
runQuery({ method: 'GET', path, target: binding })
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
474
683
|
// ── Boot ──────────────────────────────────────────────────────────
|
|
475
684
|
function boot() {
|
|
685
|
+
loadSSRData()
|
|
476
686
|
injectActionCSS()
|
|
687
|
+
initAnimations()
|
|
477
688
|
hydrateBindings()
|
|
478
689
|
hydrateTables()
|
|
690
|
+
hydrateTableErrors()
|
|
479
691
|
hydrateLists()
|
|
480
692
|
hydrateForms()
|
|
693
|
+
hydrateOptimistic()
|
|
481
694
|
hydrateBtns()
|
|
482
695
|
hydrateSelects()
|
|
483
696
|
hydrateIfs()
|
|
697
|
+
hydrateEach()
|
|
484
698
|
mountQueries()
|
|
485
699
|
}
|
|
486
700
|
|
|
701
|
+
// ── Hydrate each @list { template } ──────────────────────────────
|
|
702
|
+
function hydrateEach() {
|
|
703
|
+
document.querySelectorAll('[data-fx-each]').forEach(wrap => {
|
|
704
|
+
const binding = wrap.getAttribute('data-fx-each')
|
|
705
|
+
const tpl = wrap.getAttribute('data-fx-tpl') || ''
|
|
706
|
+
const key = binding.startsWith('@') ? binding.slice(1) : binding
|
|
707
|
+
|
|
708
|
+
const render = () => {
|
|
709
|
+
let items = get(key)
|
|
710
|
+
if (!Array.isArray(items)) items = []
|
|
711
|
+
wrap.innerHTML = ''
|
|
712
|
+
|
|
713
|
+
if (!items.length) {
|
|
714
|
+
const empty = document.createElement('div')
|
|
715
|
+
empty.className = 'fx-each-empty fx-td-empty'
|
|
716
|
+
empty.textContent = wrap.getAttribute('data-fx-empty') || 'No items.'
|
|
717
|
+
wrap.appendChild(empty)
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
items.forEach(item => {
|
|
722
|
+
const div = document.createElement('div')
|
|
723
|
+
div.className = 'fx-each-item'
|
|
724
|
+
// Interpolate {item.field} syntax in template
|
|
725
|
+
const html = tpl.replace(/\{item\.([^}]+)\}/g, (_, field) => {
|
|
726
|
+
const parts = field.split('.')
|
|
727
|
+
let val = item
|
|
728
|
+
for (const p of parts) val = val?.[p]
|
|
729
|
+
return val != null ? String(val) : ''
|
|
730
|
+
})
|
|
731
|
+
div.textContent = html || (item.name || item.title || item.label || JSON.stringify(item))
|
|
732
|
+
wrap.appendChild(div)
|
|
733
|
+
})
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
watch(key, render)
|
|
737
|
+
render()
|
|
738
|
+
})
|
|
739
|
+
}
|
|
740
|
+
|
|
487
741
|
if (document.readyState === 'loading') {
|
|
488
742
|
document.addEventListener('DOMContentLoaded', boot)
|
|
489
743
|
} else {
|
package/server/server.js
CHANGED
|
@@ -1797,7 +1797,7 @@ async function startServer(aipFile, port = 3000) {
|
|
|
1797
1797
|
|
|
1798
1798
|
// Health
|
|
1799
1799
|
srv.addRoute('GET', '/health', (req, res) => res.json(200, {
|
|
1800
|
-
status:'ok', version:'2.
|
|
1800
|
+
status:'ok', version:'2.10.1',
|
|
1801
1801
|
models: app.models.map(m=>m.name),
|
|
1802
1802
|
routes: app.apis.length, pages: app.pages.length,
|
|
1803
1803
|
admin: app.admin?.prefix || null,
|