sandals 0.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +604 -0
- data/bin/sandals +8 -0
- data/lib/sandals/action_error.rb +41 -0
- data/lib/sandals/application.rb +114 -0
- data/lib/sandals/assets/TURBO-LICENSE +20 -0
- data/lib/sandals/assets/turbo.es2017-umd.js +7298 -0
- data/lib/sandals/binding_resolver.rb +77 -0
- data/lib/sandals/cli.rb +75 -0
- data/lib/sandals/code_reloader.rb +80 -0
- data/lib/sandals/event_dispatcher.rb +35 -0
- data/lib/sandals/html_renderer.rb +686 -0
- data/lib/sandals/server.rb +179 -0
- data/lib/sandals/snapshot_history.rb +34 -0
- data/lib/sandals/test_session.rb +352 -0
- data/lib/sandals/uploaded_file.rb +18 -0
- data/lib/sandals/version.rb +5 -0
- data/lib/sandals/view.rb +568 -0
- data/lib/sandals.rb +34 -0
- metadata +74 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Sandals
|
|
6
|
+
class HTMLRenderer
|
|
7
|
+
TURBO_URL = "/__sandals/assets/turbo.js"
|
|
8
|
+
TURBO_PATH = File.expand_path("assets/turbo.es2017-umd.js", __dir__)
|
|
9
|
+
|
|
10
|
+
DEFAULT_STYLES = <<~CSS
|
|
11
|
+
:root {
|
|
12
|
+
color-scheme: light;
|
|
13
|
+
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
14
|
+
background: #f7f7f5;
|
|
15
|
+
color: #20201e;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
body {
|
|
19
|
+
margin: 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
main {
|
|
23
|
+
box-sizing: border-box;
|
|
24
|
+
width: min(100%, 48rem);
|
|
25
|
+
margin: 0 auto;
|
|
26
|
+
padding: 3rem 1.5rem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
h1 {
|
|
30
|
+
margin: 0;
|
|
31
|
+
font-size: clamp(2rem, 7vw, 4rem);
|
|
32
|
+
letter-spacing: -0.04em;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
p {
|
|
36
|
+
margin: 0;
|
|
37
|
+
line-height: 1.6;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.error,
|
|
41
|
+
.field-error {
|
|
42
|
+
color: #b42318;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.notice {
|
|
46
|
+
color: #067647;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.runtime-errors {
|
|
50
|
+
box-sizing: border-box;
|
|
51
|
+
width: min(100%, 48rem);
|
|
52
|
+
margin: 0 auto;
|
|
53
|
+
padding: 1rem 1.5rem 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.action-error {
|
|
57
|
+
display: grid;
|
|
58
|
+
gap: 0.375rem;
|
|
59
|
+
padding: 1rem;
|
|
60
|
+
border: 1px solid #fda29b;
|
|
61
|
+
border-radius: 0.375rem;
|
|
62
|
+
background: #fef3f2;
|
|
63
|
+
color: #b42318;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.action-error code {
|
|
67
|
+
overflow-wrap: anywhere;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.field-error {
|
|
71
|
+
font-size: 0.875rem;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
img {
|
|
75
|
+
display: block;
|
|
76
|
+
max-width: 100%;
|
|
77
|
+
height: auto;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
a {
|
|
81
|
+
color: #175cd3;
|
|
82
|
+
text-underline-offset: 0.15em;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.text-field,
|
|
86
|
+
.number-field,
|
|
87
|
+
.text-area,
|
|
88
|
+
.file-field,
|
|
89
|
+
.select {
|
|
90
|
+
display: grid;
|
|
91
|
+
gap: 0.375rem;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.text-field input,
|
|
95
|
+
.number-field input,
|
|
96
|
+
.text-area textarea,
|
|
97
|
+
.file-field input,
|
|
98
|
+
.select select {
|
|
99
|
+
box-sizing: border-box;
|
|
100
|
+
width: 100%;
|
|
101
|
+
padding: 0.625rem 0.75rem;
|
|
102
|
+
border: 1px solid #c8c8c3;
|
|
103
|
+
border-radius: 0.375rem;
|
|
104
|
+
background: white;
|
|
105
|
+
color: inherit;
|
|
106
|
+
font: inherit;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
input[aria-invalid="true"],
|
|
110
|
+
textarea[aria-invalid="true"],
|
|
111
|
+
select[aria-invalid="true"] {
|
|
112
|
+
border-color: #b42318;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.text-area textarea {
|
|
116
|
+
min-height: 7rem;
|
|
117
|
+
resize: vertical;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.checkbox {
|
|
121
|
+
display: flex;
|
|
122
|
+
align-items: center;
|
|
123
|
+
gap: 0.5rem;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.checkbox input {
|
|
127
|
+
width: 1rem;
|
|
128
|
+
height: 1rem;
|
|
129
|
+
margin: 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.radio {
|
|
133
|
+
display: grid;
|
|
134
|
+
gap: 0.5rem;
|
|
135
|
+
margin: 0;
|
|
136
|
+
padding: 0;
|
|
137
|
+
border: 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.radio legend {
|
|
141
|
+
margin-bottom: 0.375rem;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.radio label {
|
|
145
|
+
display: flex;
|
|
146
|
+
align-items: center;
|
|
147
|
+
gap: 0.5rem;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.radio input {
|
|
151
|
+
margin: 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
table {
|
|
155
|
+
width: 100%;
|
|
156
|
+
border-collapse: collapse;
|
|
157
|
+
background: white;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
th,
|
|
161
|
+
td {
|
|
162
|
+
padding: 0.625rem 0.75rem;
|
|
163
|
+
border: 1px solid #deded9;
|
|
164
|
+
text-align: left;
|
|
165
|
+
vertical-align: top;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
th {
|
|
169
|
+
background: #f0f0ed;
|
|
170
|
+
font-weight: 600;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
button {
|
|
174
|
+
padding: 0.625rem 1rem;
|
|
175
|
+
border: 0;
|
|
176
|
+
border-radius: 0.375rem;
|
|
177
|
+
background: #20201e;
|
|
178
|
+
color: white;
|
|
179
|
+
font: inherit;
|
|
180
|
+
cursor: pointer;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
button[aria-busy="true"] {
|
|
184
|
+
cursor: wait;
|
|
185
|
+
opacity: 0.7;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.stack {
|
|
189
|
+
display: flex;
|
|
190
|
+
flex-direction: column;
|
|
191
|
+
gap: 1rem;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.flow {
|
|
195
|
+
display: flex;
|
|
196
|
+
flex-flow: row wrap;
|
|
197
|
+
align-items: center;
|
|
198
|
+
gap: 1rem;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.form {
|
|
202
|
+
display: flex;
|
|
203
|
+
flex-direction: column;
|
|
204
|
+
gap: 1rem;
|
|
205
|
+
}
|
|
206
|
+
CSS
|
|
207
|
+
|
|
208
|
+
def render(application, view, revision:, development_reload: false)
|
|
209
|
+
document(
|
|
210
|
+
application.title,
|
|
211
|
+
render_children(view),
|
|
212
|
+
revision:,
|
|
213
|
+
refresh_interval: application.refresh_interval,
|
|
214
|
+
development_reload:
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def render_fragment(view)
|
|
219
|
+
render_children(view)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def render_action_error(error)
|
|
223
|
+
original = error.original_error
|
|
224
|
+
<<~HTML
|
|
225
|
+
<turbo-stream action="update" target="sandals-errors">
|
|
226
|
+
<template><section class="action-error" role="alert"><strong>Action failed for “#{escape(error.action_name)}”</strong><p>#{escape(original.class)}: #{escape(original.message)}</p><code>#{escape(error.location)}</code></section></template>
|
|
227
|
+
</turbo-stream>
|
|
228
|
+
HTML
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def clear_action_error
|
|
232
|
+
<<~HTML
|
|
233
|
+
<turbo-stream action="update" target="sandals-errors">
|
|
234
|
+
<template></template>
|
|
235
|
+
</turbo-stream>
|
|
236
|
+
HTML
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private
|
|
240
|
+
|
|
241
|
+
def render_children(view)
|
|
242
|
+
view.children.map { |element| render_element(element) }.join
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def render_element(element)
|
|
246
|
+
case element
|
|
247
|
+
when String
|
|
248
|
+
escape(element)
|
|
249
|
+
when Title
|
|
250
|
+
"<h1>#{escape(element.content)}</h1>"
|
|
251
|
+
when Subtitle
|
|
252
|
+
"<h2>#{escape(element.content)}</h2>"
|
|
253
|
+
when Para
|
|
254
|
+
"<p>#{element.parts.map { |part| render_element(part) }.join}</p>"
|
|
255
|
+
when Error
|
|
256
|
+
%(<p class="error" role="alert">#{escape(element.content)}</p>)
|
|
257
|
+
when Notice
|
|
258
|
+
%(<p class="notice" role="status">#{escape(element.content)}</p>)
|
|
259
|
+
when Image
|
|
260
|
+
%(<img src="#{escape(element.source)}" alt="#{escape(element.alt)}">)
|
|
261
|
+
when Link
|
|
262
|
+
%(<a href="#{escape(element.destination)}">#{escape(element.content)}</a>)
|
|
263
|
+
when Table
|
|
264
|
+
table(element)
|
|
265
|
+
when TextField
|
|
266
|
+
%(<label class="text-field"><span>#{escape(element.label)}</span><input type="text" value="#{escape(element.value)}" data-sandals-target="#{escape(element.id)}"#{validation_attributes(element)}>#{field_error(element)}</label>)
|
|
267
|
+
when NumberField
|
|
268
|
+
%(<label class="number-field"><span>#{escape(element.label)}</span><input type="number" value="#{escape(element.value)}" data-sandals-target="#{escape(element.id)}"#{validation_attributes(element)}>#{field_error(element)}</label>)
|
|
269
|
+
when TextArea
|
|
270
|
+
%(<label class="text-area"><span>#{escape(element.label)}</span><textarea data-sandals-target="#{escape(element.id)}"#{validation_attributes(element)}>#{escape(element.value)}</textarea>#{field_error(element)}</label>)
|
|
271
|
+
when FileField
|
|
272
|
+
%(<label class="file-field"><span>#{escape(element.label)}</span><input type="file" data-sandals-target="#{escape(element.id)}"#{validation_attributes(element)}>#{field_error(element)}</label>)
|
|
273
|
+
when Checkbox
|
|
274
|
+
checked = element.checked? ? " checked" : ""
|
|
275
|
+
%(<label class="checkbox"><input type="checkbox"#{checked} data-sandals-target="#{escape(element.id)}"#{validation_attributes(element)}><span>#{escape(element.label)}</span>#{field_error(element)}</label>)
|
|
276
|
+
when Radio
|
|
277
|
+
options = element.options.map do |option|
|
|
278
|
+
checked = element.selected?(option) ? " checked" : ""
|
|
279
|
+
%(<label><input type="radio" name="#{escape(element.id)}" value="#{escape(option.value)}"#{checked} data-sandals-target="#{escape(element.id)}"><span>#{escape(option.label)}</span></label>)
|
|
280
|
+
end.join
|
|
281
|
+
%(<fieldset class="radio"#{validation_attributes(element)}><legend>#{escape(element.label)}</legend>#{options}#{field_error(element)}</fieldset>)
|
|
282
|
+
when Select
|
|
283
|
+
options = element.options.map do |option|
|
|
284
|
+
selected = element.selected?(option) ? " selected" : ""
|
|
285
|
+
%(<option value="#{escape(option.value)}"#{selected}>#{escape(option.label)}</option>)
|
|
286
|
+
end.join
|
|
287
|
+
%(<label class="select"><span>#{escape(element.label)}</span><select data-sandals-target="#{escape(element.id)}"#{validation_attributes(element)}>#{options}</select>#{field_error(element)}</label>)
|
|
288
|
+
when Submit
|
|
289
|
+
%(<button type="submit" data-sandals-target="#{escape(element.id)}">#{escape(element.content)}</button>)
|
|
290
|
+
when Button
|
|
291
|
+
%(<button type="button" data-sandals-target="#{escape(element.id)}">#{escape(element.content)}</button>)
|
|
292
|
+
when Strong
|
|
293
|
+
"<strong>#{escape(element.content)}</strong>"
|
|
294
|
+
when Emphasis
|
|
295
|
+
"<em>#{escape(element.content)}</em>"
|
|
296
|
+
when Stack
|
|
297
|
+
container("stack", element)
|
|
298
|
+
when Flow
|
|
299
|
+
container("flow", element)
|
|
300
|
+
when Form
|
|
301
|
+
form(element)
|
|
302
|
+
else
|
|
303
|
+
raise ArgumentError, "cannot render #{element.class}"
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def container(class_name, element)
|
|
308
|
+
content = element.children.map { |child| render_element(child) }.join
|
|
309
|
+
%(<div class="#{class_name}">#{content}</div>)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def form(element)
|
|
313
|
+
content = element.children.map { |child| render_element(child) }.join
|
|
314
|
+
%(<form class="form">#{content}</form>)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def table(element)
|
|
318
|
+
headers = element.headers.map do |header|
|
|
319
|
+
%(<th scope="col">#{escape(header)}</th>)
|
|
320
|
+
end.join
|
|
321
|
+
rows = element.rows.map do |row|
|
|
322
|
+
cells = row.map { |cell| "<td>#{render_element(cell)}</td>" }.join
|
|
323
|
+
"<tr>#{cells}</tr>"
|
|
324
|
+
end.join
|
|
325
|
+
"<table><thead><tr>#{headers}</tr></thead><tbody>#{rows}</tbody></table>"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def validation_attributes(element)
|
|
329
|
+
return "" unless element.invalid?
|
|
330
|
+
|
|
331
|
+
%( aria-invalid="true" aria-describedby="#{escape(element.error_id)}")
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def field_error(element)
|
|
335
|
+
return "" unless element.invalid?
|
|
336
|
+
|
|
337
|
+
%(<span id="#{escape(element.error_id)}" class="field-error" role="alert">#{escape(element.error)}</span>)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def document(
|
|
341
|
+
title,
|
|
342
|
+
content,
|
|
343
|
+
revision:,
|
|
344
|
+
refresh_interval:,
|
|
345
|
+
development_reload:
|
|
346
|
+
)
|
|
347
|
+
refresh_attribute =
|
|
348
|
+
if refresh_interval
|
|
349
|
+
%( data-sandals-refresh-interval="#{refresh_interval}")
|
|
350
|
+
else
|
|
351
|
+
""
|
|
352
|
+
end
|
|
353
|
+
reload_attribute =
|
|
354
|
+
development_reload ? %( data-sandals-development-reload="true") : ""
|
|
355
|
+
<<~HTML
|
|
356
|
+
<!doctype html>
|
|
357
|
+
<html lang="es">
|
|
358
|
+
<head>
|
|
359
|
+
<meta charset="utf-8">
|
|
360
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
361
|
+
<title>#{escape(title)}</title>
|
|
362
|
+
<style>#{DEFAULT_STYLES}</style>
|
|
363
|
+
<script src="#{TURBO_URL}"></script>
|
|
364
|
+
<script>#{client_script}</script>
|
|
365
|
+
</head>
|
|
366
|
+
<body>
|
|
367
|
+
<div id="sandals-errors" class="runtime-errors" aria-live="assertive"></div>
|
|
368
|
+
<main id="sandals-view" data-sandals-revision="#{revision}"#{refresh_attribute}#{reload_attribute}>#{content}</main>
|
|
369
|
+
</body>
|
|
370
|
+
</html>
|
|
371
|
+
HTML
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def client_script
|
|
375
|
+
<<~JAVASCRIPT
|
|
376
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
377
|
+
let active = false
|
|
378
|
+
let revision = Number(
|
|
379
|
+
document.querySelector("#sandals-view").dataset.sandalsRevision
|
|
380
|
+
)
|
|
381
|
+
const refreshInterval = Number(
|
|
382
|
+
document.querySelector("#sandals-view").dataset.sandalsRefreshInterval || 0
|
|
383
|
+
)
|
|
384
|
+
const developmentReload =
|
|
385
|
+
document.querySelector("#sandals-view").dataset.sandalsDevelopmentReload === "true"
|
|
386
|
+
let codeVersion
|
|
387
|
+
let checkingCode = false
|
|
388
|
+
let refreshTimer
|
|
389
|
+
const pending = []
|
|
390
|
+
const pendingButtons = new Map()
|
|
391
|
+
|
|
392
|
+
function markButtonPending(button) {
|
|
393
|
+
const target = button.dataset.sandalsTarget
|
|
394
|
+
|
|
395
|
+
pendingButtons.set(target, button)
|
|
396
|
+
button.dataset.sandalsPending = "true"
|
|
397
|
+
button.disabled = true
|
|
398
|
+
button.setAttribute("aria-busy", "true")
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function releasePendingButtons(events) {
|
|
402
|
+
for (const event of events) {
|
|
403
|
+
if (!["click", "submit"].includes(event.event)) continue
|
|
404
|
+
|
|
405
|
+
const button = pendingButtons.get(event.target)
|
|
406
|
+
if (!button) continue
|
|
407
|
+
|
|
408
|
+
button.disabled = false
|
|
409
|
+
button.removeAttribute("aria-busy")
|
|
410
|
+
delete button.dataset.sandalsPending
|
|
411
|
+
pendingButtons.delete(event.target)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function showCodeError(error) {
|
|
416
|
+
const region = document.querySelector("#sandals-errors")
|
|
417
|
+
const section = document.createElement("section")
|
|
418
|
+
const title = document.createElement("strong")
|
|
419
|
+
const message = document.createElement("p")
|
|
420
|
+
const location = document.createElement("code")
|
|
421
|
+
|
|
422
|
+
section.className = "action-error"
|
|
423
|
+
section.setAttribute("role", "alert")
|
|
424
|
+
title.textContent = "Could not reload application"
|
|
425
|
+
message.textContent = `${error.class}: ${error.message}`
|
|
426
|
+
location.textContent = error.location
|
|
427
|
+
section.append(title, message, location)
|
|
428
|
+
region.replaceChildren(section)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function checkForCodeChanges() {
|
|
432
|
+
if (!developmentReload || document.hidden || checkingCode) return
|
|
433
|
+
|
|
434
|
+
checkingCode = true
|
|
435
|
+
try {
|
|
436
|
+
const response = await fetch("/__sandals/reload", {
|
|
437
|
+
headers: { "Accept": "application/json" }
|
|
438
|
+
})
|
|
439
|
+
if (!response.ok) return
|
|
440
|
+
|
|
441
|
+
const result = await response.json()
|
|
442
|
+
if (codeVersion === undefined) {
|
|
443
|
+
codeVersion = result.version
|
|
444
|
+
} else if (!result.error && result.version !== codeVersion) {
|
|
445
|
+
window.location.reload()
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (result.error) showCodeError(result.error)
|
|
450
|
+
} finally {
|
|
451
|
+
checkingCode = false
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function enqueue(message) {
|
|
456
|
+
scheduleRefresh()
|
|
457
|
+
const last = pending.at(-1)
|
|
458
|
+
|
|
459
|
+
if (
|
|
460
|
+
message.event === "change" &&
|
|
461
|
+
last?.revision === revision &&
|
|
462
|
+
last?.event === "change" &&
|
|
463
|
+
last?.target === message.target
|
|
464
|
+
) {
|
|
465
|
+
last.value = message.value
|
|
466
|
+
} else {
|
|
467
|
+
pending.push({ revision, ...message })
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
drain()
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function scheduleRefresh() {
|
|
474
|
+
clearTimeout(refreshTimer)
|
|
475
|
+
if (!refreshInterval || document.hidden) return
|
|
476
|
+
|
|
477
|
+
refreshTimer = setTimeout(refreshView, refreshInterval * 1000)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function refreshView() {
|
|
481
|
+
clearTimeout(refreshTimer)
|
|
482
|
+
if (!refreshInterval || document.hidden) return
|
|
483
|
+
if (active || pending.length > 0) {
|
|
484
|
+
scheduleRefresh()
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
active = true
|
|
489
|
+
const refreshRevision = revision
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
const response = await fetch("/events", {
|
|
493
|
+
method: "POST",
|
|
494
|
+
headers: {
|
|
495
|
+
"Accept": "text/vnd.turbo-stream.html",
|
|
496
|
+
"Content-Type": "application/json"
|
|
497
|
+
},
|
|
498
|
+
body: JSON.stringify({
|
|
499
|
+
revision: refreshRevision,
|
|
500
|
+
oldest_pending_revision: refreshRevision,
|
|
501
|
+
events: []
|
|
502
|
+
})
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
const body = await response.text()
|
|
506
|
+
const contentType = response.headers.get("Content-Type") || ""
|
|
507
|
+
|
|
508
|
+
if (contentType.includes("text/vnd.turbo-stream.html")) {
|
|
509
|
+
document.body.insertAdjacentHTML("beforeend", body)
|
|
510
|
+
if (response.ok) {
|
|
511
|
+
revision = Number(response.headers.get("X-Sandals-Revision"))
|
|
512
|
+
}
|
|
513
|
+
} else if (!response.ok) {
|
|
514
|
+
throw new Error(body)
|
|
515
|
+
}
|
|
516
|
+
} finally {
|
|
517
|
+
active = false
|
|
518
|
+
drain()
|
|
519
|
+
scheduleRefresh()
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function drain() {
|
|
524
|
+
if (active || pending.length === 0) return
|
|
525
|
+
|
|
526
|
+
active = true
|
|
527
|
+
const batchRevision = pending[0].revision
|
|
528
|
+
const events = []
|
|
529
|
+
let upload
|
|
530
|
+
|
|
531
|
+
if (pending[0].event === "upload") {
|
|
532
|
+
upload = pending.shift()
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
while (!upload && pending[0]?.revision === batchRevision) {
|
|
536
|
+
const { revision: _, ...event } = pending.shift()
|
|
537
|
+
events.push(event)
|
|
538
|
+
if (["click", "submit"].includes(event.event)) break
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
let response
|
|
543
|
+
|
|
544
|
+
if (upload) {
|
|
545
|
+
const formData = new FormData()
|
|
546
|
+
formData.append("revision", batchRevision)
|
|
547
|
+
formData.append("oldest_pending_revision", batchRevision)
|
|
548
|
+
formData.append("target", upload.target)
|
|
549
|
+
formData.append("file", upload.file)
|
|
550
|
+
response = await fetch("/files", {
|
|
551
|
+
method: "POST",
|
|
552
|
+
headers: { "Accept": "text/vnd.turbo-stream.html" },
|
|
553
|
+
body: formData
|
|
554
|
+
})
|
|
555
|
+
} else {
|
|
556
|
+
response = await fetch("/events", {
|
|
557
|
+
method: "POST",
|
|
558
|
+
headers: {
|
|
559
|
+
"Accept": "text/vnd.turbo-stream.html",
|
|
560
|
+
"Content-Type": "application/json"
|
|
561
|
+
},
|
|
562
|
+
body: JSON.stringify({
|
|
563
|
+
revision: batchRevision,
|
|
564
|
+
oldest_pending_revision: batchRevision,
|
|
565
|
+
events
|
|
566
|
+
})
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const body = await response.text()
|
|
571
|
+
const contentType = response.headers.get("Content-Type") || ""
|
|
572
|
+
|
|
573
|
+
if (contentType.includes("text/vnd.turbo-stream.html")) {
|
|
574
|
+
document.body.insertAdjacentHTML("beforeend", body)
|
|
575
|
+
if (response.ok) {
|
|
576
|
+
revision = Number(response.headers.get("X-Sandals-Revision"))
|
|
577
|
+
}
|
|
578
|
+
} else if (!response.ok) {
|
|
579
|
+
throw new Error(body)
|
|
580
|
+
}
|
|
581
|
+
} finally {
|
|
582
|
+
releasePendingButtons(events)
|
|
583
|
+
active = false
|
|
584
|
+
drain()
|
|
585
|
+
scheduleRefresh()
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
document.addEventListener("input", (event) => {
|
|
590
|
+
const field = event.target.closest(
|
|
591
|
+
"input[data-sandals-target], textarea[data-sandals-target], " +
|
|
592
|
+
"select[data-sandals-target]"
|
|
593
|
+
)
|
|
594
|
+
if (!field) return
|
|
595
|
+
|
|
596
|
+
if (field.type === "file") {
|
|
597
|
+
const file = field.files[0]
|
|
598
|
+
if (!file) return
|
|
599
|
+
|
|
600
|
+
enqueue({
|
|
601
|
+
target: field.dataset.sandalsTarget,
|
|
602
|
+
event: "upload",
|
|
603
|
+
file
|
|
604
|
+
})
|
|
605
|
+
return
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
enqueue({
|
|
609
|
+
target: field.dataset.sandalsTarget,
|
|
610
|
+
event: "change",
|
|
611
|
+
value: field.type === "checkbox" ? field.checked : field.value
|
|
612
|
+
})
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
document.addEventListener("click", (event) => {
|
|
616
|
+
const button = event.target.closest("button[data-sandals-target]")
|
|
617
|
+
if (!button) return
|
|
618
|
+
if (button.type === "submit") return
|
|
619
|
+
if (button.disabled || button.dataset.sandalsPending) return
|
|
620
|
+
|
|
621
|
+
markButtonPending(button)
|
|
622
|
+
enqueue({
|
|
623
|
+
target: button.dataset.sandalsTarget,
|
|
624
|
+
event: "click"
|
|
625
|
+
})
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
document.addEventListener("keydown", (event) => {
|
|
629
|
+
if (event.key !== "Enter") return
|
|
630
|
+
if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return
|
|
631
|
+
|
|
632
|
+
const field = event.target.closest(
|
|
633
|
+
'form input:not([type="checkbox"]):not([type="radio"])'
|
|
634
|
+
)
|
|
635
|
+
if (!field) return
|
|
636
|
+
|
|
637
|
+
const form = field.closest("form")
|
|
638
|
+
const button =
|
|
639
|
+
form.querySelector('button[type="submit"][data-sandals-target]')
|
|
640
|
+
if (!button || button.disabled || button.dataset.sandalsPending) return
|
|
641
|
+
|
|
642
|
+
event.preventDefault()
|
|
643
|
+
form.requestSubmit(button)
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
document.addEventListener("submit", (event) => {
|
|
647
|
+
const form = event.target.closest("form")
|
|
648
|
+
if (!form) return
|
|
649
|
+
|
|
650
|
+
event.preventDefault()
|
|
651
|
+
const button =
|
|
652
|
+
event.submitter ||
|
|
653
|
+
form.querySelector('button[type="submit"][data-sandals-target]')
|
|
654
|
+
if (!button) return
|
|
655
|
+
if (button.disabled || button.dataset.sandalsPending) return
|
|
656
|
+
|
|
657
|
+
markButtonPending(button)
|
|
658
|
+
enqueue({
|
|
659
|
+
target: button.dataset.sandalsTarget,
|
|
660
|
+
event: "submit"
|
|
661
|
+
})
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
document.addEventListener("visibilitychange", () => {
|
|
665
|
+
clearTimeout(refreshTimer)
|
|
666
|
+
if (!document.hidden) {
|
|
667
|
+
refreshView()
|
|
668
|
+
checkForCodeChanges()
|
|
669
|
+
}
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
scheduleRefresh()
|
|
673
|
+
if (developmentReload) {
|
|
674
|
+
checkForCodeChanges()
|
|
675
|
+
setInterval(checkForCodeChanges, 1000)
|
|
676
|
+
}
|
|
677
|
+
})
|
|
678
|
+
JAVASCRIPT
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def escape(value)
|
|
682
|
+
CGI.escapeHTML(value.to_s)
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
end
|
|
686
|
+
end
|