odac 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.agent/rules/memory.md +16 -1
  2. package/.github/workflows/release.yml +27 -5
  3. package/.husky/pre-push +3 -3
  4. package/.releaserc.js +2 -2
  5. package/AGENTS.md +47 -0
  6. package/CHANGELOG.md +64 -0
  7. package/README.md +1 -1
  8. package/bin/odac.js +187 -8
  9. package/client/odac.js +243 -178
  10. package/docs/ai/README.md +49 -0
  11. package/docs/ai/skills/SKILL.md +39 -0
  12. package/docs/ai/skills/backend/authentication.md +67 -0
  13. package/docs/ai/skills/backend/config.md +32 -0
  14. package/docs/ai/skills/backend/controllers.md +62 -0
  15. package/docs/ai/skills/backend/cron.md +50 -0
  16. package/docs/ai/skills/backend/database.md +21 -0
  17. package/docs/ai/skills/backend/forms.md +19 -0
  18. package/docs/ai/skills/backend/ipc.md +55 -0
  19. package/docs/ai/skills/backend/mail.md +34 -0
  20. package/docs/ai/skills/backend/request_response.md +35 -0
  21. package/docs/ai/skills/backend/routing.md +51 -0
  22. package/docs/ai/skills/backend/storage.md +43 -0
  23. package/docs/ai/skills/backend/streaming.md +34 -0
  24. package/docs/ai/skills/backend/structure.md +57 -0
  25. package/docs/ai/skills/backend/translations.md +42 -0
  26. package/docs/ai/skills/backend/utilities.md +24 -0
  27. package/docs/ai/skills/backend/validation.md +53 -0
  28. package/docs/ai/skills/backend/views.md +61 -0
  29. package/docs/ai/skills/frontend/core.md +66 -0
  30. package/docs/ai/skills/frontend/forms.md +21 -0
  31. package/docs/ai/skills/frontend/navigation.md +20 -0
  32. package/docs/ai/skills/frontend/realtime.md +47 -0
  33. package/docs/backend/04-routing/09-websocket.md +14 -1
  34. package/docs/backend/10-authentication/01-user-logins-with-authjs.md +2 -0
  35. package/docs/backend/10-authentication/05-session-management.md +25 -3
  36. package/package.json +13 -13
  37. package/src/Auth.js +100 -15
  38. package/src/Database.js +1 -1
  39. package/src/Mail.js +19 -9
  40. package/src/Odac.js +17 -14
  41. package/src/Request.js +5 -1
  42. package/src/Route/Internal.js +21 -18
  43. package/src/Route/MimeTypes.js +56 -0
  44. package/src/Route.js +136 -92
  45. package/src/Validator.js +23 -14
  46. package/src/View/Form.js +91 -51
  47. package/src/View.js +15 -10
  48. package/src/WebSocket.js +45 -12
  49. package/test/Auth.test.js +249 -0
  50. package/test/Client.test.js +29 -0
  51. package/test/Odac.test.js +4 -2
  52. package/test/Route.test.js +104 -0
  53. package/test/View/Form.test.js +37 -0
  54. package/test/WebSocket.test.js +141 -3
  55. package/.github/workflows/test-publish.yml +0 -36
package/client/odac.js CHANGED
@@ -23,9 +23,7 @@ class OdacWebSocket {
23
23
  connect() {
24
24
  if (this.#isClosed) return
25
25
 
26
- this.#socket = this.#protocols.length > 0
27
- ? new WebSocket(this.#url, this.#protocols)
28
- : new WebSocket(this.#url)
26
+ this.#socket = this.#protocols.length > 0 ? new WebSocket(this.#url, this.#protocols) : new WebSocket(this.#url)
29
27
 
30
28
  this.#socket.onopen = () => {
31
29
  this.#reconnectAttempts = 0
@@ -43,11 +41,7 @@ class OdacWebSocket {
43
41
 
44
42
  this.#socket.onclose = e => {
45
43
  this.emit('close', e)
46
- if (
47
- this.#options.autoReconnect &&
48
- !this.#isClosed &&
49
- this.#reconnectAttempts < this.#options.maxReconnectAttempts
50
- ) {
44
+ if (this.#options.autoReconnect && !this.#isClosed && this.#reconnectAttempts < this.#options.maxReconnectAttempts) {
51
45
  this.#reconnectAttempts++
52
46
  this.#reconnectTimer = setTimeout(() => this.connect(), this.#options.reconnectDelay)
53
47
  }
@@ -112,13 +106,13 @@ if (typeof window !== 'undefined') {
112
106
  #formSubmitHandlers = new Map()
113
107
  #loader = {elements: {}, callback: null}
114
108
  #isNavigating = false
115
-
109
+
116
110
  constructor() {
117
111
  // In constructor we can't call this.data() easily if it uses 'this' for caching properly before init
118
112
  // But based on original code logic:
119
113
  this.#data = this.data()
120
114
  }
121
-
115
+
122
116
  #ajax(options) {
123
117
  const {
124
118
  url,
@@ -132,67 +126,72 @@ if (typeof window !== 'undefined') {
132
126
  contentType = 'application/x-www-form-urlencoded; charset=UTF-8',
133
127
  xhr: xhrFactory
134
128
  } = options
135
-
129
+
136
130
  const xhr = xhrFactory ? xhrFactory() : new XMLHttpRequest()
137
-
131
+
138
132
  xhr.open(type, url, true)
139
-
133
+
140
134
  Object.keys(headers).forEach(key => {
141
135
  xhr.setRequestHeader(key, headers[key])
142
136
  })
143
-
137
+
144
138
  if (contentType && !(data instanceof FormData)) {
145
139
  xhr.setRequestHeader('Content-Type', contentType)
146
140
  }
147
-
141
+
148
142
  xhr.onload = () => {
149
143
  if (xhr.status >= 200 && xhr.status < 300) {
150
144
  let responseData = xhr.responseText
151
- if (dataType === 'json') {
145
+ const contentTypeHeader = xhr.getResponseHeader('Content-Type')
146
+ const isJson = dataType === 'json' || (contentTypeHeader && contentTypeHeader.includes('application/json'))
147
+
148
+ if (isJson) {
152
149
  try {
153
150
  responseData = JSON.parse(responseData)
154
151
  } catch (e) {
155
- console.error('JSON parse error:', e)
156
- error(xhr, 'parseerror', e)
157
- return
152
+ if (dataType === 'json') {
153
+ console.error('JSON parse error:', e)
154
+ error(xhr, 'parseerror', e)
155
+ return
156
+ }
158
157
  }
159
158
  }
160
-
159
+
161
160
  document.dispatchEvent(
162
161
  new CustomEvent('odac:ajaxSuccess', {
163
162
  detail: {response: responseData, status: xhr.statusText, xhr, requestUrl: url}
164
163
  })
165
164
  )
166
-
165
+
167
166
  success(responseData, xhr.statusText, xhr)
168
167
  } else {
169
168
  error(xhr, xhr.statusText)
170
169
  }
171
170
  }
172
-
171
+
173
172
  xhr.onerror = () => error(xhr, 'error')
174
173
  xhr.onloadend = () => complete()
175
174
  xhr.send(data)
176
175
  }
177
-
176
+
178
177
  #fade(element, type, duration = 400, callback) {
179
178
  const isIn = type === 'in'
180
179
  const startOpacity = isIn ? 0 : 1
181
180
  const endOpacity = isIn ? 1 : 0
182
-
181
+
183
182
  element.style.opacity = startOpacity
184
183
  if (isIn) {
185
184
  element.style.display = 'block'
186
185
  }
187
-
186
+
188
187
  let startTime = null
189
-
188
+
190
189
  const animate = currentTime => {
191
190
  if (!startTime) startTime = currentTime
192
191
  const progress = currentTime - startTime
193
192
  const opacity = startOpacity + (endOpacity - startOpacity) * Math.min(progress / duration, 1)
194
193
  element.style.opacity = opacity
195
-
194
+
196
195
  if (progress < duration) {
197
196
  requestAnimationFrame(animate)
198
197
  } else {
@@ -204,15 +203,15 @@ if (typeof window !== 'undefined') {
204
203
  }
205
204
  requestAnimationFrame(animate)
206
205
  }
207
-
206
+
208
207
  #fadeIn(element, duration, callback) {
209
208
  this.#fade(element, 'in', duration, callback)
210
209
  }
211
-
210
+
212
211
  #fadeOut(element, duration, callback) {
213
212
  this.#fade(element, 'out', duration, callback)
214
213
  }
215
-
214
+
216
215
  #on(element, event, selector, handler) {
217
216
  element.addEventListener(event, e => {
218
217
  let target = e.target.closest(selector)
@@ -221,7 +220,7 @@ if (typeof window !== 'undefined') {
221
220
  }
222
221
  })
223
222
  }
224
-
223
+
225
224
  #serialize(form) {
226
225
  const params = []
227
226
  form.querySelectorAll('input, select, textarea').forEach(el => {
@@ -243,22 +242,21 @@ if (typeof window !== 'undefined') {
243
242
  })
244
243
  return params.join('&')
245
244
  }
246
-
245
+
247
246
  action(obj) {
248
247
  if (obj.function) for (let func in obj.function) this.fn[func] = obj.function[func]
249
-
248
+
250
249
  if (obj.navigate !== undefined && obj.navigate !== false) {
251
250
  let selector, elements, callback
252
-
251
+
253
252
  if (typeof obj.navigate === 'string') {
254
253
  selector = 'a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)'
255
254
  elements = {content: obj.navigate}
256
255
  callback = null
257
- }
258
- else if (typeof obj.navigate === 'object') {
256
+ } else if (typeof obj.navigate === 'object') {
259
257
  let baseSelector = obj.navigate.links || obj.navigate.selector || 'a[href^="/"]'
260
258
  selector = `${baseSelector}:not([data-navigate="false"]):not(.no-navigate)`
261
-
259
+
262
260
  if (obj.navigate.update) {
263
261
  elements = typeof obj.navigate.update === 'string' ? {content: obj.navigate.update} : obj.navigate.update
264
262
  } else if (obj.navigate.elements) {
@@ -266,15 +264,14 @@ if (typeof window !== 'undefined') {
266
264
  } else {
267
265
  elements = {content: 'main'}
268
266
  }
269
-
267
+
270
268
  callback = obj.navigate.on || obj.navigate.callback || null
271
- }
272
- else if (obj.navigate === true) {
269
+ } else if (obj.navigate === true) {
273
270
  selector = 'a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)'
274
271
  elements = {content: 'main'}
275
272
  callback = null
276
273
  }
277
-
274
+
278
275
  if (document.readyState === 'loading') {
279
276
  document.addEventListener('DOMContentLoaded', () => {
280
277
  this.loader(selector, elements, callback)
@@ -283,7 +280,7 @@ if (typeof window !== 'undefined') {
283
280
  this.loader(selector, elements, callback)
284
281
  }
285
282
  }
286
-
283
+
287
284
  if (obj.start) document.addEventListener('DOMContentLoaded', () => obj.start())
288
285
  if (obj.load) {
289
286
  if (!this.actions.load) this.actions.load = []
@@ -312,12 +309,11 @@ if (typeof window !== 'undefined') {
312
309
  if (typeof obj[key][key2] == 'function') {
313
310
  this.#on(document, key, key2, obj[key][key2])
314
311
  } else {
315
- let func = ''
316
312
  let split = ''
317
313
  if (obj[key][key2].includes('.')) split = '.'
318
314
  else if (obj[key][key2].includes('#')) split = '#'
319
315
  else if (obj[key][key2].includes(' ')) split = ' '
320
- func = split != '' ? obj[key][key2].split(split) : [obj[key][key2]]
316
+ const func = split != '' ? obj[key][key2].split(split) : [obj[key][key2]]
321
317
  if (func != '') {
322
318
  let getfunc = obj
323
319
  func.forEach(function (item) {
@@ -329,12 +325,12 @@ if (typeof window !== 'undefined') {
329
325
  }
330
326
  }
331
327
  }
332
-
328
+
333
329
  client() {
334
330
  if (!document.cookie.includes('odac_client=')) return null
335
331
  return document.cookie.split('odac_client=')[1].split(';')[0]
336
332
  }
337
-
333
+
338
334
  data(key) {
339
335
  if (!this.#data) {
340
336
  const script = document.getElementById('odac-data')
@@ -346,30 +342,30 @@ if (typeof window !== 'undefined') {
346
342
  }
347
343
  }
348
344
  }
349
-
345
+
350
346
  if (this.#data) {
351
347
  if (key) return this.#data[key] ?? null
352
348
  return this.#data
353
349
  }
354
-
350
+
355
351
  return null
356
352
  }
357
-
353
+
358
354
  form(obj, callback) {
359
355
  if (typeof obj != 'object') obj = {form: obj}
360
356
  const formSelector = obj.form
361
-
357
+
362
358
  if (this.#formSubmitHandlers.has(formSelector)) {
363
359
  const oldHandler = this.#formSubmitHandlers.get(formSelector)
364
360
  document.removeEventListener('submit', oldHandler)
365
361
  }
366
-
362
+
367
363
  const handler = e => {
368
364
  const formElement = e.target.closest(formSelector)
369
365
  if (!formElement) return
370
-
366
+
371
367
  e.preventDefault()
372
-
368
+
373
369
  if (obj.messages !== false) {
374
370
  if (obj.messages == undefined || obj.messages == true || obj.messages.includes('error')) {
375
371
  formElement.querySelectorAll('*[odac-form-error]').forEach(el => (el.style.display = 'none'))
@@ -378,29 +374,31 @@ if (typeof window !== 'undefined') {
378
374
  formElement.querySelectorAll('*[odac-form-success]').forEach(el => (el.style.display = 'none'))
379
375
  }
380
376
  }
381
-
377
+
382
378
  const inputs = formElement.querySelectorAll('input:not([type="hidden"]), textarea, select')
383
379
  let isValid = true
384
380
  let firstInvalidInput = null
385
-
381
+
386
382
  const showError = (input, errorType) => {
387
383
  isValid = false
388
384
  firstInvalidInput = input
389
-
385
+
390
386
  if (input.type !== 'checkbox' && input.type !== 'radio') {
391
387
  input.style.borderColor = '#dc3545'
392
388
  }
393
-
389
+
394
390
  const customMessage = input.getAttribute(`data-error-${errorType}`)
395
391
  if (customMessage) {
396
392
  let errorSpan = formElement.querySelector(`[odac-form-error="${input.name}"]`)
397
-
393
+
398
394
  if (!errorSpan) {
399
395
  errorSpan = document.createElement('span')
400
396
  errorSpan.setAttribute('odac-form-error', input.name)
401
397
  if ((input.type === 'checkbox' || input.type === 'radio') && input.id) {
402
398
  const label = formElement.querySelector(`label[for="${input.id}"]`)
403
- label ? label.parentNode.insertBefore(errorSpan, label.nextSibling) : input.parentNode.insertBefore(errorSpan, input.nextSibling)
399
+ label
400
+ ? label.parentNode.insertBefore(errorSpan, label.nextSibling)
401
+ : input.parentNode.insertBefore(errorSpan, input.nextSibling)
404
402
  } else {
405
403
  input.parentNode.insertBefore(errorSpan, input.nextSibling)
406
404
  }
@@ -409,7 +407,7 @@ if (typeof window !== 'undefined') {
409
407
  errorSpan.style.cssText = 'display:block;color:#dc3545;font-size:0.875rem;margin-top:0.25rem'
410
408
  }
411
409
  }
412
-
410
+
413
411
  for (const input of inputs) {
414
412
  input.style.borderColor = ''
415
413
  const errorSpan = formElement.querySelector(`[odac-form-error="${input.name}"]`)
@@ -417,49 +415,62 @@ if (typeof window !== 'undefined') {
417
415
  errorSpan.style.display = 'none'
418
416
  errorSpan.textContent = ''
419
417
  }
420
-
418
+
421
419
  if (input.hasAttribute('required')) {
422
420
  const isEmpty = input.type === 'checkbox' || input.type === 'radio' ? !input.checked : !input.value.trim()
423
- if (isEmpty) { showError(input, 'required'); break; }
421
+ if (isEmpty) {
422
+ showError(input, 'required')
423
+ break
424
+ }
424
425
  }
425
-
426
+
426
427
  if (input.hasAttribute('minlength') && input.value && input.value.trim().length < parseInt(input.getAttribute('minlength'))) {
427
- showError(input, 'minlength'); break;
428
+ showError(input, 'minlength')
429
+ break
428
430
  }
429
-
431
+
430
432
  if (input.hasAttribute('maxlength') && input.value && input.value.trim().length > parseInt(input.getAttribute('maxlength'))) {
431
- showError(input, 'maxlength'); break;
433
+ showError(input, 'maxlength')
434
+ break
432
435
  }
433
-
436
+
434
437
  if (input.hasAttribute('pattern') && input.value) {
435
- if (!new RegExp(input.getAttribute('pattern')).test(input.value.trim())) { showError(input, 'pattern'); break; }
438
+ if (!new RegExp(input.getAttribute('pattern')).test(input.value.trim())) {
439
+ showError(input, 'pattern')
440
+ break
441
+ }
436
442
  }
437
-
443
+
438
444
  if (input.type === 'email' && input.value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.value.trim())) {
439
- showError(input, 'email'); break;
445
+ showError(input, 'email')
446
+ break
440
447
  }
441
448
  }
442
-
449
+
443
450
  if (!isValid) {
444
451
  if (firstInvalidInput) firstInvalidInput.focus()
445
452
  return
446
453
  }
447
-
454
+
448
455
  if (this.actions.odac?.form?.input?.class?.invalid) {
449
456
  const invalidClass = this.actions.odac.form.input.class.invalid
450
457
  formElement.querySelectorAll(`.${invalidClass}`).forEach(el => el.classList.remove(invalidClass))
451
458
  }
452
-
459
+
453
460
  let datastring, cache, contentType, processData
454
461
  if (formElement.querySelector('input[type=file]')) {
455
462
  datastring = new FormData(formElement)
456
463
  datastring.append('token', this.token())
457
- cache = false; contentType = false; processData = false
464
+ cache = false
465
+ contentType = false
466
+ processData = false
458
467
  } else {
459
468
  datastring = this.#serialize(formElement) + '&_token=' + this.token()
460
- cache = true; contentType = 'application/x-www-form-urlencoded; charset=UTF-8'; processData = true
469
+ cache = true
470
+ contentType = 'application/x-www-form-urlencoded; charset=UTF-8'
471
+ processData = true
461
472
  }
462
-
473
+
463
474
  const submitButtons = formElement.querySelectorAll('button[type="submit"], input[type="submit"]')
464
475
  submitButtons.forEach(btn => {
465
476
  btn.disabled = true
@@ -469,9 +480,9 @@ if (typeof window !== 'undefined') {
469
480
  btn.textContent = loadingText
470
481
  }
471
482
  })
472
-
483
+
473
484
  formElement.querySelectorAll('input:not([type="hidden"]), textarea, select').forEach(el => (el.disabled = true))
474
-
485
+
475
486
  this.#ajax({
476
487
  type: formElement.getAttribute('method'),
477
488
  url: formElement.getAttribute('action'),
@@ -494,32 +505,36 @@ if (typeof window !== 'undefined') {
494
505
  span.innerHTML = this.textToHtml(data.result.message)
495
506
  formElement.appendChild(span)
496
507
  }
497
-
508
+
498
509
  if (data.result._token) {
499
- const tokenInput = formElement.querySelector('input[name="_odac_form_token"]')
500
- if (tokenInput) tokenInput.value = data.result._token
501
-
502
- const formTokenAttr = formElement.getAttribute('data-odac-form')
503
- if (formTokenAttr) {
504
- formElement.setAttribute('data-odac-form', data.result._token)
505
- if (!formElement.matches(formSelector)) {
506
- if (this.#formSubmitHandlers.has(formSelector)) {
507
- document.removeEventListener('submit', this.#formSubmitHandlers.get(formSelector))
508
- this.#formSubmitHandlers.delete(formSelector)
509
- }
510
- const newObj = {...obj, form: `form[data-odac-form="${data.result._token}"]`}
511
- this.form(newObj, callback)
512
- }
513
- }
510
+ const tokenInput = formElement.querySelector('input[name="_odac_form_token"]')
511
+ if (tokenInput) tokenInput.value = data.result._token
512
+
513
+ const formTokenAttr = formElement.getAttribute('data-odac-form')
514
+ if (formTokenAttr) {
515
+ formElement.setAttribute('data-odac-form', data.result._token)
516
+ if (!formElement.matches(formSelector)) {
517
+ if (this.#formSubmitHandlers.has(formSelector)) {
518
+ document.removeEventListener('submit', this.#formSubmitHandlers.get(formSelector))
519
+ this.#formSubmitHandlers.delete(formSelector)
520
+ }
521
+ const newObj = {...obj, form: `form[data-odac-form="${data.result._token}"]`}
522
+ this.form(newObj, callback)
523
+ }
524
+ }
514
525
  }
515
-
516
- if (obj.clear !== false && formElement.getAttribute('clear') !== 'false' && !data.result.redirect) {
517
- formElement.querySelectorAll('input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([readonly]), textarea, select').forEach(el => {
518
- if (el.type === 'checkbox' || el.type === 'radio') el.checked = false
519
- else if (el.tagName === 'SELECT') el.selectedIndex = 0
520
- else el.value = ''
526
+
527
+ if (obj.clear !== false && formElement.getAttribute('clear') !== 'false' && !data.result.redirect) {
528
+ formElement
529
+ .querySelectorAll(
530
+ 'input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([readonly]), textarea, select'
531
+ )
532
+ .forEach(el => {
533
+ if (el.type === 'checkbox' || el.type === 'radio') el.checked = false
534
+ else if (el.tagName === 'SELECT') el.selectedIndex = 0
535
+ else el.value = ''
521
536
  })
522
- }
537
+ }
523
538
  } else if (!data.result.success && data.errors) {
524
539
  Object.entries(data.errors).forEach(([name, message]) => {
525
540
  if (message) {
@@ -537,10 +552,12 @@ if (typeof window !== 'undefined') {
537
552
  errorEl.setAttribute('odac-form-error', name)
538
553
  errorEl.innerHTML = this.textToHtml(message)
539
554
  errorEl.style.cssText = 'display:block;color:#dc3545;font-size:0.875rem;margin-top:0.25rem'
540
-
555
+
541
556
  if ((inputEl.type === 'checkbox' || inputEl.type === 'radio') && inputEl.id) {
542
557
  const label = formElement.querySelector(`label[for="${inputEl.id}"]`)
543
- label ? label.parentNode.insertBefore(errorEl, label.nextSibling) : inputEl.parentNode.insertBefore(errorEl, inputEl.nextSibling)
558
+ label
559
+ ? label.parentNode.insertBefore(errorEl, label.nextSibling)
560
+ : inputEl.parentNode.insertBefore(errorEl, inputEl.nextSibling)
544
561
  } else {
545
562
  inputEl.parentNode.insertBefore(errorEl, inputEl.nextSibling)
546
563
  }
@@ -559,12 +576,19 @@ if (typeof window !== 'undefined') {
559
576
  const inputEl = formElement.querySelector(`*[name="${name}"]`)
560
577
  if (inputEl) {
561
578
  if (inputEl.type !== 'checkbox' && inputEl.type !== 'radio') inputEl.style.borderColor = '#dc3545'
562
- inputEl.addEventListener('focus', function handler() {
579
+ inputEl.addEventListener(
580
+ 'focus',
581
+ function handler() {
563
582
  inputEl.style.borderColor = ''
564
583
  const errorEl = formElement.querySelector(`[odac-form-error="${name}"]`)
565
- if (errorEl) { errorEl.style.display = 'none'; errorEl.textContent = '' }
584
+ if (errorEl) {
585
+ errorEl.style.display = 'none'
586
+ errorEl.textContent = ''
587
+ }
566
588
  inputEl.removeEventListener('focus', handler)
567
- }, {once: true})
589
+ },
590
+ {once: true}
591
+ )
568
592
  }
569
593
  })
570
594
  }
@@ -577,10 +601,14 @@ if (typeof window !== 'undefined') {
577
601
  }
578
602
  },
579
603
  xhr: () => {
580
- var xhr = new window.XMLHttpRequest()
581
- xhr.upload.addEventListener('progress', evt => {
582
- if (evt.lengthComputable && obj.loading) obj.loading(parseInt((100 / evt.total) * evt.loaded))
583
- }, false)
604
+ const xhr = new window.XMLHttpRequest()
605
+ xhr.upload.addEventListener(
606
+ 'progress',
607
+ evt => {
608
+ if (evt.lengthComputable && obj.loading) obj.loading(parseInt((100 / evt.total) * evt.loaded))
609
+ },
610
+ false
611
+ )
584
612
  return xhr
585
613
  },
586
614
  error: () => console.error('Odac:', 'Request failed', '\nForm: ' + obj.form + '\nRequest: ' + formElement.getAttribute('action')),
@@ -588,35 +616,38 @@ if (typeof window !== 'undefined') {
588
616
  submitButtons.forEach(btn => {
589
617
  btn.disabled = false
590
618
  const originalText = btn.getAttribute('data-original-text')
591
- if (originalText) { btn.textContent = originalText; btn.removeAttribute('data-original-text') }
619
+ if (originalText) {
620
+ btn.textContent = originalText
621
+ btn.removeAttribute('data-original-text')
622
+ }
592
623
  })
593
624
  formElement.querySelectorAll('input:not([type="hidden"]), textarea, select').forEach(el => (el.disabled = false))
594
625
  }
595
626
  })
596
627
  }
597
-
628
+
598
629
  document.addEventListener('submit', handler)
599
630
  this.#formSubmitHandlers.set(formSelector, handler)
600
631
  }
601
-
632
+
602
633
  get(url, callback) {
603
634
  url = url + '?_token=' + this.token()
604
635
  this.#ajax({url: url, success: callback})
605
636
  }
606
-
637
+
607
638
  page() {
608
639
  if (!this.#page) {
609
640
  this.#page = document.documentElement.dataset.odacPage || ''
610
641
  }
611
642
  return this.#page
612
643
  }
613
-
644
+
614
645
  storage(key, value) {
615
646
  if (value === undefined) return localStorage.getItem(key)
616
647
  else if (value === null) return localStorage.removeItem(key)
617
648
  else localStorage.setItem(key, value)
618
649
  }
619
-
650
+
620
651
  token() {
621
652
  if (!this.#token.listener) {
622
653
  document.addEventListener('odac:ajaxSuccess', event => {
@@ -625,8 +656,8 @@ if (typeof window !== 'undefined') {
625
656
  try {
626
657
  const token = detail.xhr.getResponseHeader('X-Odac-Token')
627
658
  if (token) {
628
- this.#token.hash.push(token)
629
- if (this.#token.hash.length > 2) this.#token.hash.shift()
659
+ this.#token.hash.push(token)
660
+ if (this.#token.hash.length > 2) this.#token.hash.shift()
630
661
  }
631
662
  } catch (e) {
632
663
  console.error('Error in ajaxSuccess token handler:', e)
@@ -635,77 +666,87 @@ if (typeof window !== 'undefined') {
635
666
  this.#token.listener = true
636
667
  }
637
668
  if (!this.#token.hash.length) {
638
- var req = new XMLHttpRequest()
669
+ const req = new XMLHttpRequest()
639
670
  req.open('GET', '/', false)
640
671
  req.setRequestHeader('X-Odac', 'token')
641
672
  req.setRequestHeader('X-Odac-Client', this.client())
642
673
  req.send(null)
643
- var req_data = JSON.parse(req.response)
674
+ const req_data = JSON.parse(req.response)
644
675
  if (req_data.token) this.#token.hash.push(req_data.token)
645
676
  }
646
- this.#token.hash.filter(n => n)
647
- var return_token = this.#token.hash.shift()
677
+ this.#token.hash = this.#token.hash.filter(n => n)
678
+ const return_token = this.#token.hash.shift()
648
679
  if (!this.#token.hash.length)
649
680
  this.#ajax({
650
681
  url: '/',
651
682
  type: 'GET',
652
683
  headers: {'X-Odac': 'token', 'X-Odac-Client': this.client()},
653
684
  success: data => {
654
- var result = JSON.parse(JSON.stringify(data))
655
- if (result.token) this.#token.hash.push(result.token)
685
+ if (data.token) this.#token.hash.push(data.token)
656
686
  }
657
687
  })
658
688
  return return_token
659
689
  }
660
-
690
+
661
691
  textToHtml(str) {
662
692
  if (typeof str !== 'string') return str
663
693
  return str
664
- .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
665
- .replace(/"/g, '&quot;').replace(/'/g, '&#039;').replace(/\n/g, '<br>')
694
+ .replace(/&/g, '&amp;')
695
+ .replace(/</g, '&lt;')
696
+ .replace(/>/g, '&gt;')
697
+ .replace(/"/g, '&quot;')
698
+ .replace(/'/g, '&#039;')
699
+ .replace(/\n/g, '<br>')
666
700
  }
667
-
701
+
668
702
  load(url, callback, push = true) {
669
703
  if (this.#isNavigating) return false
670
-
704
+
671
705
  const currentUrl = window.location.href
672
706
  url = new URL(url, currentUrl).href
673
- if (url === '' || url.startsWith('javascript:') || url.startsWith('data:') || url.startsWith('vbscript:') || url.includes('#')) return false
674
-
707
+ if (url === '' || url.startsWith('javascript:') || url.startsWith('data:') || url.startsWith('vbscript:') || url.includes('#'))
708
+ return false
709
+
675
710
  this.#isNavigating = true
676
-
711
+
677
712
  const currentSkeleton = document.documentElement.dataset.odacSkeleton
678
713
  const elements = Object.entries(this.#loader.elements)
679
-
714
+
680
715
  const elementsToUpdate = []
681
716
  elements.forEach(([key, selector]) => {
682
717
  const element = document.querySelector(selector)
683
718
  if (element) elementsToUpdate.push({key, element})
684
719
  })
685
-
686
- let ajaxData = null, ajaxXhr = null, fadeOutComplete = false, ajaxComplete = false
687
-
720
+
721
+ let ajaxData = null,
722
+ ajaxXhr = null,
723
+ fadeOutComplete = false,
724
+ ajaxComplete = false
725
+
688
726
  const applyUpdate = () => {
689
727
  if (!fadeOutComplete || !ajaxComplete || !ajaxData) return
690
-
728
+
691
729
  const finalUrl = ajaxXhr.responseURL || url
692
- if (ajaxData.skeletonChanged) { window.location.href = finalUrl; return }
730
+ if (ajaxData.skeletonChanged) {
731
+ window.location.href = finalUrl
732
+ return
733
+ }
693
734
  if (finalUrl !== currentUrl && push) window.history.pushState(null, document.title, finalUrl)
694
-
735
+
695
736
  const newPage = ajaxXhr.getResponseHeader('X-Odac-Page')
696
737
  if (newPage !== null) {
697
738
  this.#page = newPage
698
739
  document.documentElement.dataset.odacPage = newPage
699
740
  }
700
-
741
+
701
742
  if (ajaxData.data) this.#data = ajaxData.data
702
743
  if (ajaxData.title) document.title = ajaxData.title
703
-
744
+
704
745
  if (elementsToUpdate.length === 0) {
705
746
  this.#handleLoadComplete(ajaxData, callback)
706
747
  return
707
748
  }
708
-
749
+
709
750
  let completed = 0
710
751
  elementsToUpdate.forEach(({key, element}) => {
711
752
  if (ajaxData.output && ajaxData.output[key] !== undefined) element.innerHTML = ajaxData.output[key]
@@ -715,7 +756,7 @@ if (typeof window !== 'undefined') {
715
756
  })
716
757
  })
717
758
  }
718
-
759
+
719
760
  if (elementsToUpdate.length > 0) {
720
761
  let fadeOutCount = 0
721
762
  elementsToUpdate.forEach(({element}) => {
@@ -730,7 +771,7 @@ if (typeof window !== 'undefined') {
730
771
  } else {
731
772
  fadeOutComplete = true
732
773
  }
733
-
774
+
734
775
  this.#ajax({
735
776
  url: url,
736
777
  type: 'GET',
@@ -741,7 +782,10 @@ if (typeof window !== 'undefined') {
741
782
  },
742
783
  dataType: 'json',
743
784
  success: (data, status, xhr) => {
744
- ajaxData = data; ajaxXhr = xhr; ajaxComplete = true; applyUpdate()
785
+ ajaxData = data
786
+ ajaxXhr = xhr
787
+ ajaxComplete = true
788
+ applyUpdate()
745
789
  },
746
790
  error: () => {
747
791
  this.#isNavigating = false
@@ -749,52 +793,72 @@ if (typeof window !== 'undefined') {
749
793
  }
750
794
  })
751
795
  }
752
-
796
+
753
797
  #handleLoadComplete(data, callback) {
754
- if (this.actions.load) (Array.isArray(this.actions.load) ? this.actions.load : [this.actions.load]).forEach(fn => fn(this.page(), data.variables))
755
- if (this.actions.page && this.actions.page[this.page()]) (Array.isArray(this.actions.page[this.page()]) ? this.actions.page[this.page()] : [this.actions.page[this.page()]]).forEach(fn => fn(data.variables))
756
-
798
+ if (this.actions.load)
799
+ (Array.isArray(this.actions.load) ? this.actions.load : [this.actions.load]).forEach(fn => fn(this.page(), data.variables))
800
+ if (this.actions.page && this.actions.page[this.page()])
801
+ (Array.isArray(this.actions.page[this.page()]) ? this.actions.page[this.page()] : [this.actions.page[this.page()]]).forEach(fn =>
802
+ fn(data.variables)
803
+ )
804
+
757
805
  if (callback && typeof callback === 'function') callback(this.page(), data.variables)
758
-
806
+
759
807
  window.scrollTo({top: 0, behavior: 'smooth'})
760
808
  this.#isNavigating = false
761
809
  }
762
-
810
+
763
811
  loader(selector, elements, callback) {
764
812
  this.#loader.elements = elements
765
813
  this.#loader.callback = callback
766
814
  const odacInstance = this
767
-
815
+
768
816
  this.#on(document, 'click', selector, function (e) {
769
817
  if (e.ctrlKey || e.metaKey) return
770
818
  const anchor = this
771
819
  if (!anchor) return
772
820
  const url = anchor.getAttribute('href')
773
821
  const target = anchor.getAttribute('target')
774
- if (!url || url === '' || url.startsWith('javascript:') || url.startsWith('data:') || url.startsWith('vbscript:') || url.startsWith('#')) return
822
+ if (
823
+ !url ||
824
+ url === '' ||
825
+ url.startsWith('javascript:') ||
826
+ url.startsWith('data:') ||
827
+ url.startsWith('vbscript:') ||
828
+ url.startsWith('#')
829
+ )
830
+ return
775
831
  const isExternal = url.includes('://') && !url.includes(window.location.host)
776
832
  if ((target === null || target === '_self') && !isExternal) {
777
833
  e.preventDefault()
778
834
  odacInstance.load(url, callback)
779
835
  }
780
836
  })
781
-
837
+
782
838
  window.addEventListener('popstate', () => {
783
839
  this.load(window.location.href, callback, false)
784
840
  })
785
841
  }
786
-
842
+
787
843
  listen(url, onMessage, options = {}) {
788
844
  const {onError = null, onOpen = null, autoReconnect = false, reconnectDelay = 3000} = options
789
- let eventSource = null, reconnectTimer = null, isClosed = false
790
-
845
+ let eventSource = null,
846
+ reconnectTimer = null,
847
+ isClosed = false
848
+
791
849
  const connect = () => {
792
850
  if (isClosed) return
793
851
  const urlWithToken = url + (url.includes('?') ? '&' : '?') + '_token=' + encodeURIComponent(this.token())
794
852
  eventSource = new EventSource(urlWithToken)
795
- eventSource.onopen = e => { if (onOpen) onOpen(e) }
853
+ eventSource.onopen = e => {
854
+ if (onOpen) onOpen(e)
855
+ }
796
856
  eventSource.onmessage = e => {
797
- try { onMessage(JSON.parse(e.data)) } catch { onMessage(e.data) }
857
+ try {
858
+ onMessage(JSON.parse(e.data))
859
+ } catch {
860
+ onMessage(e.data)
861
+ }
798
862
  }
799
863
  eventSource.onerror = e => {
800
864
  if (onError) onError(e)
@@ -805,24 +869,26 @@ if (typeof window !== 'undefined') {
805
869
  }
806
870
  }
807
871
  connect()
808
-
872
+
809
873
  return {
810
874
  close: () => {
811
875
  isClosed = true
812
876
  if (reconnectTimer) clearTimeout(reconnectTimer)
813
877
  if (eventSource) eventSource.close()
814
878
  },
815
- send: () => { throw new Error('SSE is one-way. Use POST requests to send data.') }
879
+ send: () => {
880
+ throw new Error('SSE is one-way. Use POST requests to send data.')
881
+ }
816
882
  }
817
883
  }
818
-
884
+
819
885
  ws(path, options = {}) {
820
886
  const {autoReconnect = true, reconnectDelay = 3000, maxReconnectAttempts = 10, shared = false, token = true} = options
821
-
887
+
822
888
  if (shared && typeof SharedWorker !== 'undefined') {
823
889
  return this.#createSharedWebSocket(path, {autoReconnect, reconnectDelay, maxReconnectAttempts, token})
824
890
  }
825
-
891
+
826
892
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
827
893
  const wsUrl = `${protocol}//${window.location.host}${path}`
828
894
  const protocols = []
@@ -830,10 +896,10 @@ if (typeof window !== 'undefined') {
830
896
  const csrfToken = this.token()
831
897
  if (csrfToken) protocols.push(`odac-token-${csrfToken}`)
832
898
  }
833
-
899
+
834
900
  return new OdacWebSocket(wsUrl, protocols, options)
835
901
  }
836
-
902
+
837
903
  #createSharedWebSocket(path, options) {
838
904
  const scriptUrl = (() => {
839
905
  if (document.currentScript) return document.currentScript.src
@@ -846,11 +912,11 @@ if (typeof window !== 'undefined') {
846
912
  const worker = new SharedWorker(scriptUrl, `odac-ws-${path}`)
847
913
  const handlers = {}
848
914
  let isConnected = false
849
-
915
+
850
916
  const emit = (event, ...args) => {
851
917
  if (handlers[event]) handlers[event].forEach(fn => fn(...args))
852
918
  }
853
-
919
+
854
920
  worker.port.onmessage = e => {
855
921
  const {type, data} = e.data
856
922
  switch (type) {
@@ -870,11 +936,11 @@ if (typeof window !== 'undefined') {
870
936
  break
871
937
  }
872
938
  }
873
-
939
+
874
940
  worker.port.start()
875
-
941
+
876
942
  const token = options.token ? this.token() : null
877
-
943
+
878
944
  worker.port.postMessage({
879
945
  type: 'connect',
880
946
  path,
@@ -883,7 +949,7 @@ if (typeof window !== 'undefined') {
883
949
  token,
884
950
  options
885
951
  })
886
-
952
+
887
953
  return {
888
954
  on: (event, handler) => {
889
955
  if (!handlers[event]) handlers[event] = []
@@ -916,9 +982,8 @@ if (typeof window !== 'undefined') {
916
982
  }
917
983
  }
918
984
  }
919
-
985
+
920
986
  window.Odac = new _odac()
921
-
922
987
  ;(function initAutoNavigate() {
923
988
  const init = () => {
924
989
  const contentEl = document.querySelector('[data-odac-navigate="content"]')
@@ -928,9 +993,9 @@ if (typeof window !== 'undefined') {
928
993
  }
929
994
  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init()
930
995
  })()
931
-
996
+
932
997
  document.addEventListener('DOMContentLoaded', () => {
933
- ['register', 'login'].forEach(type => {
998
+ ;['register', 'login'].forEach(type => {
934
999
  document.querySelectorAll(`form.odac-${type}-form[data-odac-${type}]`).forEach(form => {
935
1000
  const token = form.getAttribute(`data-odac-${type}`)
936
1001
  window.Odac.form({form: `form[data-odac-${type}="${token}"]`})
@@ -965,8 +1030,8 @@ if (typeof window !== 'undefined') {
965
1030
  socket.on('close', e => broadcast('close', {code: e?.code, reason: e?.reason, wasClean: e?.wasClean}))
966
1031
  socket.on('error', e => broadcast('error', {message: e?.message || 'WebSocket error'}))
967
1032
  } else if (socket.connected) {
968
- // If already connected, notify the new port immediately
969
- port.postMessage({type: 'open'})
1033
+ // If already connected, notify the new port immediately
1034
+ port.postMessage({type: 'open'})
970
1035
  }
971
1036
  break
972
1037
  case 'send':