weifuwu 0.27.11 → 0.27.13
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/README.md +44 -60
- package/dist/docs/ssr/ui.md +472 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +40 -29
- package/dist/ssr/assets.d.ts +3 -18
- package/dist/ssr/ui/assets.d.ts +2 -0
- package/dist/template/AGENTS.md +30 -0
- package/dist/template/app.ts +7 -10
- package/dist/template/locales/en.json +2 -2
- package/dist/template/locales/zh-CN.json +2 -2
- package/dist/template/ui/app/globals.css +2 -8
- package/dist/template/ui/app/layout.ts +32 -37
- package/dist/template/ui/app/page.ts +20 -36
- package/dist/weifuwu-ui.css +646 -0
- package/dist/weifuwu-ui.js +976 -0
- package/package.json +3 -1
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* weifuwu-ui.js — Zero-dependency frontend runtime for weifuwu SSR.
|
|
3
|
+
*
|
|
4
|
+
* One <script> covers: AJAX, state binding, SSE streaming, WebSocket,
|
|
5
|
+
* theme switching, i18n, flash messages, and UI components.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* <script src="/__wfw/js/weifuwu-ui.js"></script>
|
|
9
|
+
* <link rel="stylesheet" href="/__wfw/css/weifuwu-ui.css">
|
|
10
|
+
*
|
|
11
|
+
* @license MIT
|
|
12
|
+
* @version 0.1.0
|
|
13
|
+
*/
|
|
14
|
+
(function () {
|
|
15
|
+
'use strict'
|
|
16
|
+
|
|
17
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
18
|
+
// Internal state
|
|
19
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
20
|
+
|
|
21
|
+
/** WeakMap<Element, Proxy> — wu-data reactive state per element */
|
|
22
|
+
const states = new WeakMap()
|
|
23
|
+
|
|
24
|
+
/** Guard to prevent duplicate initialization */
|
|
25
|
+
let _wuInitialized = false
|
|
26
|
+
|
|
27
|
+
/** Active SSE/WS connections for abort */
|
|
28
|
+
let activeStream = null
|
|
29
|
+
|
|
30
|
+
/** WebSocket connections tracked by element */
|
|
31
|
+
const wsConnections = new WeakMap()
|
|
32
|
+
|
|
33
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
34
|
+
// Public API
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
36
|
+
|
|
37
|
+
const wu = {
|
|
38
|
+
/** Initialize/reinitialize all wu-* behaviors under a root element */
|
|
39
|
+
init,
|
|
40
|
+
/** Get the reactive state for an element (from its nearest [wu-data] ancestor) */
|
|
41
|
+
getState,
|
|
42
|
+
/** Access the DOM (public ref for extensions) */
|
|
43
|
+
dom: { getState, findState },
|
|
44
|
+
}
|
|
45
|
+
// These are assigned later via property assignment
|
|
46
|
+
// (they cannot be in the object literal because they aren't hoisted).
|
|
47
|
+
wu.abort = function () {}
|
|
48
|
+
wu.send = function () {}
|
|
49
|
+
wu.stream = function () {}
|
|
50
|
+
wu.toast = function () {}
|
|
51
|
+
|
|
52
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
53
|
+
// 1. Initialization
|
|
54
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
55
|
+
|
|
56
|
+
function init(root) {
|
|
57
|
+
if (root === document || !root) {
|
|
58
|
+
if (_wuInitialized) return
|
|
59
|
+
_wuInitialized = true
|
|
60
|
+
}
|
|
61
|
+
root = root || document
|
|
62
|
+
if (root === document) {
|
|
63
|
+
initTheme()
|
|
64
|
+
initFlash()
|
|
65
|
+
initI18n()
|
|
66
|
+
}
|
|
67
|
+
initStates(root)
|
|
68
|
+
initBindings(root)
|
|
69
|
+
initActions(root)
|
|
70
|
+
initTriggers(root)
|
|
71
|
+
initSSE(root)
|
|
72
|
+
initWS(root)
|
|
73
|
+
initComponents(root)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Auto-init on DOMContentLoaded
|
|
77
|
+
if (document.readyState === 'loading') {
|
|
78
|
+
document.addEventListener('DOMContentLoaded', () => init())
|
|
79
|
+
} else {
|
|
80
|
+
init()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Re-init after AJAX content replacement
|
|
84
|
+
const origPushState = history.pushState
|
|
85
|
+
history.pushState = function () {
|
|
86
|
+
origPushState.apply(this, arguments)
|
|
87
|
+
init(document.body)
|
|
88
|
+
}
|
|
89
|
+
window.addEventListener('popstate', () => init(document.body))
|
|
90
|
+
|
|
91
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
92
|
+
// 2. Reactive state (wu-data)
|
|
93
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
94
|
+
|
|
95
|
+
function initStates(root) {
|
|
96
|
+
root.querySelectorAll('[wu-data]').forEach((el) => {
|
|
97
|
+
if (states.has(el)) return // already initialized
|
|
98
|
+
try {
|
|
99
|
+
const raw = el.getAttribute('wu-data')
|
|
100
|
+
const initial = JSON.parse(raw)
|
|
101
|
+
const state = createReactiveState(el, initial)
|
|
102
|
+
states.set(el, state)
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// eslint-disable-next-line no-console
|
|
105
|
+
console.warn('[wu] Invalid wu-data:', el.getAttribute('wu-data'), e)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function createReactiveState(el, initial) {
|
|
111
|
+
const target = { ...initial }
|
|
112
|
+
const proxy = new Proxy(target, {
|
|
113
|
+
set(obj, key, value) {
|
|
114
|
+
const old = obj[key]
|
|
115
|
+
if (old === value) return true
|
|
116
|
+
obj[key] = value
|
|
117
|
+
// Update all bindings under this element
|
|
118
|
+
queueBindingUpdate(el, key, value)
|
|
119
|
+
return true
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
return proxy
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Find the nearest [wu-data] ancestor and return its state */
|
|
126
|
+
function findState(el) {
|
|
127
|
+
const parent = el.closest('[wu-data]')
|
|
128
|
+
return parent ? states.get(parent) : null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getState(el) {
|
|
132
|
+
if (states.has(el)) return states.get(el)
|
|
133
|
+
return findState(el)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Apply binding updates synchronously
|
|
137
|
+
function queueBindingUpdate(root, key, value) {
|
|
138
|
+
applyBindings(root, key, value)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function applyBindings(root, key, value) {
|
|
142
|
+
const els = root.querySelectorAll('[wu-text="' + CSS.escape(key) + '"]')
|
|
143
|
+
for (const el of els) {
|
|
144
|
+
el.textContent = value == null ? '' : String(value)
|
|
145
|
+
}
|
|
146
|
+
const showEls = root.querySelectorAll('[wu-show="' + CSS.escape(key) + '"]')
|
|
147
|
+
for (const el of showEls) {
|
|
148
|
+
el.style.display = value ? '' : 'none'
|
|
149
|
+
}
|
|
150
|
+
const hideEls = root.querySelectorAll('[wu-hide="' + CSS.escape(key) + '"]')
|
|
151
|
+
for (const el of hideEls) {
|
|
152
|
+
el.style.display = value ? 'none' : ''
|
|
153
|
+
}
|
|
154
|
+
const classEls = root.querySelectorAll('[wu-class]')
|
|
155
|
+
for (const el of classEls) {
|
|
156
|
+
const expr = el.getAttribute('wu-class')
|
|
157
|
+
const state = findState(el)
|
|
158
|
+
if (state) el.className = evaluateExpr(expr, state) || ''
|
|
159
|
+
}
|
|
160
|
+
const htmlEls = root.querySelectorAll('[wu-html="' + CSS.escape(key) + '"]')
|
|
161
|
+
for (const el of htmlEls) {
|
|
162
|
+
el.innerHTML = value == null ? '' : String(value)
|
|
163
|
+
}
|
|
164
|
+
// Re-render wu-each (exact match + prefix match for nested paths)
|
|
165
|
+
const eachEls = root.querySelectorAll('[wu-each]')
|
|
166
|
+
for (const el of eachEls) {
|
|
167
|
+
const eachPath = el.getAttribute('wu-each')
|
|
168
|
+
if (eachPath === key || eachPath.startsWith(key + '.')) {
|
|
169
|
+
renderEach(el)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
175
|
+
// 3. Binding initialization (static render)
|
|
176
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
177
|
+
|
|
178
|
+
function initBindings(root) {
|
|
179
|
+
// wu-text: set initial text from state
|
|
180
|
+
root.querySelectorAll('[wu-text]').forEach((el) => {
|
|
181
|
+
const key = el.getAttribute('wu-text')
|
|
182
|
+
const state = findState(el)
|
|
183
|
+
if (state) {
|
|
184
|
+
const val = getNestedValue(state, key)
|
|
185
|
+
el.textContent = val == null ? '' : String(val)
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
// wu-show / wu-hide: initial visibility
|
|
189
|
+
root.querySelectorAll('[wu-show], [wu-hide]').forEach((el) => {
|
|
190
|
+
const key = el.getAttribute('wu-show') || el.getAttribute('wu-hide')
|
|
191
|
+
const state = findState(el)
|
|
192
|
+
if (state) {
|
|
193
|
+
const val = getNestedValue(state, key)
|
|
194
|
+
const isShow = el.hasAttribute('wu-show')
|
|
195
|
+
el.style.display = val ? (isShow ? '' : 'none') : isShow ? 'none' : ''
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
// wu-class: initial class
|
|
199
|
+
root.querySelectorAll('[wu-class]').forEach((el) => {
|
|
200
|
+
const expr = el.getAttribute('wu-class')
|
|
201
|
+
const state = findState(el)
|
|
202
|
+
if (state) {
|
|
203
|
+
el.className = evaluateExpr(expr, state) || ''
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
// wu-model: initial value
|
|
207
|
+
root.querySelectorAll('[wu-model]').forEach((el) => {
|
|
208
|
+
const key = el.getAttribute('wu-model')
|
|
209
|
+
const state = findState(el)
|
|
210
|
+
if (state) {
|
|
211
|
+
const val = getNestedValue(state, key)
|
|
212
|
+
el.value = val == null ? '' : String(val)
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
// wu-each: initial render
|
|
216
|
+
root.querySelectorAll('[wu-each]').forEach(renderEach)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
220
|
+
// 4. wu-each: list rendering
|
|
221
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
222
|
+
|
|
223
|
+
/** Get nested property value by dot-separated path */
|
|
224
|
+
function getNestedValue(obj, path) {
|
|
225
|
+
return path.split('.').reduce((o, k) => (o != null ? o[k] : undefined), obj)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderEach(el) {
|
|
229
|
+
const path = el.getAttribute('wu-each')
|
|
230
|
+
const state = findState(el)
|
|
231
|
+
if (!state) return
|
|
232
|
+
const items = getNestedValue(state, path)
|
|
233
|
+
if (!Array.isArray(items)) return
|
|
234
|
+
// Save the template from first render
|
|
235
|
+
let template = el._wu_template
|
|
236
|
+
if (!template) {
|
|
237
|
+
template = el.innerHTML
|
|
238
|
+
el._wu_template = template
|
|
239
|
+
}
|
|
240
|
+
el.innerHTML = items
|
|
241
|
+
.map((item, index) => {
|
|
242
|
+
const rendered = template
|
|
243
|
+
.replace(/\$\{index\}/g, String(index))
|
|
244
|
+
.replace(/\$\{this\}/g, String(item == null ? '' : item))
|
|
245
|
+
// Support nested path access: ${item.name}, ${item.user.email}
|
|
246
|
+
return rendered.replace(/\$\{item\.([^}]+)\}/g, (_, prop) => {
|
|
247
|
+
return String(getNestedValue(item, prop) ?? '')
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
.join('')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
254
|
+
// 5. wu-on: event binding
|
|
255
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
256
|
+
|
|
257
|
+
function initActions(root) {
|
|
258
|
+
// Event delegation for wu-on
|
|
259
|
+
root.addEventListener('click', (e) => {
|
|
260
|
+
const el = e.target.closest('[wu-on]')
|
|
261
|
+
if (!el) return
|
|
262
|
+
const expr = el.getAttribute('wu-on')
|
|
263
|
+
handleAction(expr, el, e)
|
|
264
|
+
})
|
|
265
|
+
root.addEventListener('keyup', (e) => {
|
|
266
|
+
const el = e.target.closest('[wu-on]')
|
|
267
|
+
if (!el) return
|
|
268
|
+
const expr = el.getAttribute('wu-on')
|
|
269
|
+
if (!expr.includes('keyup')) return
|
|
270
|
+
handleAction(expr, el, e)
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function handleAction(expr, el, event) {
|
|
275
|
+
const state = findState(el)
|
|
276
|
+
if (!state) return
|
|
277
|
+
|
|
278
|
+
// Parse "eventType: expression" or just "expression"
|
|
279
|
+
let actionExpr = expr
|
|
280
|
+
const colonIdx = expr.indexOf(':')
|
|
281
|
+
if (colonIdx !== -1) {
|
|
282
|
+
const eventType = expr.slice(0, colonIdx).trim()
|
|
283
|
+
// Filter by event type
|
|
284
|
+
if (eventType === 'click' && event.type !== 'click') return
|
|
285
|
+
if (eventType === 'keyup' && event.type !== 'keyup') return
|
|
286
|
+
actionExpr = expr.slice(colonIdx + 1).trim()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
evaluateExpr(actionExpr, state)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
293
|
+
// 6. Expression evaluator
|
|
294
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
295
|
+
|
|
296
|
+
function evaluateExpr(expr, state) {
|
|
297
|
+
// Execute expression in the Proxy's scope via with().
|
|
298
|
+
// The Proxy's set trap catches mutations and triggers binding updates.
|
|
299
|
+
// Returns the expression result (used by wu-class).
|
|
300
|
+
try {
|
|
301
|
+
const fn = new Function('$s', `with($s) { return (${expr}) }`)
|
|
302
|
+
return fn(state)
|
|
303
|
+
} catch (e) {
|
|
304
|
+
// eslint-disable-next-line no-console
|
|
305
|
+
console.warn('[wu] Expression error:', expr, e)
|
|
306
|
+
return ''
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
311
|
+
// 7. wu-model: two-way binding
|
|
312
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
313
|
+
|
|
314
|
+
function initModels(root) {
|
|
315
|
+
root.querySelectorAll('[wu-model]').forEach((el) => {
|
|
316
|
+
const key = el.getAttribute('wu-model')
|
|
317
|
+
const state = findState(el)
|
|
318
|
+
if (!state) return
|
|
319
|
+
|
|
320
|
+
el.addEventListener('input', () => {
|
|
321
|
+
state[key] = el.value
|
|
322
|
+
})
|
|
323
|
+
el.addEventListener('change', () => {
|
|
324
|
+
state[key] = el.value
|
|
325
|
+
})
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
330
|
+
// 8. wu-get / wu-post / wu-put / wu-patch / wu-delete (AJAX)
|
|
331
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
332
|
+
|
|
333
|
+
function initTriggers(root) {
|
|
334
|
+
// Click triggers: [wu-get], [wu-post], [wu-put], [wu-patch], [wu-delete]
|
|
335
|
+
root.addEventListener('click', (e) => {
|
|
336
|
+
const el = e.target.closest('[wu-get],[wu-post],[wu-put],[wu-patch],[wu-delete]')
|
|
337
|
+
if (!el) return
|
|
338
|
+
const method = el.hasAttribute('wu-get')
|
|
339
|
+
? 'GET'
|
|
340
|
+
: el.hasAttribute('wu-post')
|
|
341
|
+
? 'POST'
|
|
342
|
+
: el.hasAttribute('wu-put')
|
|
343
|
+
? 'PUT'
|
|
344
|
+
: el.hasAttribute('wu-patch')
|
|
345
|
+
? 'PATCH'
|
|
346
|
+
: 'DELETE'
|
|
347
|
+
const url =
|
|
348
|
+
el.getAttribute('wu-get') ||
|
|
349
|
+
el.getAttribute('wu-post') ||
|
|
350
|
+
el.getAttribute('wu-put') ||
|
|
351
|
+
el.getAttribute('wu-patch') ||
|
|
352
|
+
el.getAttribute('wu-delete')
|
|
353
|
+
triggerRequest(el, method, url, e)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
// Load triggers: [wu-get wu-trigger="load"]
|
|
357
|
+
root.querySelectorAll('[wu-trigger="load"]').forEach((el) => {
|
|
358
|
+
const method = el.hasAttribute('wu-get') ? 'GET' : 'POST'
|
|
359
|
+
const url = el.getAttribute('wu-get') || el.getAttribute('wu-post')
|
|
360
|
+
if (url) triggerRequest(el, method, url, null)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
// Every triggers: [wu-trigger="every:5s"]
|
|
364
|
+
root.querySelectorAll('[wu-trigger]').forEach((el) => {
|
|
365
|
+
const trigger = el.getAttribute('wu-trigger')
|
|
366
|
+
const m = trigger && trigger.match(/^every:(\d+)$/)
|
|
367
|
+
if (!m) return
|
|
368
|
+
const interval = parseInt(m[1], 10) * 1000
|
|
369
|
+
const method = el.hasAttribute('wu-get') ? 'GET' : 'POST'
|
|
370
|
+
const url = el.getAttribute('wu-get') || el.getAttribute('wu-post')
|
|
371
|
+
if (url) {
|
|
372
|
+
setInterval(() => triggerRequest(el, method, url, null), interval)
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
// visible triggers: [wu-trigger="visible"]
|
|
377
|
+
if ('IntersectionObserver' in window) {
|
|
378
|
+
root.querySelectorAll('[wu-trigger="visible"]').forEach((el) => {
|
|
379
|
+
const method = el.hasAttribute('wu-get') ? 'GET' : 'POST'
|
|
380
|
+
const url = el.getAttribute('wu-get') || el.getAttribute('wu-post')
|
|
381
|
+
if (!url) return
|
|
382
|
+
const observer = new IntersectionObserver(
|
|
383
|
+
(entries) => {
|
|
384
|
+
for (const entry of entries) {
|
|
385
|
+
if (entry.isIntersecting) {
|
|
386
|
+
triggerRequest(el, method, url, null)
|
|
387
|
+
observer.disconnect()
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
{ rootMargin: '100px' },
|
|
392
|
+
)
|
|
393
|
+
observer.observe(el)
|
|
394
|
+
})
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Form submit triggers
|
|
398
|
+
root.addEventListener('submit', (e) => {
|
|
399
|
+
const form = e.target.closest('[wu-post],[wu-put]')
|
|
400
|
+
if (!form) return
|
|
401
|
+
e.preventDefault()
|
|
402
|
+
const method = form.hasAttribute('wu-post') ? 'POST' : 'PUT'
|
|
403
|
+
const url = form.getAttribute('wu-post') || form.getAttribute('wu-put')
|
|
404
|
+
triggerRequest(form, method, url, e)
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function triggerRequest(el, method, url, _event) {
|
|
409
|
+
// Confirmation
|
|
410
|
+
const confirmMsg = el.getAttribute('wu-confirm')
|
|
411
|
+
if (confirmMsg && !confirm(confirmMsg)) return
|
|
412
|
+
|
|
413
|
+
// Build options
|
|
414
|
+
const opts = { method, headers: {} }
|
|
415
|
+
const isForm = el.tagName === 'FORM'
|
|
416
|
+
|
|
417
|
+
// Body: from form or wu-data
|
|
418
|
+
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
|
419
|
+
if (isForm) {
|
|
420
|
+
opts.body = new FormData(el)
|
|
421
|
+
} else {
|
|
422
|
+
const state = findState(el)
|
|
423
|
+
if (state && el.getAttribute('wu-data')) {
|
|
424
|
+
opts.headers['Content-Type'] = 'application/json'
|
|
425
|
+
opts.body = JSON.stringify(state)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Show loading indicator
|
|
431
|
+
const loadingId = el.getAttribute('wu-loading')
|
|
432
|
+
const loadingEl = loadingId ? document.querySelector(loadingId) : null
|
|
433
|
+
if (loadingEl) loadingEl.classList.remove('wu-hidden')
|
|
434
|
+
|
|
435
|
+
// wu-stream check
|
|
436
|
+
const isStream = el.hasAttribute('wu-stream')
|
|
437
|
+
const target = el.getAttribute('wu-target')
|
|
438
|
+
|
|
439
|
+
if (isStream) {
|
|
440
|
+
// Abort previous stream
|
|
441
|
+
if (activeStream) activeStream.abort()
|
|
442
|
+
const controller = new AbortController()
|
|
443
|
+
activeStream = controller
|
|
444
|
+
opts.signal = controller.signal
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
fetch(url, opts)
|
|
448
|
+
.then(async (res) => {
|
|
449
|
+
if (loadingEl) loadingEl.classList.add('wu-hidden')
|
|
450
|
+
|
|
451
|
+
if (isStream) {
|
|
452
|
+
return handleStreamResponse(res, el)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!res.ok) {
|
|
456
|
+
// Try to parse error JSON
|
|
457
|
+
try {
|
|
458
|
+
const errData = await res.json()
|
|
459
|
+
if (errData.errors) {
|
|
460
|
+
handleErrors(el, errData.errors)
|
|
461
|
+
}
|
|
462
|
+
} catch {}
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Handle redirect
|
|
467
|
+
const redirect = res.headers.get('X-WFU-Redirect')
|
|
468
|
+
if (redirect) {
|
|
469
|
+
return (window.location.href = redirect)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const html = await res.text()
|
|
473
|
+
if (!target) {
|
|
474
|
+
document.open()
|
|
475
|
+
document.write(html)
|
|
476
|
+
document.close()
|
|
477
|
+
init()
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const targetEl = document.querySelector(target)
|
|
482
|
+
if (!targetEl) return
|
|
483
|
+
|
|
484
|
+
const swap = el.getAttribute('wu-swap') || 'innerHTML'
|
|
485
|
+
applySwap(targetEl, html, swap)
|
|
486
|
+
|
|
487
|
+
// Re-init inside the replaced content
|
|
488
|
+
init(targetEl)
|
|
489
|
+
})
|
|
490
|
+
.catch((err) => {
|
|
491
|
+
if (err.name === 'AbortError') return
|
|
492
|
+
if (loadingEl) loadingEl.classList.add('wu-hidden')
|
|
493
|
+
// eslint-disable-next-line no-console
|
|
494
|
+
console.warn('[wu] Fetch error:', err)
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function applySwap(target, html, swap) {
|
|
499
|
+
switch (swap) {
|
|
500
|
+
case 'outerHTML':
|
|
501
|
+
target.outerHTML = html
|
|
502
|
+
break
|
|
503
|
+
case 'before':
|
|
504
|
+
target.insertAdjacentHTML('beforebegin', html)
|
|
505
|
+
break
|
|
506
|
+
case 'after':
|
|
507
|
+
target.insertAdjacentHTML('afterend', html)
|
|
508
|
+
break
|
|
509
|
+
case 'prepend':
|
|
510
|
+
target.insertAdjacentHTML('afterbegin', html)
|
|
511
|
+
break
|
|
512
|
+
case 'append':
|
|
513
|
+
target.insertAdjacentHTML('beforeend', html)
|
|
514
|
+
break
|
|
515
|
+
default:
|
|
516
|
+
target.innerHTML = html
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function handleErrors(el, errors) {
|
|
521
|
+
const root = el.closest('[wu-data]') || el
|
|
522
|
+
for (const [field, message] of Object.entries(errors)) {
|
|
523
|
+
const errorEl = root.querySelector('[wu-error="' + field + '"]')
|
|
524
|
+
if (errorEl) errorEl.textContent = message
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
529
|
+
// 9. SSE streaming (wu-stream, wu-sse)
|
|
530
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
531
|
+
|
|
532
|
+
function initSSE(root) {
|
|
533
|
+
// Auto-connect SSE on [wu-sse] elements
|
|
534
|
+
root.querySelectorAll('[wu-sse]').forEach((el) => {
|
|
535
|
+
if (el._wu_sse) return
|
|
536
|
+
const url = el.getAttribute('wu-sse')
|
|
537
|
+
connectSSE(el, url)
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function connectSSE(el, url) {
|
|
542
|
+
el._wu_sse = true
|
|
543
|
+
const es = new EventSource(url)
|
|
544
|
+
el._wu_es = es
|
|
545
|
+
|
|
546
|
+
es.addEventListener('message', (e) => {
|
|
547
|
+
try {
|
|
548
|
+
const handler = el.getAttribute('wu-on-sse-message')
|
|
549
|
+
if (handler) {
|
|
550
|
+
const state = findState(el)
|
|
551
|
+
if (state) {
|
|
552
|
+
const _data = JSON.parse(e.data)
|
|
553
|
+
const fn = new Function('$s', 'data', `with($s) { ${handler} }`)
|
|
554
|
+
fn(state, _data)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
} catch {}
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
// Custom event handlers: wu-on-sse-{eventName}
|
|
561
|
+
const handlerAttr = Array.from(el.attributes)
|
|
562
|
+
.filter((a) => a.name.startsWith('wu-on-sse-'))
|
|
563
|
+
for (const attr of handlerAttr) {
|
|
564
|
+
const eventName = attr.name.slice('wu-on-sse-'.length)
|
|
565
|
+
const handler = attr.value
|
|
566
|
+
es.addEventListener(eventName, (e) => {
|
|
567
|
+
try {
|
|
568
|
+
const data = JSON.parse(e.data)
|
|
569
|
+
const state = findState(el)
|
|
570
|
+
if (state) {
|
|
571
|
+
const fn = new Function('$s', 'data', `with($s) { ${handler} }`)
|
|
572
|
+
fn(state, data)
|
|
573
|
+
}
|
|
574
|
+
} catch (err) {
|
|
575
|
+
// eslint-disable-next-line no-console
|
|
576
|
+
console.warn('[wu] SSE handler error:', err)
|
|
577
|
+
}
|
|
578
|
+
})
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Programmatic SSE stream (used with wu-post/wu-get wu-stream) */
|
|
583
|
+
function handleStreamResponse(res, el) {
|
|
584
|
+
const reader = res.body.getReader()
|
|
585
|
+
const decoder = new TextDecoder()
|
|
586
|
+
let buffer = ''
|
|
587
|
+
|
|
588
|
+
async function read() {
|
|
589
|
+
while (true) {
|
|
590
|
+
const { done, value } = await reader.read()
|
|
591
|
+
if (done) break
|
|
592
|
+
buffer += decoder.decode(value, { stream: true })
|
|
593
|
+
const lines = buffer.split('\n')
|
|
594
|
+
buffer = lines.pop() || ''
|
|
595
|
+
for (const line of lines) {
|
|
596
|
+
if (!line.startsWith('data: ')) continue
|
|
597
|
+
try {
|
|
598
|
+
const data = JSON.parse(line.slice(6))
|
|
599
|
+
emitSSEEvent(el, data)
|
|
600
|
+
} catch {}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
read().catch(() => {
|
|
606
|
+
activeStream = null
|
|
607
|
+
})
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function emitSSEEvent(el, data) {
|
|
611
|
+
if (!data.type) return
|
|
612
|
+
const handlerAttr = 'wu-on-sse-' + data.type
|
|
613
|
+
const state = findState(el)
|
|
614
|
+
if (!state) return
|
|
615
|
+
|
|
616
|
+
// Find handler in el or ancestors
|
|
617
|
+
const handler = el.getAttribute(handlerAttr)
|
|
618
|
+
if (handler) {
|
|
619
|
+
const fn = new Function('$s', 'data', `with($s) { ${handler} }`)
|
|
620
|
+
fn(state, data)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
wu.stream = function (method, url, opts) {
|
|
625
|
+
if (activeStream) activeStream.abort()
|
|
626
|
+
const controller = new AbortController()
|
|
627
|
+
activeStream = controller
|
|
628
|
+
|
|
629
|
+
const fetchOpts = {
|
|
630
|
+
method,
|
|
631
|
+
headers: { 'Content-Type': 'application/json' },
|
|
632
|
+
signal: controller.signal,
|
|
633
|
+
...(opts.body ? { body: opts.body } : {}),
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
fetch(url, fetchOpts)
|
|
637
|
+
.then(async (res) => {
|
|
638
|
+
const reader = res.body.getReader()
|
|
639
|
+
const decoder = new TextDecoder()
|
|
640
|
+
let buffer = ''
|
|
641
|
+
|
|
642
|
+
while (true) {
|
|
643
|
+
const { done, value } = await reader.read()
|
|
644
|
+
if (done) break
|
|
645
|
+
buffer += decoder.decode(value, { stream: true })
|
|
646
|
+
const lines = buffer.split('\n')
|
|
647
|
+
buffer = lines.pop() || ''
|
|
648
|
+
for (const line of lines) {
|
|
649
|
+
if (!line.startsWith('data: ')) continue
|
|
650
|
+
try {
|
|
651
|
+
const data = JSON.parse(line.slice(6))
|
|
652
|
+
if (opts.onEvent && opts.onEvent[data.type]) {
|
|
653
|
+
opts.onEvent[data.type](data)
|
|
654
|
+
}
|
|
655
|
+
} catch {}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
})
|
|
659
|
+
.catch(() => {})
|
|
660
|
+
.finally(() => {
|
|
661
|
+
if (opts.onDone) opts.onDone()
|
|
662
|
+
})
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
wu.abort = function () {
|
|
666
|
+
if (activeStream) {
|
|
667
|
+
activeStream.abort()
|
|
668
|
+
activeStream = null
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
673
|
+
// 10. WebSocket (wu-ws)
|
|
674
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
675
|
+
|
|
676
|
+
function initWS(root) {
|
|
677
|
+
root.querySelectorAll('[wu-ws]').forEach((el) => {
|
|
678
|
+
if (wsConnections.has(el)) return
|
|
679
|
+
const url = el.getAttribute('wu-ws')
|
|
680
|
+
const ws = new WebSocket(url)
|
|
681
|
+
wsConnections.set(el, ws)
|
|
682
|
+
|
|
683
|
+
ws.onopen = () => {
|
|
684
|
+
const handler = el.getAttribute('wu-on-ws-open')
|
|
685
|
+
if (handler) {
|
|
686
|
+
const state = findState(el)
|
|
687
|
+
if (state) evaluateExpr(handler, state)
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
ws.onclose = () => {
|
|
692
|
+
const handler = el.getAttribute('wu-on-ws-close')
|
|
693
|
+
if (handler) {
|
|
694
|
+
const state = findState(el)
|
|
695
|
+
if (state) evaluateExpr(handler, state)
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
ws.onmessage = (e) => {
|
|
700
|
+
const handler = el.getAttribute('wu-on-ws-message')
|
|
701
|
+
if (handler) {
|
|
702
|
+
const state = findState(el)
|
|
703
|
+
if (state) {
|
|
704
|
+
const fn = new Function('$s', 'data', `with($s) { ${handler} }`)
|
|
705
|
+
fn(state, e.data)
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
})
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
wu.send = function (data) {
|
|
713
|
+
for (const [, ws] of wsConnections) {
|
|
714
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
715
|
+
ws.send(typeof data === 'string' ? data : JSON.stringify(data))
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
721
|
+
// 11. Theme (wu-theme)
|
|
722
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
723
|
+
|
|
724
|
+
function initTheme() {
|
|
725
|
+
// Set initial theme from cookie
|
|
726
|
+
const cookie = document.cookie.match(/theme=([^;]+)/)?.[1] || 'system'
|
|
727
|
+
applyTheme(cookie)
|
|
728
|
+
|
|
729
|
+
// Listen for system preference changes
|
|
730
|
+
if (window.matchMedia) {
|
|
731
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
732
|
+
const current = document.cookie.match(/theme=([^;]+)/)?.[1] || 'system'
|
|
733
|
+
if (current === 'system') applyTheme('system')
|
|
734
|
+
})
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function applyTheme(value) {
|
|
739
|
+
const theme =
|
|
740
|
+
value === 'system'
|
|
741
|
+
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
742
|
+
? 'dark'
|
|
743
|
+
: 'light'
|
|
744
|
+
: value
|
|
745
|
+
document.documentElement.setAttribute('data-theme', theme)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Theme switching (delegated click)
|
|
749
|
+
document.addEventListener('click', (e) => {
|
|
750
|
+
const btn = e.target.closest('[wu-theme]')
|
|
751
|
+
if (!btn) return
|
|
752
|
+
const value = btn.getAttribute('wu-theme')
|
|
753
|
+
applyTheme(value)
|
|
754
|
+
document.cookie = 'theme=' + value + '; Path=/; SameSite=Lax; Max-Age=31536000'
|
|
755
|
+
// Sync with server (no redirect, JSON request)
|
|
756
|
+
fetch('/__theme/' + value, { headers: { Accept: 'application/json' } }).catch(() => {})
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
760
|
+
// 12. i18n (wu-lang, wu-text-key)
|
|
761
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
762
|
+
|
|
763
|
+
let i18nMessages = {}
|
|
764
|
+
|
|
765
|
+
function initI18n() {
|
|
766
|
+
const script = document.getElementById('__wfw-i18n')
|
|
767
|
+
if (script) {
|
|
768
|
+
try {
|
|
769
|
+
i18nMessages = JSON.parse(script.textContent)
|
|
770
|
+
} catch {}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Language switching (delegated click)
|
|
775
|
+
document.addEventListener('click', (e) => {
|
|
776
|
+
const btn = e.target.closest('[wu-lang]')
|
|
777
|
+
if (!btn) return
|
|
778
|
+
const locale = btn.getAttribute('wu-lang')
|
|
779
|
+
switchLocale(locale)
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
async function switchLocale(locale) {
|
|
783
|
+
try {
|
|
784
|
+
const res = await fetch('/__lang/' + locale, {
|
|
785
|
+
headers: { Accept: 'application/json' },
|
|
786
|
+
})
|
|
787
|
+
const data = await res.json()
|
|
788
|
+
// locale updated server-side via data-locale attribute
|
|
789
|
+
i18nMessages = data.messages || {}
|
|
790
|
+
document.cookie = 'locale=' + locale + '; Path=/; SameSite=Lax; Max-Age=31536000'
|
|
791
|
+
document.body.setAttribute('data-locale', locale)
|
|
792
|
+
// Update all wu-text-key elements
|
|
793
|
+
document.querySelectorAll('[wu-text-key]').forEach((el) => {
|
|
794
|
+
const key = el.getAttribute('wu-text-key')
|
|
795
|
+
el.textContent = translate(key)
|
|
796
|
+
})
|
|
797
|
+
} catch (err) {
|
|
798
|
+
// eslint-disable-next-line no-console
|
|
799
|
+
console.warn('[wu] i18n switch failed:', err)
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function translate(key) {
|
|
804
|
+
const msg = key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), i18nMessages)
|
|
805
|
+
return msg != null ? String(msg) : key
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
wu.t = translate
|
|
809
|
+
|
|
810
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
811
|
+
// 13. Flash (wu-flash)
|
|
812
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
813
|
+
|
|
814
|
+
function initFlash() {
|
|
815
|
+
const script = document.getElementById('__wfw-flash')
|
|
816
|
+
if (!script) return
|
|
817
|
+
try {
|
|
818
|
+
const data = JSON.parse(script.textContent)
|
|
819
|
+
const container = document.querySelector('[wu-flash]')
|
|
820
|
+
if (!container) return
|
|
821
|
+
showFlash(container, data)
|
|
822
|
+
} catch {}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function showFlash(container, data) {
|
|
826
|
+
const type = data.type || 'info'
|
|
827
|
+
const message = data.message || data.text || String(data)
|
|
828
|
+
const el = document.createElement('div')
|
|
829
|
+
el.className = 'wu-flash-msg wu-flash-' + type
|
|
830
|
+
el.textContent = message
|
|
831
|
+
container.appendChild(el)
|
|
832
|
+
setTimeout(() => {
|
|
833
|
+
el.classList.add('wu-flash-leaving')
|
|
834
|
+
setTimeout(() => el.remove(), 200)
|
|
835
|
+
}, 3000)
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
839
|
+
// 14. UI Components
|
|
840
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
841
|
+
|
|
842
|
+
function initComponents(root) {
|
|
843
|
+
initModal(root)
|
|
844
|
+
initCollapse(root)
|
|
845
|
+
initTabs(root)
|
|
846
|
+
initDropdown(root)
|
|
847
|
+
initToast(root)
|
|
848
|
+
initModels(root)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// ── Modal ──
|
|
852
|
+
|
|
853
|
+
function initModal(root) {
|
|
854
|
+
// Toggle buttons
|
|
855
|
+
root.addEventListener('click', (e) => {
|
|
856
|
+
const btn = e.target.closest('[wu-toggle]')
|
|
857
|
+
if (!btn) return
|
|
858
|
+
const target = btn.getAttribute('wu-target')
|
|
859
|
+
if (!target) return
|
|
860
|
+
const modal = root.querySelector(target)
|
|
861
|
+
if (modal && modal.hasAttribute('wu-modal')) {
|
|
862
|
+
modal.classList.toggle('wu-open')
|
|
863
|
+
}
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
// Close buttons
|
|
867
|
+
root.addEventListener('click', (e) => {
|
|
868
|
+
const btn = e.target.closest('[wu-close]')
|
|
869
|
+
if (!btn) return
|
|
870
|
+
const modal = btn.closest('[wu-modal]')
|
|
871
|
+
if (modal) modal.classList.remove('wu-open')
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
// Click outside to close
|
|
875
|
+
root.addEventListener('click', (e) => {
|
|
876
|
+
const modal = e.target.closest('[wu-modal].wu-open')
|
|
877
|
+
if (!modal) return
|
|
878
|
+
if (e.target === modal) modal.classList.remove('wu-open')
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
// ESC to close
|
|
882
|
+
root.addEventListener('keydown', (e) => {
|
|
883
|
+
if (e.key === 'Escape') {
|
|
884
|
+
root.querySelectorAll('[wu-modal].wu-open').forEach((m) => m.classList.remove('wu-open'))
|
|
885
|
+
}
|
|
886
|
+
})
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// ── Collapse ──
|
|
890
|
+
|
|
891
|
+
function initCollapse(root) {
|
|
892
|
+
root.addEventListener('click', (e) => {
|
|
893
|
+
const toggle = e.target.closest('[wu-collapse] > [wu-toggle]')
|
|
894
|
+
if (!toggle) return
|
|
895
|
+
toggle.parentElement.classList.toggle('wu-open')
|
|
896
|
+
})
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// ── Tabs ──
|
|
900
|
+
|
|
901
|
+
function initTabs(root) {
|
|
902
|
+
root.addEventListener('click', (e) => {
|
|
903
|
+
const tab = e.target.closest('[wu-tabs] [wu-tab]')
|
|
904
|
+
if (!tab) return
|
|
905
|
+
const tabs = tab.closest('[wu-tabs]')
|
|
906
|
+
const tabName = tab.getAttribute('wu-tab')
|
|
907
|
+
if (!tabs || !tabName) return
|
|
908
|
+
|
|
909
|
+
tabs.querySelectorAll('[wu-tab]').forEach((t) => t.classList.remove('wu-active'))
|
|
910
|
+
tab.classList.add('wu-active')
|
|
911
|
+
|
|
912
|
+
tabs.querySelectorAll('[wu-panel]').forEach((p) => p.classList.remove('wu-active'))
|
|
913
|
+
const panel = tabs.querySelector('[wu-panel="' + tabName + '"]')
|
|
914
|
+
if (panel) panel.classList.add('wu-active')
|
|
915
|
+
})
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ── Dropdown ──
|
|
919
|
+
|
|
920
|
+
function initDropdown(root) {
|
|
921
|
+
root.addEventListener('click', (e) => {
|
|
922
|
+
const dd = e.target.closest('[wu-dropdown]')
|
|
923
|
+
if (!dd) return
|
|
924
|
+
const toggle = e.target.closest('[wu-toggle]')
|
|
925
|
+
if (toggle && dd.contains(toggle)) {
|
|
926
|
+
dd.classList.toggle('wu-open')
|
|
927
|
+
e.stopPropagation()
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
// Close dropdowns on outside click
|
|
933
|
+
document.addEventListener('click', (e) => {
|
|
934
|
+
document.querySelectorAll('[wu-dropdown].wu-open').forEach((dd) => {
|
|
935
|
+
if (!dd.contains(e.target)) dd.classList.remove('wu-open')
|
|
936
|
+
})
|
|
937
|
+
})
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ── Toast ──
|
|
941
|
+
|
|
942
|
+
let toastContainer = null
|
|
943
|
+
|
|
944
|
+
function initToast() {
|
|
945
|
+
toastContainer = document.querySelector('.wu-toast-container')
|
|
946
|
+
if (!toastContainer) {
|
|
947
|
+
toastContainer = document.createElement('div')
|
|
948
|
+
toastContainer.className = 'wu-toast-container'
|
|
949
|
+
document.body.appendChild(toastContainer)
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
wu.toast = function (message, type) {
|
|
954
|
+
type = type || 'info'
|
|
955
|
+
const el = document.createElement('div')
|
|
956
|
+
el.className = 'wu-toast wu-toast-' + type
|
|
957
|
+
el.textContent = message
|
|
958
|
+
toastContainer.appendChild(el)
|
|
959
|
+
setTimeout(() => {
|
|
960
|
+
el.classList.add('wu-toast-leaving')
|
|
961
|
+
setTimeout(() => el.remove(), 200)
|
|
962
|
+
}, 3000)
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
966
|
+
// Expose
|
|
967
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
968
|
+
|
|
969
|
+
window.wu = wu
|
|
970
|
+
window.wu_ = wu // shorthand for inline scripts
|
|
971
|
+
|
|
972
|
+
// Convenience: re-init when new content is loaded
|
|
973
|
+
document.addEventListener('wu:content-loaded', (e) => {
|
|
974
|
+
init(e.detail || document.body)
|
|
975
|
+
})
|
|
976
|
+
})()
|