odac 1.0.1 → 1.2.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 (143) hide show
  1. package/.agent/rules/coding.md +27 -0
  2. package/.agent/rules/memory.md +33 -0
  3. package/.agent/rules/project.md +30 -0
  4. package/.agent/rules/workflow.md +16 -0
  5. package/.github/workflows/auto-pr-description.yml +3 -1
  6. package/.github/workflows/release.yml +42 -1
  7. package/.github/workflows/test-coverage.yml +6 -5
  8. package/.github/workflows/test-publish.yml +36 -0
  9. package/.husky/pre-commit +10 -0
  10. package/.husky/pre-push +13 -0
  11. package/.releaserc.js +3 -3
  12. package/CHANGELOG.md +184 -0
  13. package/README.md +53 -34
  14. package/bin/odac.js +181 -49
  15. package/client/odac.js +878 -995
  16. package/docs/backend/01-overview/03-development-server.md +39 -46
  17. package/docs/backend/02-structure/01-typical-project-layout.md +59 -25
  18. package/docs/backend/03-config/00-configuration-overview.md +15 -6
  19. package/docs/backend/03-config/01-database-connection.md +3 -3
  20. package/docs/backend/03-config/02-static-route-mapping-optional.md +1 -1
  21. package/docs/backend/03-config/03-request-timeout.md +1 -1
  22. package/docs/backend/03-config/04-environment-variables.md +4 -4
  23. package/docs/backend/03-config/05-early-hints.md +2 -2
  24. package/docs/backend/04-routing/02-controller-less-view-routes.md +9 -3
  25. package/docs/backend/04-routing/03-api-and-data-routes.md +18 -0
  26. package/docs/backend/04-routing/07-cron-jobs.md +17 -1
  27. package/docs/backend/04-routing/09-websocket.md +29 -0
  28. package/docs/backend/05-controllers/01-how-to-build-a-controller.md +48 -3
  29. package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +2 -0
  30. package/docs/backend/05-controllers/03-controller-classes.md +61 -55
  31. package/docs/backend/05-forms/01-custom-forms.md +103 -95
  32. package/docs/backend/05-forms/02-automatic-database-insert.md +21 -21
  33. package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +17 -0
  34. package/docs/backend/07-views/02-rendering-a-view.md +1 -1
  35. package/docs/backend/07-views/03-variables.md +5 -5
  36. package/docs/backend/07-views/04-request-data.md +1 -1
  37. package/docs/backend/07-views/08-backend-javascript.md +1 -1
  38. package/docs/backend/07-views/10-styling-and-tailwind.md +93 -0
  39. package/docs/backend/08-database/01-getting-started.md +100 -0
  40. package/docs/backend/08-database/02-basics.md +136 -0
  41. package/docs/backend/08-database/03-advanced.md +84 -0
  42. package/docs/backend/08-database/04-migrations.md +48 -0
  43. package/docs/backend/09-validation/01-the-validator-service.md +1 -0
  44. package/docs/backend/10-authentication/03-register.md +9 -2
  45. package/docs/backend/10-authentication/04-odac-register-forms.md +48 -48
  46. package/docs/backend/10-authentication/05-session-management.md +16 -2
  47. package/docs/backend/10-authentication/06-odac-login-forms.md +50 -50
  48. package/docs/backend/10-authentication/07-magic-links.md +134 -0
  49. package/docs/backend/11-mail/01-the-mail-service.md +118 -28
  50. package/docs/backend/12-streaming/01-streaming-overview.md +2 -2
  51. package/docs/backend/13-utilities/01-odac-var.md +7 -7
  52. package/docs/backend/13-utilities/02-ipc.md +73 -0
  53. package/docs/frontend/01-overview/01-introduction.md +5 -1
  54. package/docs/frontend/02-ajax-navigation/01-quick-start.md +1 -1
  55. package/docs/index.json +21 -125
  56. package/eslint.config.mjs +5 -47
  57. package/jest.config.js +1 -1
  58. package/package.json +16 -7
  59. package/src/Auth.js +414 -121
  60. package/src/Config.js +12 -7
  61. package/src/Database.js +188 -0
  62. package/src/Env.js +3 -1
  63. package/src/Ipc.js +337 -0
  64. package/src/Lang.js +9 -2
  65. package/src/Mail.js +408 -37
  66. package/src/Odac.js +105 -40
  67. package/src/Request.js +71 -49
  68. package/src/Route/Cron.js +62 -18
  69. package/src/Route/Internal.js +215 -12
  70. package/src/Route/Middleware.js +7 -2
  71. package/src/Route.js +372 -109
  72. package/src/Server.js +118 -12
  73. package/src/Storage.js +169 -0
  74. package/src/Token.js +6 -4
  75. package/src/Validator.js +95 -3
  76. package/src/Var.js +22 -6
  77. package/src/View/EarlyHints.js +43 -33
  78. package/src/View/Form.js +210 -28
  79. package/src/View.js +108 -7
  80. package/src/WebSocket.js +18 -3
  81. package/template/odac.json +5 -0
  82. package/template/package.json +3 -1
  83. package/template/route/www.js +12 -10
  84. package/template/view/content/home.html +3 -3
  85. package/template/view/head/main.html +2 -2
  86. package/test/Client.test.js +168 -0
  87. package/test/Config.test.js +112 -0
  88. package/test/Lang.test.js +92 -0
  89. package/test/Odac.test.js +86 -0
  90. package/test/{framework/middleware.test.js → Route/Middleware.test.js} +2 -2
  91. package/test/{framework/Route.test.js → Route.test.js} +1 -1
  92. package/test/{framework/View → View}/EarlyHints.test.js +1 -1
  93. package/test/{framework/WebSocket.test.js → WebSocket.test.js} +2 -2
  94. package/test/scripts/check-coverage.js +4 -4
  95. package/docs/backend/08-database/01-database-connection.md +0 -99
  96. package/docs/backend/08-database/02-using-mysql.md +0 -322
  97. package/src/Mysql.js +0 -575
  98. package/template/config.json +0 -5
  99. package/test/cli/Cli.test.js +0 -36
  100. package/test/core/Candy.test.js +0 -234
  101. package/test/core/Commands.test.js +0 -538
  102. package/test/core/Config.test.js +0 -1432
  103. package/test/core/Lang.test.js +0 -250
  104. package/test/core/Process.test.js +0 -156
  105. package/test/server/Api.test.js +0 -647
  106. package/test/server/DNS.test.js +0 -2050
  107. package/test/server/DNS.test.js.bak +0 -2084
  108. package/test/server/Hub.test.js +0 -497
  109. package/test/server/Log.test.js +0 -73
  110. package/test/server/Mail.account.test_.js +0 -460
  111. package/test/server/Mail.init.test_.js +0 -411
  112. package/test/server/Mail.test_.js +0 -1340
  113. package/test/server/SSL.test_.js +0 -1491
  114. package/test/server/Server.test.js +0 -765
  115. package/test/server/Service.test_.js +0 -1127
  116. package/test/server/Subdomain.test.js +0 -440
  117. package/test/server/Web/Firewall.test.js +0 -175
  118. package/test/server/Web/Proxy.test.js +0 -397
  119. package/test/server/Web.test.js +0 -1494
  120. package/test/server/__mocks__/acme-client.js +0 -17
  121. package/test/server/__mocks__/bcrypt.js +0 -50
  122. package/test/server/__mocks__/child_process.js +0 -389
  123. package/test/server/__mocks__/crypto.js +0 -432
  124. package/test/server/__mocks__/fs.js +0 -450
  125. package/test/server/__mocks__/globalOdac.js +0 -227
  126. package/test/server/__mocks__/http.js +0 -575
  127. package/test/server/__mocks__/https.js +0 -272
  128. package/test/server/__mocks__/index.js +0 -249
  129. package/test/server/__mocks__/mail/server.js +0 -100
  130. package/test/server/__mocks__/mail/smtp.js +0 -31
  131. package/test/server/__mocks__/mailparser.js +0 -81
  132. package/test/server/__mocks__/net.js +0 -369
  133. package/test/server/__mocks__/node-forge.js +0 -328
  134. package/test/server/__mocks__/os.js +0 -320
  135. package/test/server/__mocks__/path.js +0 -291
  136. package/test/server/__mocks__/selfsigned.js +0 -8
  137. package/test/server/__mocks__/server/src/mail/server.js +0 -100
  138. package/test/server/__mocks__/server/src/mail/smtp.js +0 -31
  139. package/test/server/__mocks__/smtp-server.js +0 -106
  140. package/test/server/__mocks__/sqlite3.js +0 -394
  141. package/test/server/__mocks__/testFactories.js +0 -299
  142. package/test/server/__mocks__/testHelpers.js +0 -363
  143. package/test/server/__mocks__/tls.js +0 -229
package/client/odac.js CHANGED
@@ -1,1104 +1,987 @@
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
440
- 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)
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)
529
+ errorEl.style.display = 'block'
530
+ if (!errorEl.style.color) errorEl.style.color = '#dc3545'
531
+ if (!errorEl.style.fontSize) errorEl.style.fontSize = '0.875rem'
532
+ if (!errorEl.style.marginTop) errorEl.style.marginTop = '0.25rem'
533
+ } else {
534
+ const inputEl = formElement.querySelector(`*[name="${name}"]`)
535
+ if (inputEl) {
536
+ const errorEl = document.createElement('span')
537
+ errorEl.setAttribute('odac-form-error', name)
538
+ errorEl.innerHTML = this.textToHtml(message)
539
+ errorEl.style.cssText = 'display:block;color:#dc3545;font-size:0.875rem;margin-top:0.25rem'
540
+
541
+ if ((inputEl.type === 'checkbox' || inputEl.type === 'radio') && inputEl.id) {
542
+ const label = formElement.querySelector(`label[for="${inputEl.id}"]`)
543
+ label ? label.parentNode.insertBefore(errorEl, label.nextSibling) : inputEl.parentNode.insertBefore(errorEl, inputEl.nextSibling)
446
544
  } else {
447
545
  inputEl.parentNode.insertBefore(errorEl, inputEl.nextSibling)
448
546
  }
449
- } else {
450
- inputEl.parentNode.insertBefore(errorEl, inputEl.nextSibling)
547
+ } else if (name === '_odac_form') {
548
+ const errorEl = document.createElement('div')
549
+ errorEl.setAttribute('odac-form-error', name)
550
+ errorEl.innerHTML = this.textToHtml(message)
551
+ errorEl.style.display = 'block'
552
+ errorEl.style.color = '#dc3545'
553
+ errorEl.style.fontSize = '0.875rem'
554
+ errorEl.style.marginBottom = '1rem'
555
+ formElement.insertBefore(errorEl, formElement.firstChild)
451
556
  }
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
557
  }
460
558
  }
461
- }
462
- const inputEl = formElement.querySelector(`*[name="${name}"]`)
463
- if (inputEl) {
464
- if (inputEl.type !== 'checkbox' && inputEl.type !== 'radio') {
465
- inputEl.style.borderColor = '#dc3545'
559
+ const inputEl = formElement.querySelector(`*[name="${name}"]`)
560
+ if (inputEl) {
561
+ if (inputEl.type !== 'checkbox' && inputEl.type !== 'radio') inputEl.style.borderColor = '#dc3545'
562
+ inputEl.addEventListener('focus', function handler() {
563
+ inputEl.style.borderColor = ''
564
+ const errorEl = formElement.querySelector(`[odac-form-error="${name}"]`)
565
+ if (errorEl) { errorEl.style.display = 'none'; errorEl.textContent = '' }
566
+ inputEl.removeEventListener('focus', handler)
567
+ }, {once: true})
466
568
  }
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)
569
+ })
499
570
  }
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
571
  }
517
- })
518
- formElement.querySelectorAll('input:not([type="hidden"]), textarea, select').forEach(el => (el.disabled = false))
519
- }
520
- })
572
+ if (data.result.success && data.result.redirect) {
573
+ window.location.href = data.result.redirect
574
+ } else if (callback !== undefined) {
575
+ if (typeof callback === 'function') callback(data)
576
+ else if (data.result.success) window.location.replace(callback)
577
+ }
578
+ },
579
+ 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)
584
+ return xhr
585
+ },
586
+ error: () => console.error('Odac:', 'Request failed', '\nForm: ' + obj.form + '\nRequest: ' + formElement.getAttribute('action')),
587
+ complete: () => {
588
+ submitButtons.forEach(btn => {
589
+ btn.disabled = false
590
+ const originalText = btn.getAttribute('data-original-text')
591
+ if (originalText) { btn.textContent = originalText; btn.removeAttribute('data-original-text') }
592
+ })
593
+ formElement.querySelectorAll('input:not([type="hidden"]), textarea, select').forEach(el => (el.disabled = false))
594
+ }
595
+ })
596
+ }
597
+
598
+ document.addEventListener('submit', handler)
599
+ this.#formSubmitHandlers.set(formSelector, handler)
521
600
  }
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 || ''
601
+
602
+ get(url, callback) {
603
+ url = url + '?_token=' + this.token()
604
+ this.#ajax({url: url, success: callback})
535
605
  }
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
606
+
607
+ page() {
608
+ if (!this.#page) {
609
+ this.#page = document.documentElement.dataset.odacPage || ''
610
+ }
611
+ return this.#page
560
612
  }
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)
613
+
614
+ storage(key, value) {
615
+ if (value === undefined) return localStorage.getItem(key)
616
+ else if (value === null) return localStorage.removeItem(key)
617
+ else localStorage.setItem(key, value)
569
618
  }
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)
619
+
620
+ token() {
621
+ if (!this.#token.listener) {
622
+ document.addEventListener('odac:ajaxSuccess', event => {
623
+ const {detail} = event
624
+ if (detail.requestUrl.includes('://')) return false
625
+ try {
626
+ const token = detail.xhr.getResponseHeader('X-Odac-Token')
627
+ if (token) {
628
+ this.#token.hash.push(token)
629
+ if (this.#token.hash.length > 2) this.#token.hash.shift()
630
+ }
631
+ } catch (e) {
632
+ console.error('Error in ajaxSuccess token handler:', e)
649
633
  }
650
634
  })
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()
635
+ this.#token.listener = true
636
+ }
637
+ if (!this.#token.hash.length) {
638
+ var req = new XMLHttpRequest()
639
+ req.open('GET', '/', false)
640
+ req.setRequestHeader('X-Odac', 'token')
641
+ req.setRequestHeader('X-Odac-Client', this.client())
642
+ req.send(null)
643
+ var req_data = JSON.parse(req.response)
644
+ if (req_data.token) this.#token.hash.push(req_data.token)
645
+ }
646
+ this.#token.hash.filter(n => n)
647
+ var return_token = this.#token.hash.shift()
648
+ if (!this.#token.hash.length)
649
+ this.#ajax({
650
+ url: '/',
651
+ type: 'GET',
652
+ headers: {'X-Odac': 'token', 'X-Odac-Client': this.client()},
653
+ success: data => {
654
+ var result = JSON.parse(JSON.stringify(data))
655
+ if (result.token) this.#token.hash.push(result.token)
663
656
  }
664
657
  })
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
- }
658
+ return return_token
710
659
  }
711
-
712
- // Call custom callback
713
- if (callback && typeof callback === 'function') {
714
- callback(this.page(), data.variables)
660
+
661
+ textToHtml(str) {
662
+ if (typeof str !== 'string') return str
663
+ return str
664
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
665
+ .replace(/"/g, '&quot;').replace(/'/g, '&#039;').replace(/\n/g, '<br>')
715
666
  }
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)
667
+
668
+ load(url, callback, push = true) {
669
+ if (this.#isNavigating) return false
670
+
671
+ const currentUrl = window.location.href
672
+ url = new URL(url, currentUrl).href
673
+ if (url === '' || url.startsWith('javascript:') || url.startsWith('data:') || url.startsWith('vbscript:') || url.includes('#')) return false
674
+
675
+ this.#isNavigating = true
676
+
677
+ const currentSkeleton = document.documentElement.dataset.odacSkeleton
678
+ const elements = Object.entries(this.#loader.elements)
679
+
680
+ const elementsToUpdate = []
681
+ elements.forEach(([key, selector]) => {
682
+ const element = document.querySelector(selector)
683
+ if (element) elementsToUpdate.push({key, element})
684
+ })
685
+
686
+ let ajaxData = null, ajaxXhr = null, fadeOutComplete = false, ajaxComplete = false
687
+
688
+ const applyUpdate = () => {
689
+ if (!fadeOutComplete || !ajaxComplete || !ajaxData) return
690
+
691
+ const finalUrl = ajaxXhr.responseURL || url
692
+ if (ajaxData.skeletonChanged) { window.location.href = finalUrl; return }
693
+ if (finalUrl !== currentUrl && push) window.history.pushState(null, document.title, finalUrl)
694
+
695
+ const newPage = ajaxXhr.getResponseHeader('X-Odac-Page')
696
+ if (newPage !== null) {
697
+ this.#page = newPage
698
+ document.documentElement.dataset.odacPage = newPage
699
+ }
700
+
701
+ if (ajaxData.data) this.#data = ajaxData.data
702
+ if (ajaxData.title) document.title = ajaxData.title
703
+
704
+ if (elementsToUpdate.length === 0) {
705
+ this.#handleLoadComplete(ajaxData, callback)
706
+ return
707
+ }
708
+
709
+ let completed = 0
710
+ elementsToUpdate.forEach(({key, element}) => {
711
+ if (ajaxData.output && ajaxData.output[key] !== undefined) element.innerHTML = ajaxData.output[key]
712
+ this.#fadeIn(element, 200, () => {
713
+ completed++
714
+ if (completed === elementsToUpdate.length) this.#handleLoadComplete(ajaxData, callback)
715
+ })
716
+ })
771
717
  }
772
-
773
- eventSource.onmessage = e => {
774
- try {
775
- const data = JSON.parse(e.data)
776
- onMessage(data)
777
- } catch {
778
- onMessage(e.data)
779
- }
718
+
719
+ if (elementsToUpdate.length > 0) {
720
+ let fadeOutCount = 0
721
+ elementsToUpdate.forEach(({element}) => {
722
+ this.#fadeOut(element, 200, () => {
723
+ fadeOutCount++
724
+ if (fadeOutCount === elementsToUpdate.length) {
725
+ fadeOutComplete = true
726
+ applyUpdate()
727
+ }
728
+ })
729
+ })
730
+ } else {
731
+ fadeOutComplete = true
780
732
  }
781
-
782
- eventSource.onerror = e => {
783
- if (onError) onError(e)
784
-
785
- if (autoReconnect && !isClosed) {
786
- eventSource.close()
787
- reconnectTimer = setTimeout(connect, reconnectDelay)
733
+
734
+ this.#ajax({
735
+ url: url,
736
+ type: 'GET',
737
+ headers: {
738
+ 'X-Odac': 'ajaxload',
739
+ 'X-Odac-Load': Object.keys(this.#loader.elements).join(','),
740
+ 'X-Odac-Skeleton': currentSkeleton || ''
741
+ },
742
+ dataType: 'json',
743
+ success: (data, status, xhr) => {
744
+ ajaxData = data; ajaxXhr = xhr; ajaxComplete = true; applyUpdate()
745
+ },
746
+ error: () => {
747
+ this.#isNavigating = false
748
+ window.location.replace(url)
788
749
  }
789
- }
750
+ })
790
751
  }
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
- }
752
+
753
+ #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
+
757
+ if (callback && typeof callback === 'function') callback(this.page(), data.variables)
758
+
759
+ window.scrollTo({top: 0, behavior: 'smooth'})
760
+ this.#isNavigating = false
803
761
  }
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})
762
+
763
+ loader(selector, elements, callback) {
764
+ this.#loader.elements = elements
765
+ this.#loader.callback = callback
766
+ const odacInstance = this
767
+
768
+ this.#on(document, 'click', selector, function (e) {
769
+ if (e.ctrlKey || e.metaKey) return
770
+ const anchor = this
771
+ if (!anchor) return
772
+ const url = anchor.getAttribute('href')
773
+ const target = anchor.getAttribute('target')
774
+ if (!url || url === '' || url.startsWith('javascript:') || url.startsWith('data:') || url.startsWith('vbscript:') || url.startsWith('#')) return
775
+ const isExternal = url.includes('://') && !url.includes(window.location.host)
776
+ if ((target === null || target === '_self') && !isExternal) {
777
+ e.preventDefault()
778
+ odacInstance.load(url, callback)
779
+ }
780
+ })
781
+
782
+ window.addEventListener('popstate', () => {
783
+ this.load(window.location.href, callback, false)
784
+ })
811
785
  }
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))
786
+
787
+ listen(url, onMessage, options = {}) {
788
+ const {onError = null, onOpen = null, autoReconnect = false, reconnectDelay = 3000} = options
789
+ let eventSource = null, reconnectTimer = null, isClosed = false
790
+
791
+ const connect = () => {
792
+ if (isClosed) return
793
+ const urlWithToken = url + (url.includes('?') ? '&' : '?') + '_token=' + encodeURIComponent(this.token())
794
+ eventSource = new EventSource(urlWithToken)
795
+ eventSource.onopen = e => { if (onOpen) onOpen(e) }
796
+ eventSource.onmessage = e => {
797
+ try { onMessage(JSON.parse(e.data)) } catch { onMessage(e.data) }
798
+ }
799
+ eventSource.onerror = e => {
800
+ if (onError) onError(e)
801
+ if (autoReconnect && !isClosed) {
802
+ eventSource.close()
803
+ reconnectTimer = setTimeout(connect, reconnectDelay)
804
+ }
805
+ }
806
+ }
807
+ connect()
808
+
809
+ return {
810
+ close: () => {
811
+ isClosed = true
812
+ if (reconnectTimer) clearTimeout(reconnectTimer)
813
+ if (eventSource) eventSource.close()
814
+ },
815
+ send: () => { throw new Error('SSE is one-way. Use POST requests to send data.') }
822
816
  }
823
817
  }
824
-
825
- const connect = () => {
826
- if (isClosed) return
827
-
818
+
819
+ ws(path, options = {}) {
820
+ const {autoReconnect = true, reconnectDelay = 3000, maxReconnectAttempts = 10, shared = false, token = true} = options
821
+
822
+ if (shared && typeof SharedWorker !== 'undefined') {
823
+ return this.#createSharedWebSocket(path, {autoReconnect, reconnectDelay, maxReconnectAttempts, token})
824
+ }
825
+
828
826
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
829
827
  const wsUrl = `${protocol}//${window.location.host}${path}`
830
-
831
828
  const protocols = []
832
829
  if (token) {
833
830
  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')
831
+ if (csrfToken) protocols.push(`odac-token-${csrfToken}`)
844
832
  }
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)
833
+
834
+ return new OdacWebSocket(wsUrl, protocols, options)
835
+ }
836
+
837
+ #createSharedWebSocket(path, options) {
838
+ const scriptUrl = (() => {
839
+ if (document.currentScript) return document.currentScript.src
840
+ const scripts = document.querySelectorAll('script')
841
+ for (let i = 0; i < scripts.length; i++) {
842
+ if (scripts[i].src.includes('odac.js')) return scripts[i].src
843
+ }
844
+ return '/odac.js'
845
+ })()
846
+ const worker = new SharedWorker(scriptUrl, `odac-ws-${path}`)
847
+ const handlers = {}
848
+ let isConnected = false
849
+
850
+ const emit = (event, ...args) => {
851
+ if (handlers[event]) handlers[event].forEach(fn => fn(...args))
852
+ }
853
+
854
+ worker.port.onmessage = e => {
855
+ const {type, data} = e.data
856
+ switch (type) {
857
+ case 'open':
858
+ isConnected = true
859
+ emit('open')
860
+ break
861
+ case 'message':
862
+ emit('message', data)
863
+ break
864
+ case 'close':
865
+ isConnected = false
866
+ emit('close', data)
867
+ break
868
+ case 'error':
869
+ emit('error', data)
870
+ break
852
871
  }
853
872
  }
854
-
855
- socket.onclose = e => {
856
- emit('close', e)
857
-
858
- if (autoReconnect && !isClosed && reconnectAttempts < maxReconnectAttempts) {
859
- reconnectAttempts++
860
- reconnectTimer = setTimeout(connect, reconnectDelay)
873
+
874
+ worker.port.start()
875
+
876
+ const token = options.token ? this.token() : null
877
+
878
+ worker.port.postMessage({
879
+ type: 'connect',
880
+ path,
881
+ host: window.location.host,
882
+ protocol: window.location.protocol === 'https:' ? 'wss:' : 'ws:',
883
+ token,
884
+ options
885
+ })
886
+
887
+ return {
888
+ on: (event, handler) => {
889
+ if (!handlers[event]) handlers[event] = []
890
+ handlers[event].push(handler)
891
+ return this
892
+ },
893
+ off: (event, handler) => {
894
+ if (!handlers[event]) return
895
+ if (handler) {
896
+ handlers[event] = handlers[event].filter(h => h !== handler)
897
+ } else {
898
+ delete handlers[event]
899
+ }
900
+ return this
901
+ },
902
+ send: data => {
903
+ worker.port.postMessage({
904
+ type: 'send',
905
+ data: typeof data === 'object' ? JSON.stringify(data) : data
906
+ })
907
+ return this
908
+ },
909
+ close: () => {
910
+ worker.port.postMessage({type: 'close'})
911
+ worker.port.close()
912
+ },
913
+ get connected() {
914
+ return isConnected
861
915
  }
862
916
  }
863
-
864
- socket.onerror = e => {
865
- emit('error', e)
866
- }
867
917
  }
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
918
+ }
919
+
920
+ window.Odac = new _odac()
921
+
922
+ ;(function initAutoNavigate() {
923
+ const init = () => {
924
+ const contentEl = document.querySelector('[data-odac-navigate="content"]')
925
+ if (contentEl) {
926
+ window.Odac.loader('a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)', {content: '[data-odac-navigate="content"]'}, null)
902
927
  }
903
928
  }
904
- }
929
+ document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init()
930
+ })()
931
+
932
+ document.addEventListener('DOMContentLoaded', () => {
933
+ ['register', 'login'].forEach(type => {
934
+ document.querySelectorAll(`form.odac-${type}-form[data-odac-${type}]`).forEach(form => {
935
+ const token = form.getAttribute(`data-odac-${type}`)
936
+ window.Odac.form({form: `form[data-odac-${type}="${token}"]`})
937
+ })
938
+ })
939
+ document.querySelectorAll('form.odac-custom-form[data-odac-form]').forEach(form => {
940
+ const token = form.getAttribute('data-odac-form')
941
+ window.Odac.form({form: `form[data-odac-form="${token}"]`})
942
+ })
943
+ })
944
+ } else {
945
+ let socket = null
946
+ const ports = new Set()
905
947
 
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
948
+ const broadcast = (type, data) => ports.forEach(port => port.postMessage({type, data}))
911
949
 
912
- const emit = (event, ...args) => {
913
- if (handlers[event]) {
914
- handlers[event].forEach(fn => fn(...args))
915
- }
916
- }
950
+ self.onconnect = e => {
951
+ const port = e.ports[0]
952
+ ports.add(port)
917
953
 
918
- worker.port.onmessage = e => {
919
- const {type, data} = e.data
954
+ port.onmessage = event => {
955
+ const {type, path, host, protocol, token, options, data} = event.data
920
956
 
921
957
  switch (type) {
922
- case 'open':
923
- isConnected = true
924
- emit('open')
958
+ case 'connect':
959
+ if (!socket) {
960
+ const wsUrl = protocol + '//' + host + path
961
+ const protocols = token ? ['odac-token-' + token] : []
962
+ socket = new OdacWebSocket(wsUrl, protocols, options)
963
+ socket.on('open', () => broadcast('open'))
964
+ socket.on('message', data => broadcast('message', data))
965
+ socket.on('close', e => broadcast('close', {code: e?.code, reason: e?.reason, wasClean: e?.wasClean}))
966
+ socket.on('error', e => broadcast('error', {message: e?.message || 'WebSocket error'}))
967
+ } else if (socket.connected) {
968
+ // If already connected, notify the new port immediately
969
+ port.postMessage({type: 'open'})
970
+ }
925
971
  break
926
- case 'message':
927
- emit('message', data)
972
+ case 'send':
973
+ if (socket) socket.send(data)
928
974
  break
929
975
  case 'close':
930
- isConnected = false
931
- emit('close', data)
932
- break
933
- case 'error':
934
- emit('error', data)
976
+ ports.delete(port)
977
+ if (ports.size === 0 && socket) {
978
+ socket.close()
979
+ socket = null
980
+ }
935
981
  break
936
982
  }
937
983
  }
938
984
 
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)
985
+ port.start()
1067
986
  }
1068
987
  }
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
- })