odac 1.0.0 → 1.1.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 (61) hide show
  1. package/.github/workflows/auto-pr-description.yml +3 -1
  2. package/CHANGELOG.md +127 -0
  3. package/README.md +39 -36
  4. package/bin/odac.js +1 -31
  5. package/client/odac.js +871 -994
  6. package/docs/backend/01-overview/03-development-server.md +7 -7
  7. package/docs/backend/02-structure/01-typical-project-layout.md +1 -0
  8. package/docs/backend/03-config/00-configuration-overview.md +9 -0
  9. package/docs/backend/03-config/01-database-connection.md +1 -1
  10. package/docs/backend/04-routing/02-controller-less-view-routes.md +9 -3
  11. package/docs/backend/04-routing/09-websocket.md +29 -0
  12. package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +2 -0
  13. package/docs/backend/05-controllers/03-controller-classes.md +27 -41
  14. package/docs/backend/05-forms/01-custom-forms.md +103 -95
  15. package/docs/backend/05-forms/02-automatic-database-insert.md +21 -21
  16. package/docs/backend/07-views/02-rendering-a-view.md +1 -1
  17. package/docs/backend/07-views/03-variables.md +5 -5
  18. package/docs/backend/07-views/04-request-data.md +1 -1
  19. package/docs/backend/07-views/08-backend-javascript.md +1 -1
  20. package/docs/backend/08-database/01-getting-started.md +100 -0
  21. package/docs/backend/08-database/02-basics.md +136 -0
  22. package/docs/backend/08-database/03-advanced.md +84 -0
  23. package/docs/backend/08-database/04-migrations.md +48 -0
  24. package/docs/backend/09-validation/01-the-validator-service.md +1 -0
  25. package/docs/backend/10-authentication/03-register.md +8 -1
  26. package/docs/backend/10-authentication/04-odac-register-forms.md +46 -46
  27. package/docs/backend/10-authentication/05-session-management.md +1 -1
  28. package/docs/backend/10-authentication/06-odac-login-forms.md +48 -48
  29. package/docs/backend/10-authentication/07-magic-links.md +134 -0
  30. package/docs/backend/11-mail/01-the-mail-service.md +118 -28
  31. package/docs/backend/12-streaming/01-streaming-overview.md +2 -2
  32. package/docs/backend/13-utilities/01-odac-var.md +7 -7
  33. package/docs/backend/13-utilities/02-ipc.md +73 -0
  34. package/docs/frontend/01-overview/01-introduction.md +5 -1
  35. package/docs/frontend/02-ajax-navigation/01-quick-start.md +1 -1
  36. package/docs/index.json +16 -124
  37. package/eslint.config.mjs +5 -47
  38. package/package.json +9 -4
  39. package/src/Auth.js +362 -104
  40. package/src/Config.js +7 -2
  41. package/src/Database.js +188 -0
  42. package/src/Ipc.js +330 -0
  43. package/src/Mail.js +408 -37
  44. package/src/Odac.js +65 -9
  45. package/src/Request.js +70 -48
  46. package/src/Route/Cron.js +4 -1
  47. package/src/Route/Internal.js +214 -11
  48. package/src/Route/Middleware.js +7 -2
  49. package/src/Route.js +106 -26
  50. package/src/Server.js +80 -11
  51. package/src/Storage.js +165 -0
  52. package/src/Validator.js +94 -2
  53. package/src/View/Form.js +193 -17
  54. package/src/View.js +46 -1
  55. package/src/WebSocket.js +18 -3
  56. package/template/config.json +1 -1
  57. package/template/route/www.js +12 -10
  58. package/test/core/{Candy.test.js → Odac.test.js} +2 -2
  59. package/docs/backend/08-database/01-database-connection.md +0 -99
  60. package/docs/backend/08-database/02-using-mysql.md +0 -322
  61. package/src/Mysql.js +0 -575
package/client/odac.js CHANGED
@@ -1,1104 +1,981 @@
1
- class Odac {
2
- actions = {}
3
- #data = null
4
- fn = {}
5
- #page = null
6
- #token = {hash: [], data: false}
7
- #formSubmitHandlers = new Map()
8
- #loader = {elements: {}, callback: null}
9
- #isNavigating = false
10
-
11
- constructor() {
12
- this.#data = this.data()
1
+ class OdacWebSocket {
2
+ #url
3
+ #protocols
4
+ #options
5
+ #socket = null
6
+ #reconnectTimer = null
7
+ #reconnectAttempts = 0
8
+ #handlers = {}
9
+ #isClosed = false
10
+
11
+ constructor(url, protocols = [], options = {}) {
12
+ this.#url = url
13
+ this.#protocols = protocols
14
+ this.#options = {
15
+ autoReconnect: true,
16
+ reconnectDelay: 3000,
17
+ maxReconnectAttempts: 10,
18
+ ...options
19
+ }
20
+ this.connect()
13
21
  }
14
22
 
15
- #ajax(options) {
16
- const {
17
- url,
18
- type = 'GET',
19
- headers = {},
20
- data = null,
21
- dataType = 'text',
22
- success = () => {},
23
- error = () => {},
24
- complete = () => {},
25
- contentType = 'application/x-www-form-urlencoded; charset=UTF-8',
26
- xhr: xhrFactory
27
- } = options
28
-
29
- const xhr = xhrFactory ? xhrFactory() : new XMLHttpRequest()
23
+ connect() {
24
+ if (this.#isClosed) return
30
25
 
31
- xhr.open(type, url, true)
26
+ this.#socket = this.#protocols.length > 0
27
+ ? new WebSocket(this.#url, this.#protocols)
28
+ : new WebSocket(this.#url)
32
29
 
33
- Object.keys(headers).forEach(key => {
34
- xhr.setRequestHeader(key, headers[key])
35
- })
36
-
37
- if (contentType && !(data instanceof FormData)) {
38
- xhr.setRequestHeader('Content-Type', contentType)
30
+ this.#socket.onopen = () => {
31
+ this.#reconnectAttempts = 0
32
+ this.emit('open')
39
33
  }
40
34
 
41
- xhr.onload = () => {
42
- if (xhr.status >= 200 && xhr.status < 300) {
43
- let responseData = xhr.responseText
44
- if (dataType === 'json') {
45
- try {
46
- responseData = JSON.parse(responseData)
47
- } catch (e) {
48
- console.error('JSON parse error:', e)
49
- error(xhr, 'parseerror', e)
50
- return
51
- }
52
- }
53
-
54
- document.dispatchEvent(
55
- new CustomEvent('odac:ajaxSuccess', {
56
- detail: {response: responseData, status: xhr.statusText, xhr, requestUrl: url}
57
- })
58
- )
35
+ this.#socket.onmessage = e => {
36
+ try {
37
+ const data = JSON.parse(e.data)
38
+ this.emit('message', data)
39
+ } catch {
40
+ this.emit('message', e.data)
41
+ }
42
+ }
59
43
 
60
- success(responseData, xhr.statusText, xhr)
61
- } else {
62
- error(xhr, xhr.statusText)
44
+ this.#socket.onclose = e => {
45
+ this.emit('close', e)
46
+ if (
47
+ this.#options.autoReconnect &&
48
+ !this.#isClosed &&
49
+ this.#reconnectAttempts < this.#options.maxReconnectAttempts
50
+ ) {
51
+ this.#reconnectAttempts++
52
+ this.#reconnectTimer = setTimeout(() => this.connect(), this.#options.reconnectDelay)
63
53
  }
64
54
  }
65
55
 
66
- xhr.onerror = () => error(xhr, 'error')
67
- xhr.onloadend = () => complete()
68
- xhr.send(data)
56
+ this.#socket.onerror = e => {
57
+ this.emit('error', e)
58
+ }
69
59
  }
70
60
 
71
- #fade(element, type, duration = 400, callback) {
72
- const isIn = type === 'in'
73
- const startOpacity = isIn ? 0 : 1
74
- const endOpacity = isIn ? 1 : 0
75
-
76
- element.style.opacity = startOpacity
77
- if (isIn) {
78
- element.style.display = 'block'
61
+ emit(event, ...args) {
62
+ if (this.#handlers[event]) {
63
+ this.#handlers[event].forEach(fn => fn(...args))
79
64
  }
65
+ }
80
66
 
81
- let startTime = null
67
+ on(event, handler) {
68
+ if (!this.#handlers[event]) this.#handlers[event] = []
69
+ this.#handlers[event].push(handler)
70
+ return this
71
+ }
82
72
 
83
- const animate = currentTime => {
84
- if (!startTime) startTime = currentTime
85
- const progress = currentTime - startTime
86
- const opacity = startOpacity + (endOpacity - startOpacity) * Math.min(progress / duration, 1)
87
- element.style.opacity = opacity
73
+ off(event, handler) {
74
+ if (!this.#handlers[event]) return this
75
+ if (handler) {
76
+ this.#handlers[event] = this.#handlers[event].filter(h => h !== handler)
77
+ } else {
78
+ delete this.#handlers[event]
79
+ }
80
+ return this
81
+ }
88
82
 
89
- if (progress < duration) {
90
- requestAnimationFrame(animate)
91
- } else {
92
- if (!isIn) {
93
- element.style.display = 'none'
94
- }
95
- if (callback) callback()
96
- }
83
+ send(data) {
84
+ if (this.#socket && this.#socket.readyState === WebSocket.OPEN) {
85
+ this.#socket.send(typeof data === 'object' ? JSON.stringify(data) : data)
97
86
  }
98
- requestAnimationFrame(animate)
87
+ return this
99
88
  }
100
89
 
101
- #fadeIn(element, duration, callback) {
102
- this.#fade(element, 'in', duration, callback)
90
+ close() {
91
+ this.#isClosed = true
92
+ if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer)
93
+ if (this.#socket) this.#socket.close()
103
94
  }
104
95
 
105
- #fadeOut(element, duration, callback) {
106
- this.#fade(element, 'out', duration, callback)
96
+ get state() {
97
+ return this.#socket ? this.#socket.readyState : WebSocket.CLOSED
107
98
  }
108
99
 
109
- #on(element, event, selector, handler) {
110
- element.addEventListener(event, e => {
111
- let target = e.target.closest(selector)
112
- if (target) {
113
- handler.call(target, e)
114
- }
115
- })
100
+ get connected() {
101
+ return this.#socket && this.#socket.readyState === WebSocket.OPEN
116
102
  }
103
+ }
117
104
 
118
- #serialize(form) {
119
- const params = []
120
- form.querySelectorAll('input, select, textarea').forEach(el => {
121
- if (el.name && !el.disabled) {
122
- if (el.type === 'checkbox' || el.type === 'radio') {
123
- if (el.checked) {
124
- params.push(`${encodeURIComponent(el.name)}=${encodeURIComponent(el.value)}`)
125
- }
126
- } else if (el.tagName.toLowerCase() === 'select' && el.multiple) {
127
- Array.from(el.options).forEach(option => {
128
- if (option.selected) {
129
- params.push(`${encodeURIComponent(el.name)}=${encodeURIComponent(option.value)}`)
105
+ if (typeof window !== 'undefined') {
106
+ class _odac {
107
+ actions = {}
108
+ #data = null
109
+ fn = {}
110
+ #page = null
111
+ #token = {hash: [], data: false}
112
+ #formSubmitHandlers = new Map()
113
+ #loader = {elements: {}, callback: null}
114
+ #isNavigating = false
115
+
116
+ constructor() {
117
+ // In constructor we can't call this.data() easily if it uses 'this' for caching properly before init
118
+ // But based on original code logic:
119
+ this.#data = this.data()
120
+ }
121
+
122
+ #ajax(options) {
123
+ const {
124
+ url,
125
+ type = 'GET',
126
+ headers = {},
127
+ data = null,
128
+ dataType = 'text',
129
+ success = () => {},
130
+ error = () => {},
131
+ complete = () => {},
132
+ contentType = 'application/x-www-form-urlencoded; charset=UTF-8',
133
+ xhr: xhrFactory
134
+ } = options
135
+
136
+ const xhr = xhrFactory ? xhrFactory() : new XMLHttpRequest()
137
+
138
+ xhr.open(type, url, true)
139
+
140
+ Object.keys(headers).forEach(key => {
141
+ xhr.setRequestHeader(key, headers[key])
142
+ })
143
+
144
+ if (contentType && !(data instanceof FormData)) {
145
+ xhr.setRequestHeader('Content-Type', contentType)
146
+ }
147
+
148
+ xhr.onload = () => {
149
+ if (xhr.status >= 200 && xhr.status < 300) {
150
+ let responseData = xhr.responseText
151
+ if (dataType === 'json') {
152
+ try {
153
+ responseData = JSON.parse(responseData)
154
+ } catch (e) {
155
+ console.error('JSON parse error:', e)
156
+ error(xhr, 'parseerror', e)
157
+ return
130
158
  }
131
- })
159
+ }
160
+
161
+ document.dispatchEvent(
162
+ new CustomEvent('odac:ajaxSuccess', {
163
+ detail: {response: responseData, status: xhr.statusText, xhr, requestUrl: url}
164
+ })
165
+ )
166
+
167
+ success(responseData, xhr.statusText, xhr)
132
168
  } else {
133
- params.push(`${encodeURIComponent(el.name)}=${encodeURIComponent(el.value)}`)
169
+ error(xhr, xhr.statusText)
134
170
  }
135
171
  }
136
- })
137
- return params.join('&')
138
- }
139
-
140
- action(obj) {
141
- if (obj.function) for (let func in obj.function) this.fn[func] = obj.function[func]
142
-
143
- // Handle navigate configuration
144
- if (obj.navigate !== undefined && obj.navigate !== false) {
145
- let selector, elements, callback
146
-
147
- // Minimal: navigate: 'main'
148
- if (typeof obj.navigate === 'string') {
149
- selector = 'a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)'
150
- elements = {content: obj.navigate}
151
- callback = null
152
- }
153
- // Medium/Advanced: navigate: {...}
154
- else if (typeof obj.navigate === 'object') {
155
- // Determine base selector
156
- let baseSelector
157
- if (obj.navigate.links) {
158
- baseSelector = obj.navigate.links
159
- } else if (obj.navigate.selector) {
160
- baseSelector = obj.navigate.selector
172
+
173
+ xhr.onerror = () => error(xhr, 'error')
174
+ xhr.onloadend = () => complete()
175
+ xhr.send(data)
176
+ }
177
+
178
+ #fade(element, type, duration = 400, callback) {
179
+ const isIn = type === 'in'
180
+ const startOpacity = isIn ? 0 : 1
181
+ const endOpacity = isIn ? 1 : 0
182
+
183
+ element.style.opacity = startOpacity
184
+ if (isIn) {
185
+ element.style.display = 'block'
186
+ }
187
+
188
+ let startTime = null
189
+
190
+ const animate = currentTime => {
191
+ if (!startTime) startTime = currentTime
192
+ const progress = currentTime - startTime
193
+ const opacity = startOpacity + (endOpacity - startOpacity) * Math.min(progress / duration, 1)
194
+ element.style.opacity = opacity
195
+
196
+ if (progress < duration) {
197
+ requestAnimationFrame(animate)
161
198
  } else {
162
- baseSelector = 'a[href^="/"]' // Default: all internal links
163
- }
164
-
165
- // Add exclusions to selector
166
- selector = `${baseSelector}:not([data-navigate="false"]):not(.no-navigate)`
167
-
168
- // Determine elements to update
169
- if (obj.navigate.update) {
170
- if (typeof obj.navigate.update === 'string') {
171
- elements = {content: obj.navigate.update}
172
- } else {
173
- elements = obj.navigate.update
199
+ if (!isIn) {
200
+ element.style.display = 'none'
174
201
  }
175
- } else if (obj.navigate.elements) {
176
- elements = obj.navigate.elements
177
- } else {
178
- elements = {content: 'main'} // Default
202
+ if (callback) callback()
179
203
  }
180
-
181
- // Determine callback
182
- callback = obj.navigate.on || obj.navigate.callback || null
183
- }
184
- // Boolean: navigate: true
185
- else if (obj.navigate === true) {
186
- selector = 'a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)'
187
- elements = {content: 'main'}
188
- callback = null
189
- }
190
-
191
- // Initialize loader after DOM is ready
192
- if (document.readyState === 'loading') {
193
- document.addEventListener('DOMContentLoaded', () => {
194
- this.loader(selector, elements, callback)
195
- })
196
- } else {
197
- this.loader(selector, elements, callback)
198
204
  }
205
+ requestAnimationFrame(animate)
199
206
  }
200
-
201
- if (obj.start) document.addEventListener('DOMContentLoaded', () => obj.start())
202
- if (obj.load) {
203
- if (!this.actions.load) this.actions.load = []
204
- this.actions.load.push(obj.load)
205
- document.addEventListener('DOMContentLoaded', () => obj.load())
207
+
208
+ #fadeIn(element, duration, callback) {
209
+ this.#fade(element, 'in', duration, callback)
206
210
  }
207
- if (obj.page) {
208
- if (!this.actions.page) this.actions.page = {}
209
- for (let page in obj.page) {
210
- if (!this.actions.page[page]) this.actions.page[page] = []
211
- this.actions.page[page].push(obj.page[page])
212
- if (this.page() == page) document.addEventListener('DOMContentLoaded', () => obj.page[page]())
213
- }
211
+
212
+ #fadeOut(element, duration, callback) {
213
+ this.#fade(element, 'out', duration, callback)
214
214
  }
215
- if (obj.interval) {
216
- if (!this.actions.interval) this.actions.interval = {}
217
- for (let interval in obj.interval) {
218
- this.actions.interval[interval] = obj.interval[interval]
219
- if (obj.interval[interval].page && obj.interval[interval].page != this.page()) continue
220
- this.actions.interval[interval]._ = setInterval(obj.interval[interval].function, obj.interval[interval].interval ?? 1000)
221
- }
215
+
216
+ #on(element, event, selector, handler) {
217
+ element.addEventListener(event, e => {
218
+ let target = e.target.closest(selector)
219
+ if (target) {
220
+ handler.call(target, e)
221
+ }
222
+ })
222
223
  }
223
- for (let key in obj) {
224
- if (['function', 'start', 'load', 'page', 'interval', 'navigate'].includes(key)) continue
225
- for (let key2 in obj[key]) {
226
- if (typeof obj[key][key2] == 'function') {
227
- this.#on(document, key, key2, obj[key][key2])
228
- } else {
229
- let func = ''
230
- let split = ''
231
- if (obj[key][key2].includes('.')) split = '.'
232
- else if (obj[key][key2].includes('#')) split = '#'
233
- else if (obj[key][key2].includes(' ')) split = ' '
234
- func = split != '' ? obj[key][key2].split(split) : [obj[key][key2]]
235
- if (func != '') {
236
- let getfunc = obj
237
- func.forEach(function (item) {
238
- getfunc = getfunc[item] !== undefined ? getfunc[item] : getfunc[split + item]
224
+
225
+ #serialize(form) {
226
+ const params = []
227
+ form.querySelectorAll('input, select, textarea').forEach(el => {
228
+ if (el.name && !el.disabled) {
229
+ if (el.type === 'checkbox' || el.type === 'radio') {
230
+ if (el.checked) {
231
+ params.push(`${encodeURIComponent(el.name)}=${encodeURIComponent(el.value)}`)
232
+ }
233
+ } else if (el.tagName.toLowerCase() === 'select' && el.multiple) {
234
+ Array.from(el.options).forEach(option => {
235
+ if (option.selected) {
236
+ params.push(`${encodeURIComponent(el.name)}=${encodeURIComponent(option.value)}`)
237
+ }
239
238
  })
240
- this.#on(document, key, key2, getfunc)
239
+ } else {
240
+ params.push(`${encodeURIComponent(el.name)}=${encodeURIComponent(el.value)}`)
241
241
  }
242
242
  }
243
- }
244
- }
245
- }
246
-
247
- client() {
248
- if (!document.cookie.includes('odac_client=')) return null
249
- return document.cookie.split('odac_client=')[1].split(';')[0]
250
- }
251
-
252
- data() {
253
- if (this.#data) return this.#data
254
- if (!document.cookie.includes('odac_data=')) return null
255
- return JSON.parse(unescape(document.cookie.split('odac_data=')[1].split(';')[0]))
256
- }
257
-
258
- form(obj, callback) {
259
- if (typeof obj != 'object') obj = {form: obj}
260
- const formSelector = obj.form
261
-
262
- if (this.#formSubmitHandlers.has(formSelector)) {
263
- const oldHandler = this.#formSubmitHandlers.get(formSelector)
264
- document.removeEventListener('submit', oldHandler)
243
+ })
244
+ return params.join('&')
265
245
  }
266
-
267
- const handler = e => {
268
- const formElement = e.target.closest(formSelector)
269
- if (!formElement) return
270
-
271
- e.preventDefault()
272
-
273
- const inputs = formElement.querySelectorAll('input:not([type="hidden"]), textarea, select')
274
- let isValid = true
275
- let firstInvalidInput = null
276
-
277
- const showError = (input, errorType) => {
278
- isValid = false
279
- firstInvalidInput = input
280
-
281
- if (input.type !== 'checkbox' && input.type !== 'radio') {
282
- input.style.borderColor = '#dc3545'
283
- }
284
-
285
- const customMessage = input.getAttribute(`data-error-${errorType}`)
286
- if (customMessage) {
287
- let errorSpan = formElement.querySelector(`[odac-form-error="${input.name}"]`)
288
-
289
- if (!errorSpan) {
290
- errorSpan = document.createElement('span')
291
- errorSpan.setAttribute('odac-form-error', input.name)
292
-
293
- if ((input.type === 'checkbox' || input.type === 'radio') && input.id) {
294
- const label = formElement.querySelector(`label[for="${input.id}"]`)
295
- if (label) {
296
- label.parentNode.insertBefore(errorSpan, label.nextSibling)
297
- } else {
298
- input.parentNode.insertBefore(errorSpan, input.nextSibling)
299
- }
300
- } else {
301
- input.parentNode.insertBefore(errorSpan, input.nextSibling)
302
- }
246
+
247
+ action(obj) {
248
+ if (obj.function) for (let func in obj.function) this.fn[func] = obj.function[func]
249
+
250
+ if (obj.navigate !== undefined && obj.navigate !== false) {
251
+ let selector, elements, callback
252
+
253
+ if (typeof obj.navigate === 'string') {
254
+ selector = 'a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)'
255
+ elements = {content: obj.navigate}
256
+ callback = null
257
+ }
258
+ else if (typeof obj.navigate === 'object') {
259
+ let baseSelector = obj.navigate.links || obj.navigate.selector || 'a[href^="/"]'
260
+ selector = `${baseSelector}:not([data-navigate="false"]):not(.no-navigate)`
261
+
262
+ if (obj.navigate.update) {
263
+ elements = typeof obj.navigate.update === 'string' ? {content: obj.navigate.update} : obj.navigate.update
264
+ } else if (obj.navigate.elements) {
265
+ elements = obj.navigate.elements
266
+ } else {
267
+ elements = {content: 'main'}
303
268
  }
304
-
305
- errorSpan.textContent = customMessage
306
- errorSpan.style.cssText = 'display:block;color:#dc3545;font-size:0.875rem;margin-top:0.25rem'
269
+
270
+ callback = obj.navigate.on || obj.navigate.callback || null
271
+ }
272
+ else if (obj.navigate === true) {
273
+ selector = 'a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)'
274
+ elements = {content: 'main'}
275
+ callback = null
276
+ }
277
+
278
+ if (document.readyState === 'loading') {
279
+ document.addEventListener('DOMContentLoaded', () => {
280
+ this.loader(selector, elements, callback)
281
+ })
282
+ } else {
283
+ this.loader(selector, elements, callback)
307
284
  }
308
285
  }
309
-
310
- for (const input of inputs) {
311
- input.style.borderColor = ''
312
- const errorSpan = formElement.querySelector(`[odac-form-error="${input.name}"]`)
313
- if (errorSpan) {
314
- errorSpan.style.display = 'none'
315
- errorSpan.textContent = ''
316
- }
317
-
318
- if (input.hasAttribute('required')) {
319
- const isEmpty = input.type === 'checkbox' || input.type === 'radio' ? !input.checked : !input.value.trim()
320
- if (isEmpty) {
321
- showError(input, 'required')
322
- break
323
- }
324
- }
325
-
326
- if (input.hasAttribute('minlength') && input.value && input.value.trim().length < parseInt(input.getAttribute('minlength'))) {
327
- showError(input, 'minlength')
328
- break
286
+
287
+ if (obj.start) document.addEventListener('DOMContentLoaded', () => obj.start())
288
+ if (obj.load) {
289
+ if (!this.actions.load) this.actions.load = []
290
+ this.actions.load.push(obj.load)
291
+ document.addEventListener('DOMContentLoaded', () => obj.load())
292
+ }
293
+ if (obj.page) {
294
+ if (!this.actions.page) this.actions.page = {}
295
+ for (let page in obj.page) {
296
+ if (!this.actions.page[page]) this.actions.page[page] = []
297
+ this.actions.page[page].push(obj.page[page])
298
+ if (this.page() == page) document.addEventListener('DOMContentLoaded', () => obj.page[page]())
329
299
  }
330
-
331
- if (input.hasAttribute('maxlength') && input.value && input.value.trim().length > parseInt(input.getAttribute('maxlength'))) {
332
- showError(input, 'maxlength')
333
- break
300
+ }
301
+ if (obj.interval) {
302
+ if (!this.actions.interval) this.actions.interval = {}
303
+ for (let interval in obj.interval) {
304
+ this.actions.interval[interval] = obj.interval[interval]
305
+ if (obj.interval[interval].page && obj.interval[interval].page != this.page()) continue
306
+ this.actions.interval[interval]._ = setInterval(obj.interval[interval].function, obj.interval[interval].interval ?? 1000)
334
307
  }
335
-
336
- if (input.hasAttribute('pattern') && input.value) {
337
- const trimmedValue = input.value.trim()
338
- const pattern = input.getAttribute('pattern')
339
- if (!new RegExp(pattern).test(trimmedValue)) {
340
- showError(input, 'pattern')
341
- break
308
+ }
309
+ for (let key in obj) {
310
+ if (['function', 'start', 'load', 'page', 'interval', 'navigate'].includes(key)) continue
311
+ for (let key2 in obj[key]) {
312
+ if (typeof obj[key][key2] == 'function') {
313
+ this.#on(document, key, key2, obj[key][key2])
314
+ } else {
315
+ let func = ''
316
+ let split = ''
317
+ if (obj[key][key2].includes('.')) split = '.'
318
+ else if (obj[key][key2].includes('#')) split = '#'
319
+ else if (obj[key][key2].includes(' ')) split = ' '
320
+ func = split != '' ? obj[key][key2].split(split) : [obj[key][key2]]
321
+ if (func != '') {
322
+ let getfunc = obj
323
+ func.forEach(function (item) {
324
+ getfunc = getfunc[item] !== undefined ? getfunc[item] : getfunc[split + item]
325
+ })
326
+ this.#on(document, key, key2, getfunc)
327
+ }
342
328
  }
343
329
  }
344
-
345
- if (input.type === 'email' && input.value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.value.trim())) {
346
- showError(input, 'email')
347
- break
348
- }
349
- }
350
-
351
- if (!isValid) {
352
- if (firstInvalidInput) firstInvalidInput.focus()
353
- return
354
- }
355
-
356
- let actions = this.actions
357
- if (
358
- actions.odac &&
359
- actions.odac.form &&
360
- actions.odac.form.input &&
361
- actions.odac.form.input.class &&
362
- actions.odac.form.input.class.invalid
363
- ) {
364
- const invalidClass = actions.odac.form.input.class.invalid
365
- formElement
366
- .querySelectorAll(`select.${invalidClass},input.${invalidClass},textarea.${invalidClass}`)
367
- .forEach(el => el.classList.remove(invalidClass))
368
330
  }
369
-
370
- if (obj.messages !== false) {
371
- if (obj.messages == undefined || obj.messages == true || obj.messages.includes('error')) {
372
- formElement.querySelectorAll('*[odac-form-error]').forEach(el => (el.style.display = 'none'))
373
- }
374
- if (obj.messages == undefined || obj.messages == true || obj.messages.includes('success')) {
375
- formElement.querySelectorAll('*[odac-form-success]').forEach(el => (el.style.display = 'none'))
331
+ }
332
+
333
+ client() {
334
+ if (!document.cookie.includes('odac_client=')) return null
335
+ return document.cookie.split('odac_client=')[1].split(';')[0]
336
+ }
337
+
338
+ data(key) {
339
+ if (!this.#data) {
340
+ const script = document.getElementById('odac-data')
341
+ if (script) {
342
+ try {
343
+ this.#data = JSON.parse(script.textContent)
344
+ } catch (e) {
345
+ console.error('Odac: Failed to parse odac-data', e)
346
+ }
376
347
  }
377
348
  }
378
-
379
- let datastring, cache, contentType, processData
380
- if (formElement.querySelector('input[type=file]')) {
381
- datastring = new FormData(formElement)
382
- datastring.append('token', this.token())
383
- cache = false
384
- contentType = false
385
- processData = false
386
- } else {
387
- datastring = this.#serialize(formElement) + '&_token=' + this.token()
388
- cache = true
389
- contentType = 'application/x-www-form-urlencoded; charset=UTF-8'
390
- processData = true
349
+
350
+ if (this.#data) {
351
+ if (key) return this.#data[key] ?? null
352
+ return this.#data
391
353
  }
392
-
393
- const submitButtons = formElement.querySelectorAll('button[type="submit"], input[type="submit"]')
394
- submitButtons.forEach(btn => {
395
- btn.disabled = true
396
- const loadingText = btn.getAttribute('data-loading-text')
397
- if (loadingText) {
398
- btn.setAttribute('data-original-text', btn.textContent)
399
- btn.textContent = loadingText
354
+
355
+ return null
356
+ }
357
+
358
+ form(obj, callback) {
359
+ if (typeof obj != 'object') obj = {form: obj}
360
+ const formSelector = obj.form
361
+
362
+ if (this.#formSubmitHandlers.has(formSelector)) {
363
+ const oldHandler = this.#formSubmitHandlers.get(formSelector)
364
+ document.removeEventListener('submit', oldHandler)
365
+ }
366
+
367
+ const handler = e => {
368
+ const formElement = e.target.closest(formSelector)
369
+ if (!formElement) return
370
+
371
+ e.preventDefault()
372
+
373
+ if (obj.messages !== false) {
374
+ if (obj.messages == undefined || obj.messages == true || obj.messages.includes('error')) {
375
+ formElement.querySelectorAll('*[odac-form-error]').forEach(el => (el.style.display = 'none'))
376
+ }
377
+ if (obj.messages == undefined || obj.messages == true || obj.messages.includes('success')) {
378
+ formElement.querySelectorAll('*[odac-form-success]').forEach(el => (el.style.display = 'none'))
379
+ }
400
380
  }
401
- })
402
-
403
- formElement.querySelectorAll('input:not([type="hidden"]), textarea, select').forEach(el => (el.disabled = true))
404
-
405
- this.#ajax({
406
- type: formElement.getAttribute('method'),
407
- url: formElement.getAttribute('action'),
408
- data: datastring,
409
- dataType: 'json',
410
- contentType: contentType,
411
- processData: processData,
412
- cache: cache,
413
- success: data => {
414
- if (!data.result) return false
415
- if (obj.messages == undefined || obj.messages) {
416
- if (data.result.success && (obj.messages == undefined || obj.messages.includes('success') || obj.messages == true)) {
417
- const successEl = formElement.querySelector('*[odac-form-success]')
418
- if (successEl) {
419
- successEl.innerHTML = data.result.message
420
- this.#fadeIn(successEl)
381
+
382
+ const inputs = formElement.querySelectorAll('input:not([type="hidden"]), textarea, select')
383
+ let isValid = true
384
+ let firstInvalidInput = null
385
+
386
+ const showError = (input, errorType) => {
387
+ isValid = false
388
+ firstInvalidInput = input
389
+
390
+ if (input.type !== 'checkbox' && input.type !== 'radio') {
391
+ input.style.borderColor = '#dc3545'
392
+ }
393
+
394
+ const customMessage = input.getAttribute(`data-error-${errorType}`)
395
+ if (customMessage) {
396
+ let errorSpan = formElement.querySelector(`[odac-form-error="${input.name}"]`)
397
+
398
+ if (!errorSpan) {
399
+ errorSpan = document.createElement('span')
400
+ errorSpan.setAttribute('odac-form-error', input.name)
401
+ if ((input.type === 'checkbox' || input.type === 'radio') && input.id) {
402
+ const label = formElement.querySelector(`label[for="${input.id}"]`)
403
+ label ? label.parentNode.insertBefore(errorSpan, label.nextSibling) : input.parentNode.insertBefore(errorSpan, input.nextSibling)
421
404
  } else {
422
- const span = document.createElement('span')
423
- span.setAttribute('odac-form-success', obj.form)
424
- span.textContent = data.result.message
425
- formElement.appendChild(span)
405
+ input.parentNode.insertBefore(errorSpan, input.nextSibling)
426
406
  }
427
- } else if (!data.result.success && data.errors) {
428
- Object.entries(data.errors).forEach(([name, message]) => {
429
- if (message) {
430
- let errorEl = formElement.querySelector(`[odac-form-error="${name}"]`)
431
- if (errorEl) {
432
- errorEl.textContent = message
433
- errorEl.style.cssText = 'display:block;color:#dc3545;font-size:0.875rem;margin-top:0.25rem'
434
- } else {
435
- const inputEl = formElement.querySelector(`*[name="${name}"]`)
436
- if (inputEl) {
437
- errorEl = document.createElement('span')
438
- errorEl.setAttribute('odac-form-error', name)
439
- errorEl.textContent = message
407
+ }
408
+ errorSpan.textContent = customMessage
409
+ errorSpan.style.cssText = 'display:block;color:#dc3545;font-size:0.875rem;margin-top:0.25rem'
410
+ }
411
+ }
412
+
413
+ for (const input of inputs) {
414
+ input.style.borderColor = ''
415
+ const errorSpan = formElement.querySelector(`[odac-form-error="${input.name}"]`)
416
+ if (errorSpan) {
417
+ errorSpan.style.display = 'none'
418
+ errorSpan.textContent = ''
419
+ }
420
+
421
+ if (input.hasAttribute('required')) {
422
+ const isEmpty = input.type === 'checkbox' || input.type === 'radio' ? !input.checked : !input.value.trim()
423
+ if (isEmpty) { showError(input, 'required'); break; }
424
+ }
425
+
426
+ if (input.hasAttribute('minlength') && input.value && input.value.trim().length < parseInt(input.getAttribute('minlength'))) {
427
+ showError(input, 'minlength'); break;
428
+ }
429
+
430
+ if (input.hasAttribute('maxlength') && input.value && input.value.trim().length > parseInt(input.getAttribute('maxlength'))) {
431
+ showError(input, 'maxlength'); break;
432
+ }
433
+
434
+ if (input.hasAttribute('pattern') && input.value) {
435
+ if (!new RegExp(input.getAttribute('pattern')).test(input.value.trim())) { showError(input, 'pattern'); break; }
436
+ }
437
+
438
+ if (input.type === 'email' && input.value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.value.trim())) {
439
+ showError(input, 'email'); break;
440
+ }
441
+ }
442
+
443
+ if (!isValid) {
444
+ if (firstInvalidInput) firstInvalidInput.focus()
445
+ return
446
+ }
447
+
448
+ if (this.actions.odac?.form?.input?.class?.invalid) {
449
+ const invalidClass = this.actions.odac.form.input.class.invalid
450
+ formElement.querySelectorAll(`.${invalidClass}`).forEach(el => el.classList.remove(invalidClass))
451
+ }
452
+
453
+ let datastring, cache, contentType, processData
454
+ if (formElement.querySelector('input[type=file]')) {
455
+ datastring = new FormData(formElement)
456
+ datastring.append('token', this.token())
457
+ cache = false; contentType = false; processData = false
458
+ } else {
459
+ datastring = this.#serialize(formElement) + '&_token=' + this.token()
460
+ cache = true; contentType = 'application/x-www-form-urlencoded; charset=UTF-8'; processData = true
461
+ }
462
+
463
+ const submitButtons = formElement.querySelectorAll('button[type="submit"], input[type="submit"]')
464
+ submitButtons.forEach(btn => {
465
+ btn.disabled = true
466
+ const loadingText = btn.getAttribute('data-loading-text')
467
+ if (loadingText) {
468
+ btn.setAttribute('data-original-text', btn.textContent)
469
+ btn.textContent = loadingText
470
+ }
471
+ })
472
+
473
+ formElement.querySelectorAll('input:not([type="hidden"]), textarea, select').forEach(el => (el.disabled = true))
474
+
475
+ this.#ajax({
476
+ type: formElement.getAttribute('method'),
477
+ url: formElement.getAttribute('action'),
478
+ data: datastring,
479
+ dataType: 'json',
480
+ contentType: contentType,
481
+ processData: processData,
482
+ cache: cache,
483
+ success: data => {
484
+ if (!data.result) return false
485
+ if (obj.messages == undefined || obj.messages) {
486
+ if (data.result.success && (obj.messages == undefined || obj.messages.includes('success') || obj.messages == true)) {
487
+ const successEl = formElement.querySelector('*[odac-form-success]')
488
+ if (successEl) {
489
+ successEl.innerHTML = this.textToHtml(data.result.message)
490
+ this.#fadeIn(successEl)
491
+ } else {
492
+ const span = document.createElement('span')
493
+ span.setAttribute('odac-form-success', obj.form)
494
+ span.innerHTML = this.textToHtml(data.result.message)
495
+ formElement.appendChild(span)
496
+ }
497
+
498
+ 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
+ }
514
+ }
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 = ''
521
+ })
522
+ }
523
+ } else if (!data.result.success && data.errors) {
524
+ Object.entries(data.errors).forEach(([name, message]) => {
525
+ if (message) {
526
+ let errorEl = formElement.querySelector(`[odac-form-error="${name}"]`)
527
+ if (errorEl) {
528
+ errorEl.innerHTML = this.textToHtml(message)
440
529
  errorEl.style.cssText = 'display:block;color:#dc3545;font-size:0.875rem;margin-top:0.25rem'
441
-
442
- if ((inputEl.type === 'checkbox' || inputEl.type === 'radio') && inputEl.id) {
443
- const label = formElement.querySelector(`label[for="${inputEl.id}"]`)
444
- if (label) {
445
- label.parentNode.insertBefore(errorEl, label.nextSibling)
530
+ } else {
531
+ const inputEl = formElement.querySelector(`*[name="${name}"]`)
532
+ if (inputEl) {
533
+ const errorEl = document.createElement('span')
534
+ errorEl.setAttribute('odac-form-error', name)
535
+ errorEl.innerHTML = this.textToHtml(message)
536
+ errorEl.style.cssText = 'display:block;color:#dc3545;font-size:0.875rem;margin-top:0.25rem'
537
+
538
+ if ((inputEl.type === 'checkbox' || inputEl.type === 'radio') && inputEl.id) {
539
+ const label = formElement.querySelector(`label[for="${inputEl.id}"]`)
540
+ label ? label.parentNode.insertBefore(errorEl, label.nextSibling) : inputEl.parentNode.insertBefore(errorEl, inputEl.nextSibling)
446
541
  } else {
447
542
  inputEl.parentNode.insertBefore(errorEl, inputEl.nextSibling)
448
543
  }
449
- } else {
450
- inputEl.parentNode.insertBefore(errorEl, inputEl.nextSibling)
544
+ } else if (name === '_odac_form') {
545
+ const errorEl = document.createElement('div')
546
+ errorEl.setAttribute('odac-form-error', name)
547
+ errorEl.innerHTML = this.textToHtml(message)
548
+ errorEl.style.cssText = 'display:block;color:#dc3545;background-color:#f8d7da;border:1px solid #f5c2c7;border-radius:0.375rem;padding:0.75rem 1rem;margin-bottom:1rem;font-size:0.875rem'
549
+ formElement.insertBefore(errorEl, formElement.firstChild)
451
550
  }
452
- } else if (name === '_odac_form') {
453
- errorEl = document.createElement('div')
454
- errorEl.setAttribute('odac-form-error', name)
455
- errorEl.textContent = message
456
- errorEl.style.cssText =
457
- 'display:block;color:#dc3545;background-color:#f8d7da;border:1px solid #f5c2c7;border-radius:0.375rem;padding:0.75rem 1rem;margin-bottom:1rem;font-size:0.875rem'
458
- formElement.insertBefore(errorEl, formElement.firstChild)
459
551
  }
460
552
  }
461
- }
462
- const inputEl = formElement.querySelector(`*[name="${name}"]`)
463
- if (inputEl) {
464
- if (inputEl.type !== 'checkbox' && inputEl.type !== 'radio') {
465
- inputEl.style.borderColor = '#dc3545'
553
+ const inputEl = formElement.querySelector(`*[name="${name}"]`)
554
+ if (inputEl) {
555
+ if (inputEl.type !== 'checkbox' && inputEl.type !== 'radio') inputEl.style.borderColor = '#dc3545'
556
+ inputEl.addEventListener('focus', function handler() {
557
+ inputEl.style.borderColor = ''
558
+ const errorEl = formElement.querySelector(`[odac-form-error="${name}"]`)
559
+ if (errorEl) { errorEl.style.display = 'none'; errorEl.textContent = '' }
560
+ inputEl.removeEventListener('focus', handler)
561
+ }, {once: true})
466
562
  }
467
- inputEl.addEventListener(
468
- 'focus',
469
- function handler() {
470
- inputEl.style.borderColor = ''
471
- const errorEl = formElement.querySelector(`[odac-form-error="${name}"]`)
472
- if (errorEl) {
473
- errorEl.style.display = 'none'
474
- errorEl.textContent = ''
475
- }
476
- inputEl.removeEventListener('focus', handler)
477
- }.bind(this),
478
- {once: true}
479
- )
480
- }
481
- })
482
- }
483
- }
484
- if (data.result.success && data.result.redirect) {
485
- window.location.href = data.result.redirect
486
- } else if (callback !== undefined) {
487
- if (typeof callback === 'function') callback(data)
488
- else if (data.result.success) window.location.replace(callback)
489
- }
490
- },
491
- xhr: () => {
492
- var xhr = new window.XMLHttpRequest()
493
- xhr.upload.addEventListener(
494
- 'progress',
495
- function (evt) {
496
- if (evt.lengthComputable) {
497
- var percent = parseInt((100 / evt.total) * evt.loaded)
498
- if (obj.loading) obj.loading(percent)
563
+ })
499
564
  }
500
- },
501
- false
502
- )
503
- return xhr
504
- },
505
- error: () => {
506
- console.error('Odac:', 'Somethings went wrong...', '\nForm: ' + obj.form + '\nRequest: ' + formElement.getAttribute('action'))
507
- },
508
- complete: () => {
509
- const submitButtons = formElement.querySelectorAll('button[type="submit"], input[type="submit"]')
510
- submitButtons.forEach(btn => {
511
- btn.disabled = false
512
- const originalText = btn.getAttribute('data-original-text')
513
- if (originalText) {
514
- btn.textContent = originalText
515
- btn.removeAttribute('data-original-text')
516
565
  }
517
- })
518
- formElement.querySelectorAll('input:not([type="hidden"]), textarea, select').forEach(el => (el.disabled = false))
519
- }
520
- })
566
+ if (data.result.success && data.result.redirect) {
567
+ window.location.href = data.result.redirect
568
+ } else if (callback !== undefined) {
569
+ if (typeof callback === 'function') callback(data)
570
+ else if (data.result.success) window.location.replace(callback)
571
+ }
572
+ },
573
+ xhr: () => {
574
+ var xhr = new window.XMLHttpRequest()
575
+ xhr.upload.addEventListener('progress', evt => {
576
+ if (evt.lengthComputable && obj.loading) obj.loading(parseInt((100 / evt.total) * evt.loaded))
577
+ }, false)
578
+ return xhr
579
+ },
580
+ error: () => console.error('Odac:', 'Request failed', '\nForm: ' + obj.form + '\nRequest: ' + formElement.getAttribute('action')),
581
+ complete: () => {
582
+ submitButtons.forEach(btn => {
583
+ btn.disabled = false
584
+ const originalText = btn.getAttribute('data-original-text')
585
+ if (originalText) { btn.textContent = originalText; btn.removeAttribute('data-original-text') }
586
+ })
587
+ formElement.querySelectorAll('input:not([type="hidden"]), textarea, select').forEach(el => (el.disabled = false))
588
+ }
589
+ })
590
+ }
591
+
592
+ document.addEventListener('submit', handler)
593
+ this.#formSubmitHandlers.set(formSelector, handler)
521
594
  }
522
-
523
- document.addEventListener('submit', handler)
524
- this.#formSubmitHandlers.set(formSelector, handler)
525
- }
526
-
527
- get(url, callback) {
528
- url = url + '?_token=' + this.token()
529
- this.#ajax({url: url, success: callback})
530
- }
531
-
532
- page() {
533
- if (!this.#page) {
534
- this.#page = document.documentElement.dataset.candyPage || ''
595
+
596
+ get(url, callback) {
597
+ url = url + '?_token=' + this.token()
598
+ this.#ajax({url: url, success: callback})
535
599
  }
536
- return this.#page
537
- }
538
-
539
- storage(key, value) {
540
- if (value === undefined) return localStorage.getItem(key)
541
- else if (value === null) return localStorage.removeItem(key)
542
- else localStorage.setItem(key, value)
543
- }
544
-
545
- token() {
546
- if (!this.#token.listener) {
547
- document.addEventListener('odac:ajaxSuccess', event => {
548
- const {detail} = event
549
- const {xhr, requestUrl} = detail
550
- if (requestUrl.includes('://')) return false
551
- try {
552
- const token = xhr.getResponseHeader('X-Odac-Token')
553
- if (token) this.#token.hash.push(token)
554
- if (this.#token.hash.length > 2) this.#token.hash.shift()
555
- } catch (e) {
556
- console.error('Error in ajaxSuccess token handler:', e)
557
- }
558
- })
559
- this.#token.listener = true
600
+
601
+ page() {
602
+ if (!this.#page) {
603
+ this.#page = document.documentElement.dataset.odacPage || ''
604
+ }
605
+ return this.#page
560
606
  }
561
- if (!this.#token.hash.length) {
562
- var req = new XMLHttpRequest()
563
- req.open('GET', '/', false)
564
- req.setRequestHeader('X-Odac', 'token')
565
- req.setRequestHeader('X-Odac-Client', this.client())
566
- req.send(null)
567
- var req_data = JSON.parse(req.response)
568
- if (req_data.token) this.#token.hash.push(req_data.token)
607
+
608
+ storage(key, value) {
609
+ if (value === undefined) return localStorage.getItem(key)
610
+ else if (value === null) return localStorage.removeItem(key)
611
+ else localStorage.setItem(key, value)
569
612
  }
570
- this.#token.hash.filter(n => n)
571
- var return_token = this.#token.hash.shift()
572
- if (!this.#token.hash.length)
573
- this.#ajax({
574
- url: '/',
575
- type: 'GET',
576
- headers: {'X-Odac': 'token', 'X-Odac-Client': this.client()},
577
- success: data => {
578
- var result = JSON.parse(JSON.stringify(data))
579
- if (result.token) this.#token.hash.push(result.token)
580
- }
581
- })
582
- return return_token
583
- }
584
-
585
- load(url, callback, push = true) {
586
- if (this.#isNavigating) return false
587
-
588
- const currentUrl = window.location.href
589
-
590
- // Normalize URL to be absolute
591
- url = new URL(url, currentUrl).href
592
-
593
- if (url === '' || url.startsWith('javascript:') || url.includes('#')) return false
594
-
595
- this.#isNavigating = true
596
-
597
- const currentSkeleton = document.documentElement.dataset.candySkeleton
598
- const elements = Object.entries(this.#loader.elements)
599
-
600
- // Collect elements to update
601
- const elementsToUpdate = []
602
- elements.forEach(([key, selector]) => {
603
- const element = document.querySelector(selector)
604
- if (element) {
605
- elementsToUpdate.push({key, element})
606
- }
607
- })
608
-
609
- let ajaxData = null
610
- let ajaxXhr = null
611
- let fadeOutComplete = false
612
- let ajaxComplete = false
613
-
614
- const applyUpdate = () => {
615
- if (!fadeOutComplete || !ajaxComplete || !ajaxData) return
616
-
617
- const finalUrl = ajaxXhr.responseURL || url
618
-
619
- if (ajaxData.skeletonChanged) {
620
- window.location.href = finalUrl
621
- return
622
- }
623
-
624
- if (finalUrl !== currentUrl && push) {
625
- window.history.pushState(null, document.title, finalUrl)
626
- }
627
-
628
- const newPage = ajaxXhr.getResponseHeader('X-Odac-Page')
629
- if (newPage !== null) {
630
- this.#page = newPage
631
- document.documentElement.dataset.candyPage = newPage
632
- }
633
-
634
- if (elementsToUpdate.length === 0) {
635
- this.#handleLoadComplete(ajaxData, callback)
636
- return
637
- }
638
-
639
- // Update content and fade in
640
- let completed = 0
641
- elementsToUpdate.forEach(({key, element}) => {
642
- if (ajaxData.output && ajaxData.output[key] !== undefined) {
643
- element.innerHTML = ajaxData.output[key]
644
- }
645
- this.#fadeIn(element, 200, () => {
646
- completed++
647
- if (completed === elementsToUpdate.length) {
648
- this.#handleLoadComplete(ajaxData, callback)
613
+
614
+ token() {
615
+ if (!this.#token.listener) {
616
+ document.addEventListener('odac:ajaxSuccess', event => {
617
+ const {detail} = event
618
+ if (detail.requestUrl.includes('://')) return false
619
+ try {
620
+ const token = detail.xhr.getResponseHeader('X-Odac-Token')
621
+ if (token) {
622
+ this.#token.hash.push(token)
623
+ if (this.#token.hash.length > 2) this.#token.hash.shift()
624
+ }
625
+ } catch (e) {
626
+ console.error('Error in ajaxSuccess token handler:', e)
649
627
  }
650
628
  })
651
- })
652
- }
653
-
654
- // Start fade out
655
- if (elementsToUpdate.length > 0) {
656
- let fadeOutCount = 0
657
- elementsToUpdate.forEach(({element}) => {
658
- this.#fadeOut(element, 200, () => {
659
- fadeOutCount++
660
- if (fadeOutCount === elementsToUpdate.length) {
661
- fadeOutComplete = true
662
- applyUpdate()
629
+ this.#token.listener = true
630
+ }
631
+ if (!this.#token.hash.length) {
632
+ var req = new XMLHttpRequest()
633
+ req.open('GET', '/', false)
634
+ req.setRequestHeader('X-Odac', 'token')
635
+ req.setRequestHeader('X-Odac-Client', this.client())
636
+ req.send(null)
637
+ var req_data = JSON.parse(req.response)
638
+ if (req_data.token) this.#token.hash.push(req_data.token)
639
+ }
640
+ this.#token.hash.filter(n => n)
641
+ var return_token = this.#token.hash.shift()
642
+ if (!this.#token.hash.length)
643
+ this.#ajax({
644
+ url: '/',
645
+ type: 'GET',
646
+ headers: {'X-Odac': 'token', 'X-Odac-Client': this.client()},
647
+ success: data => {
648
+ var result = JSON.parse(JSON.stringify(data))
649
+ if (result.token) this.#token.hash.push(result.token)
663
650
  }
664
651
  })
665
- })
666
- } else {
667
- fadeOutComplete = true
668
- }
669
-
670
- this.#ajax({
671
- url: url,
672
- type: 'GET',
673
- headers: {
674
- 'X-Odac': 'ajaxload',
675
- 'X-Odac-Load': Object.keys(this.#loader.elements).join(','),
676
- 'X-Odac-Skeleton': currentSkeleton || ''
677
- },
678
- dataType: 'json',
679
- success: (data, status, xhr) => {
680
- ajaxData = data
681
- ajaxXhr = xhr
682
- ajaxComplete = true
683
- applyUpdate()
684
- },
685
- error: () => {
686
- this.#isNavigating = false
687
- window.location.replace(url)
688
- }
689
- })
690
- }
691
-
692
- #handleLoadComplete(data, callback) {
693
- // Call load actions
694
- if (this.actions.load) {
695
- if (Array.isArray(this.actions.load)) {
696
- this.actions.load.forEach(fn => fn(this.page(), data.variables))
697
- } else if (typeof this.actions.load === 'function') {
698
- this.actions.load(this.page(), data.variables)
699
- }
700
- }
701
-
702
- // Call page-specific actions
703
- if (this.actions.page && this.actions.page[this.page()]) {
704
- const pageActions = this.actions.page[this.page()]
705
- if (Array.isArray(pageActions)) {
706
- pageActions.forEach(fn => fn(data.variables))
707
- } else if (typeof pageActions === 'function') {
708
- pageActions(data.variables)
709
- }
652
+ return return_token
710
653
  }
711
-
712
- // Call custom callback
713
- if (callback && typeof callback === 'function') {
714
- callback(this.page(), data.variables)
654
+
655
+ textToHtml(str) {
656
+ if (typeof str !== 'string') return str
657
+ return str
658
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
659
+ .replace(/"/g, '&quot;').replace(/'/g, '&#039;').replace(/\n/g, '<br>')
715
660
  }
716
-
717
- // Scroll to top
718
- window.scrollTo({top: 0, behavior: 'smooth'})
719
-
720
- this.#isNavigating = false
721
- }
722
-
723
- loader(selector, elements, callback) {
724
- this.#loader.elements = elements
725
- this.#loader.callback = callback
726
-
727
- const candyInstance = this
728
-
729
- // Handle link clicks
730
- this.#on(document, 'click', selector, function (e) {
731
- if (e.ctrlKey || e.metaKey) return
732
-
733
- const anchor = this
734
- if (!anchor) return
735
-
736
- const url = anchor.getAttribute('href')
737
- const target = anchor.getAttribute('target')
738
-
739
- if (!url || url === '' || url.startsWith('javascript:') || url.startsWith('#')) return
740
-
741
- const currentHost = window.location.host
742
- const isExternal = url.includes('://') && !url.includes(currentHost)
743
-
744
- if ((target === null || target === '_self') && !isExternal) {
745
- e.preventDefault()
746
- candyInstance.load(url, callback)
747
- }
748
- })
749
-
750
- // Handle browser back/forward
751
- window.addEventListener('popstate', () => {
752
- this.load(window.location.href, callback, false)
753
- })
754
- }
755
-
756
- listen(url, onMessage, options = {}) {
757
- const {onError = null, onOpen = null, autoReconnect = false, reconnectDelay = 3000} = options
758
-
759
- let eventSource = null
760
- let reconnectTimer = null
761
- let isClosed = false
762
-
763
- const connect = () => {
764
- if (isClosed) return
765
-
766
- const urlWithToken = url + (url.includes('?') ? '&' : '?') + '_token=' + encodeURIComponent(this.token())
767
- eventSource = new EventSource(urlWithToken)
768
-
769
- eventSource.onopen = e => {
770
- if (onOpen) onOpen(e)
661
+
662
+ load(url, callback, push = true) {
663
+ if (this.#isNavigating) return false
664
+
665
+ const currentUrl = window.location.href
666
+ url = new URL(url, currentUrl).href
667
+ if (url === '' || url.startsWith('javascript:') || url.startsWith('data:') || url.startsWith('vbscript:') || url.includes('#')) return false
668
+
669
+ this.#isNavigating = true
670
+
671
+ const currentSkeleton = document.documentElement.dataset.odacSkeleton
672
+ const elements = Object.entries(this.#loader.elements)
673
+
674
+ const elementsToUpdate = []
675
+ elements.forEach(([key, selector]) => {
676
+ const element = document.querySelector(selector)
677
+ if (element) elementsToUpdate.push({key, element})
678
+ })
679
+
680
+ let ajaxData = null, ajaxXhr = null, fadeOutComplete = false, ajaxComplete = false
681
+
682
+ const applyUpdate = () => {
683
+ if (!fadeOutComplete || !ajaxComplete || !ajaxData) return
684
+
685
+ const finalUrl = ajaxXhr.responseURL || url
686
+ if (ajaxData.skeletonChanged) { window.location.href = finalUrl; return }
687
+ if (finalUrl !== currentUrl && push) window.history.pushState(null, document.title, finalUrl)
688
+
689
+ const newPage = ajaxXhr.getResponseHeader('X-Odac-Page')
690
+ if (newPage !== null) {
691
+ this.#page = newPage
692
+ document.documentElement.dataset.odacPage = newPage
693
+ }
694
+
695
+ if (ajaxData.data) this.#data = ajaxData.data
696
+ if (ajaxData.title) document.title = ajaxData.title
697
+
698
+ if (elementsToUpdate.length === 0) {
699
+ this.#handleLoadComplete(ajaxData, callback)
700
+ return
701
+ }
702
+
703
+ let completed = 0
704
+ elementsToUpdate.forEach(({key, element}) => {
705
+ if (ajaxData.output && ajaxData.output[key] !== undefined) element.innerHTML = ajaxData.output[key]
706
+ this.#fadeIn(element, 200, () => {
707
+ completed++
708
+ if (completed === elementsToUpdate.length) this.#handleLoadComplete(ajaxData, callback)
709
+ })
710
+ })
771
711
  }
772
-
773
- eventSource.onmessage = e => {
774
- try {
775
- const data = JSON.parse(e.data)
776
- onMessage(data)
777
- } catch {
778
- onMessage(e.data)
779
- }
712
+
713
+ if (elementsToUpdate.length > 0) {
714
+ let fadeOutCount = 0
715
+ elementsToUpdate.forEach(({element}) => {
716
+ this.#fadeOut(element, 200, () => {
717
+ fadeOutCount++
718
+ if (fadeOutCount === elementsToUpdate.length) {
719
+ fadeOutComplete = true
720
+ applyUpdate()
721
+ }
722
+ })
723
+ })
724
+ } else {
725
+ fadeOutComplete = true
780
726
  }
781
-
782
- eventSource.onerror = e => {
783
- if (onError) onError(e)
784
-
785
- if (autoReconnect && !isClosed) {
786
- eventSource.close()
787
- reconnectTimer = setTimeout(connect, reconnectDelay)
727
+
728
+ this.#ajax({
729
+ url: url,
730
+ type: 'GET',
731
+ headers: {
732
+ 'X-Odac': 'ajaxload',
733
+ 'X-Odac-Load': Object.keys(this.#loader.elements).join(','),
734
+ 'X-Odac-Skeleton': currentSkeleton || ''
735
+ },
736
+ dataType: 'json',
737
+ success: (data, status, xhr) => {
738
+ ajaxData = data; ajaxXhr = xhr; ajaxComplete = true; applyUpdate()
739
+ },
740
+ error: () => {
741
+ this.#isNavigating = false
742
+ window.location.replace(url)
788
743
  }
789
- }
744
+ })
790
745
  }
791
-
792
- connect()
793
-
794
- return {
795
- close: () => {
796
- isClosed = true
797
- if (reconnectTimer) clearTimeout(reconnectTimer)
798
- if (eventSource) eventSource.close()
799
- },
800
- send: () => {
801
- throw new Error('SSE is one-way. Use POST requests to send data.')
802
- }
746
+
747
+ #handleLoadComplete(data, callback) {
748
+ if (this.actions.load) (Array.isArray(this.actions.load) ? this.actions.load : [this.actions.load]).forEach(fn => fn(this.page(), data.variables))
749
+ 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))
750
+
751
+ if (callback && typeof callback === 'function') callback(this.page(), data.variables)
752
+
753
+ window.scrollTo({top: 0, behavior: 'smooth'})
754
+ this.#isNavigating = false
803
755
  }
804
- }
805
-
806
- ws(path, options = {}) {
807
- const {autoReconnect = true, reconnectDelay = 3000, maxReconnectAttempts = 10, shared = false, token = true} = options
808
-
809
- if (shared && typeof SharedWorker !== 'undefined') {
810
- return this.#createSharedWebSocket(path, {autoReconnect, reconnectDelay, maxReconnectAttempts, token})
756
+
757
+ loader(selector, elements, callback) {
758
+ this.#loader.elements = elements
759
+ this.#loader.callback = callback
760
+ const odacInstance = this
761
+
762
+ this.#on(document, 'click', selector, function (e) {
763
+ if (e.ctrlKey || e.metaKey) return
764
+ const anchor = this
765
+ if (!anchor) return
766
+ const url = anchor.getAttribute('href')
767
+ const target = anchor.getAttribute('target')
768
+ if (!url || url === '' || url.startsWith('javascript:') || url.startsWith('data:') || url.startsWith('vbscript:') || url.startsWith('#')) return
769
+ const isExternal = url.includes('://') && !url.includes(window.location.host)
770
+ if ((target === null || target === '_self') && !isExternal) {
771
+ e.preventDefault()
772
+ odacInstance.load(url, callback)
773
+ }
774
+ })
775
+
776
+ window.addEventListener('popstate', () => {
777
+ this.load(window.location.href, callback, false)
778
+ })
811
779
  }
812
-
813
- let socket = null
814
- let reconnectTimer = null
815
- let reconnectAttempts = 0
816
- let isClosed = false
817
- const handlers = {}
818
-
819
- const emit = (event, ...args) => {
820
- if (handlers[event]) {
821
- handlers[event].forEach(fn => fn(...args))
780
+
781
+ listen(url, onMessage, options = {}) {
782
+ const {onError = null, onOpen = null, autoReconnect = false, reconnectDelay = 3000} = options
783
+ let eventSource = null, reconnectTimer = null, isClosed = false
784
+
785
+ const connect = () => {
786
+ if (isClosed) return
787
+ const urlWithToken = url + (url.includes('?') ? '&' : '?') + '_token=' + encodeURIComponent(this.token())
788
+ eventSource = new EventSource(urlWithToken)
789
+ eventSource.onopen = e => { if (onOpen) onOpen(e) }
790
+ eventSource.onmessage = e => {
791
+ try { onMessage(JSON.parse(e.data)) } catch { onMessage(e.data) }
792
+ }
793
+ eventSource.onerror = e => {
794
+ if (onError) onError(e)
795
+ if (autoReconnect && !isClosed) {
796
+ eventSource.close()
797
+ reconnectTimer = setTimeout(connect, reconnectDelay)
798
+ }
799
+ }
800
+ }
801
+ connect()
802
+
803
+ return {
804
+ close: () => {
805
+ isClosed = true
806
+ if (reconnectTimer) clearTimeout(reconnectTimer)
807
+ if (eventSource) eventSource.close()
808
+ },
809
+ send: () => { throw new Error('SSE is one-way. Use POST requests to send data.') }
822
810
  }
823
811
  }
824
-
825
- const connect = () => {
826
- if (isClosed) return
827
-
812
+
813
+ ws(path, options = {}) {
814
+ const {autoReconnect = true, reconnectDelay = 3000, maxReconnectAttempts = 10, shared = false, token = true} = options
815
+
816
+ if (shared && typeof SharedWorker !== 'undefined') {
817
+ return this.#createSharedWebSocket(path, {autoReconnect, reconnectDelay, maxReconnectAttempts, token})
818
+ }
819
+
828
820
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
829
821
  const wsUrl = `${protocol}//${window.location.host}${path}`
830
-
831
822
  const protocols = []
832
823
  if (token) {
833
824
  const csrfToken = this.token()
834
- if (csrfToken) {
835
- protocols.push(`odac-token-${csrfToken}`)
836
- }
837
- }
838
-
839
- socket = protocols.length > 0 ? new WebSocket(wsUrl, protocols) : new WebSocket(wsUrl)
840
-
841
- socket.onopen = () => {
842
- reconnectAttempts = 0
843
- emit('open')
825
+ if (csrfToken) protocols.push(`odac-token-${csrfToken}`)
844
826
  }
845
-
846
- socket.onmessage = e => {
847
- try {
848
- const data = JSON.parse(e.data)
849
- emit('message', data)
850
- } catch {
851
- emit('message', e.data)
827
+
828
+ return new OdacWebSocket(wsUrl, protocols, options)
829
+ }
830
+
831
+ #createSharedWebSocket(path, options) {
832
+ const scriptUrl = (() => {
833
+ if (document.currentScript) return document.currentScript.src
834
+ const scripts = document.querySelectorAll('script')
835
+ for (let i = 0; i < scripts.length; i++) {
836
+ if (scripts[i].src.includes('odac.js')) return scripts[i].src
837
+ }
838
+ return '/odac.js'
839
+ })()
840
+ const worker = new SharedWorker(scriptUrl, `odac-ws-${path}`)
841
+ const handlers = {}
842
+ let isConnected = false
843
+
844
+ const emit = (event, ...args) => {
845
+ if (handlers[event]) handlers[event].forEach(fn => fn(...args))
846
+ }
847
+
848
+ worker.port.onmessage = e => {
849
+ const {type, data} = e.data
850
+ switch (type) {
851
+ case 'open':
852
+ isConnected = true
853
+ emit('open')
854
+ break
855
+ case 'message':
856
+ emit('message', data)
857
+ break
858
+ case 'close':
859
+ isConnected = false
860
+ emit('close', data)
861
+ break
862
+ case 'error':
863
+ emit('error', data)
864
+ break
852
865
  }
853
866
  }
854
-
855
- socket.onclose = e => {
856
- emit('close', e)
857
-
858
- if (autoReconnect && !isClosed && reconnectAttempts < maxReconnectAttempts) {
859
- reconnectAttempts++
860
- reconnectTimer = setTimeout(connect, reconnectDelay)
867
+
868
+ worker.port.start()
869
+
870
+ const token = options.token ? this.token() : null
871
+
872
+ worker.port.postMessage({
873
+ type: 'connect',
874
+ path,
875
+ host: window.location.host,
876
+ protocol: window.location.protocol === 'https:' ? 'wss:' : 'ws:',
877
+ token,
878
+ options
879
+ })
880
+
881
+ return {
882
+ on: (event, handler) => {
883
+ if (!handlers[event]) handlers[event] = []
884
+ handlers[event].push(handler)
885
+ return this
886
+ },
887
+ off: (event, handler) => {
888
+ if (!handlers[event]) return
889
+ if (handler) {
890
+ handlers[event] = handlers[event].filter(h => h !== handler)
891
+ } else {
892
+ delete handlers[event]
893
+ }
894
+ return this
895
+ },
896
+ send: data => {
897
+ worker.port.postMessage({
898
+ type: 'send',
899
+ data: typeof data === 'object' ? JSON.stringify(data) : data
900
+ })
901
+ return this
902
+ },
903
+ close: () => {
904
+ worker.port.postMessage({type: 'close'})
905
+ worker.port.close()
906
+ },
907
+ get connected() {
908
+ return isConnected
861
909
  }
862
910
  }
863
-
864
- socket.onerror = e => {
865
- emit('error', e)
866
- }
867
911
  }
868
-
869
- connect()
870
-
871
- return {
872
- on: (event, handler) => {
873
- if (!handlers[event]) handlers[event] = []
874
- handlers[event].push(handler)
875
- return this
876
- },
877
- off: (event, handler) => {
878
- if (!handlers[event]) return
879
- if (handler) {
880
- handlers[event] = handlers[event].filter(h => h !== handler)
881
- } else {
882
- delete handlers[event]
883
- }
884
- return this
885
- },
886
- send: data => {
887
- if (socket && socket.readyState === WebSocket.OPEN) {
888
- socket.send(typeof data === 'object' ? JSON.stringify(data) : data)
889
- }
890
- return this
891
- },
892
- close: () => {
893
- isClosed = true
894
- if (reconnectTimer) clearTimeout(reconnectTimer)
895
- if (socket) socket.close()
896
- },
897
- get state() {
898
- return socket ? socket.readyState : WebSocket.CLOSED
899
- },
900
- get connected() {
901
- return socket && socket.readyState === WebSocket.OPEN
912
+ }
913
+
914
+ window.Odac = new _odac()
915
+
916
+ ;(function initAutoNavigate() {
917
+ const init = () => {
918
+ const contentEl = document.querySelector('[data-odac-navigate="content"]')
919
+ if (contentEl) {
920
+ window.Odac.loader('a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)', {content: '[data-odac-navigate="content"]'}, null)
902
921
  }
903
922
  }
904
- }
923
+ document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init()
924
+ })()
925
+
926
+ document.addEventListener('DOMContentLoaded', () => {
927
+ ['register', 'login'].forEach(type => {
928
+ document.querySelectorAll(`form.odac-${type}-form[data-odac-${type}]`).forEach(form => {
929
+ const token = form.getAttribute(`data-odac-${type}`)
930
+ window.Odac.form({form: `form[data-odac-${type}="${token}"]`})
931
+ })
932
+ })
933
+ document.querySelectorAll('form.odac-custom-form[data-odac-form]').forEach(form => {
934
+ const token = form.getAttribute('data-odac-form')
935
+ window.Odac.form({form: `form[data-odac-form="${token}"]`})
936
+ })
937
+ })
938
+ } else {
939
+ let socket = null
940
+ const ports = new Set()
905
941
 
906
- #createSharedWebSocket(path, options) {
907
- const workerUrl = this.#createWorkerBlob()
908
- const worker = new SharedWorker(workerUrl, `odac-ws-${path}`)
909
- const handlers = {}
910
- let isConnected = false
942
+ const broadcast = (type, data) => ports.forEach(port => port.postMessage({type, data}))
911
943
 
912
- const emit = (event, ...args) => {
913
- if (handlers[event]) {
914
- handlers[event].forEach(fn => fn(...args))
915
- }
916
- }
944
+ self.onconnect = e => {
945
+ const port = e.ports[0]
946
+ ports.add(port)
917
947
 
918
- worker.port.onmessage = e => {
919
- const {type, data} = e.data
948
+ port.onmessage = event => {
949
+ const {type, path, host, protocol, token, options, data} = event.data
920
950
 
921
951
  switch (type) {
922
- case 'open':
923
- isConnected = true
924
- emit('open')
952
+ case 'connect':
953
+ if (!socket) {
954
+ const wsUrl = protocol + '//' + host + path
955
+ const protocols = token ? ['odac-token-' + token] : []
956
+ socket = new OdacWebSocket(wsUrl, protocols, options)
957
+ socket.on('open', () => broadcast('open'))
958
+ socket.on('message', data => broadcast('message', data))
959
+ socket.on('close', e => broadcast('close', e))
960
+ socket.on('error', e => broadcast('error', e))
961
+ } else if (socket.connected) {
962
+ // If already connected, notify the new port immediately
963
+ port.postMessage({type: 'open'})
964
+ }
925
965
  break
926
- case 'message':
927
- emit('message', data)
966
+ case 'send':
967
+ if (socket) socket.send(data)
928
968
  break
929
969
  case 'close':
930
- isConnected = false
931
- emit('close', data)
932
- break
933
- case 'error':
934
- emit('error', data)
970
+ ports.delete(port)
971
+ if (ports.size === 0 && socket) {
972
+ socket.close()
973
+ socket = null
974
+ }
935
975
  break
936
976
  }
937
977
  }
938
978
 
939
- worker.port.start()
940
-
941
- const token = options.token ? this.token() : null
942
-
943
- worker.port.postMessage({
944
- type: 'connect',
945
- path,
946
- host: window.location.host,
947
- protocol: window.location.protocol === 'https:' ? 'wss:' : 'ws:',
948
- token,
949
- options
950
- })
951
-
952
- return {
953
- on: (event, handler) => {
954
- if (!handlers[event]) handlers[event] = []
955
- handlers[event].push(handler)
956
- return this
957
- },
958
- off: (event, handler) => {
959
- if (!handlers[event]) return
960
- if (handler) {
961
- handlers[event] = handlers[event].filter(h => h !== handler)
962
- } else {
963
- delete handlers[event]
964
- }
965
- return this
966
- },
967
- send: data => {
968
- worker.port.postMessage({
969
- type: 'send',
970
- data: typeof data === 'object' ? JSON.stringify(data) : data
971
- })
972
- return this
973
- },
974
- close: () => {
975
- worker.port.postMessage({type: 'close'})
976
- worker.port.close()
977
- },
978
- get connected() {
979
- return isConnected
980
- }
981
- }
982
- }
983
-
984
- #createWorkerBlob() {
985
- const workerCode = `
986
- let socket = null
987
- let reconnectTimer = null
988
- let reconnectAttempts = 0
989
- let options = {}
990
- let protocols = []
991
- const ports = new Set()
992
-
993
- function broadcast(type, data) {
994
- ports.forEach(port => {
995
- port.postMessage({type, data})
996
- })
997
- }
998
-
999
- function connect(wsUrl, protocols) {
1000
- if (socket && socket.readyState !== WebSocket.CLOSED) return
1001
-
1002
- socket = protocols && protocols.length > 0 ? new WebSocket(wsUrl, protocols) : new WebSocket(wsUrl)
1003
-
1004
- socket.onopen = () => {
1005
- reconnectAttempts = 0
1006
- broadcast('open')
1007
- }
1008
-
1009
- socket.onmessage = e => {
1010
- try {
1011
- const data = JSON.parse(e.data)
1012
- broadcast('message', data)
1013
- } catch {
1014
- broadcast('message', e.data)
1015
- }
1016
- }
1017
-
1018
- socket.onclose = e => {
1019
- broadcast('close', e)
1020
-
1021
- if (options.autoReconnect && reconnectAttempts < options.maxReconnectAttempts) {
1022
- reconnectAttempts++
1023
- reconnectTimer = setTimeout(() => connect(wsUrl, protocols), options.reconnectDelay)
1024
- }
1025
- }
1026
-
1027
- socket.onerror = e => {
1028
- broadcast('error', e)
1029
- }
1030
- }
1031
-
1032
- self.onconnect = e => {
1033
- const port = e.ports[0]
1034
- ports.add(port)
1035
-
1036
- port.onmessage = event => {
1037
- const {type, path, host, protocol, token, options: opts, data} = event.data
1038
-
1039
- switch (type) {
1040
- case 'connect':
1041
- options = opts
1042
- const wsUrl = protocol + '//' + host + path
1043
- protocols = token ? ['odac-token-' + token] : []
1044
- connect(wsUrl, protocols)
1045
- break
1046
- case 'send':
1047
- if (socket && socket.readyState === WebSocket.OPEN) {
1048
- socket.send(data)
1049
- }
1050
- break
1051
- case 'close':
1052
- ports.delete(port)
1053
- if (ports.size === 0 && socket) {
1054
- socket.close()
1055
- socket = null
1056
- }
1057
- break
1058
- }
1059
- }
1060
-
1061
- port.start()
1062
- }
1063
- `
1064
-
1065
- const blob = new Blob([workerCode], {type: 'application/javascript'})
1066
- return URL.createObjectURL(blob)
979
+ port.start()
1067
980
  }
1068
981
  }
1069
-
1070
- window.Odac = new Odac()
1071
-
1072
- // Auto-initialize navigation from data-odac-navigate attribute
1073
- ;(function initAutoNavigate() {
1074
- const init = () => {
1075
- const contentEl = document.querySelector('[data-odac-navigate="content"]')
1076
- if (contentEl) {
1077
- window.Odac.loader('a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)', {content: '[data-odac-navigate="content"]'}, null)
1078
- }
1079
- }
1080
-
1081
- if (document.readyState === 'loading') {
1082
- document.addEventListener('DOMContentLoaded', init)
1083
- } else {
1084
- init()
1085
- }
1086
- })()
1087
-
1088
- document.addEventListener('DOMContentLoaded', () => {
1089
- const formTypes = ['register', 'login']
1090
-
1091
- formTypes.forEach(type => {
1092
- const forms = document.querySelectorAll(`form.odac-${type}-form[data-odac-${type}]`)
1093
- forms.forEach(form => {
1094
- const token = form.getAttribute(`data-odac-${type}`)
1095
- window.Odac.form({form: `form[data-odac-${type}="${token}"]`})
1096
- })
1097
- })
1098
-
1099
- const customForms = document.querySelectorAll('form.odac-custom-form[data-odac-form]')
1100
- customForms.forEach(form => {
1101
- const token = form.getAttribute('data-odac-form')
1102
- window.Odac.form({form: `form[data-odac-form="${token}"]`})
1103
- })
1104
- })