studio-engine 0.96.2.rc1 → 0.97.0.rc1
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 +4 -4
- data/CHANGELOG.md +183 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +57 -0
- data/app/assets/tailwind/studio_engine/engine.css +8 -0
- data/app/javascript/studio/alpine_shims.js +23 -0
- data/app/javascript/studio/application.js +9 -21
- data/app/javascript/studio/board.js +593 -0
- data/app/javascript/studio/booking.js +176 -0
- data/app/javascript/studio/controllers/board_controller.js +41 -0
- data/app/javascript/studio/controllers/booking_controller.js +24 -0
- data/app/javascript/studio/controllers/email_banner_scale_controller.js +22 -0
- data/app/javascript/studio/controllers/footer_map_controller.js +22 -0
- data/app/javascript/studio/controllers/geo_settings_controller.js +24 -0
- data/app/javascript/studio/controllers/hold_button_controller.js +64 -0
- data/app/javascript/studio/controllers/link_preview_card_controller.js +25 -0
- data/app/javascript/studio/controllers/link_sidebar_controller.js +18 -0
- data/app/javascript/studio/controllers/survey_controller.js +18 -0
- data/app/javascript/studio/controllers/toast_controller.js +26 -0
- data/app/javascript/studio/email_banner.js +277 -0
- data/app/javascript/studio/email_banner_scale.js +60 -0
- data/app/javascript/studio/footer_map.js +143 -0
- data/app/javascript/studio/geo_settings.js +105 -0
- data/app/javascript/studio/hold_button.js +416 -0
- data/app/javascript/studio/hold_button_hooks.js +162 -0
- data/app/javascript/studio/lazy_controllers.js +138 -0
- data/app/javascript/studio/leveling_activity.js +252 -0
- data/app/javascript/studio/link_preview_card.js +34 -0
- data/app/javascript/studio/link_sidebar.js +157 -0
- data/app/javascript/studio/stimulus.js +72 -0
- data/app/javascript/studio/survey.js +316 -0
- data/app/javascript/studio/theme_editor.js +197 -0
- data/app/javascript/studio/toast.js +270 -0
- data/app/views/components/_link_sidebar.html.erb +5 -100
- data/app/views/components/_link_sidebar_trigger.html.erb +2 -2
- data/app/views/layouts/studio/_flash.html.erb +13 -118
- data/app/views/layouts/studio/_head.html.erb +30 -14
- data/app/views/studio/_board_assets.html.erb +9 -452
- data/app/views/studio/_hold_button.html.erb +38 -445
- data/app/views/studio/_hold_button_guard.html.erb +172 -0
- data/app/views/studio/_leveling_activity_assets.html.erb +9 -283
- data/app/views/studio/board/_board.html.erb +16 -5
- data/app/views/studio/booking/_assets.html.erb +5 -139
- data/app/views/studio/booking/_frame.html.erb +3 -3
- data/app/views/studio/booking/_popup.html.erb +4 -4
- data/app/views/studio/emails/_banner_editor.html.erb +4 -152
- data/app/views/studio/emails/_banner_scale.html.erb +5 -56
- data/app/views/studio/emails/index.html.erb +0 -1
- data/app/views/studio/geo_settings/edit.html.erb +1 -97
- data/app/views/studio/site_footer/_map.html.erb +7 -5
- data/app/views/studio/site_footer/_map_assets.html.erb +5 -129
- data/app/views/studio/site_identities/edit.html.erb +1 -24
- data/app/views/studio/surveys/show.html.erb +4 -5
- data/app/views/style/_tasks.html.erb +3 -8
- data/app/views/style/_theme.html.erb +2 -52
- data/app/views/theme_settings/edit.html.erb +3 -82
- data/config/importmap.rb +6 -0
- data/docs/BOOKING.md +6 -5
- data/docs/SITE_FOOTER.md +16 -12
- data/lib/studio/version.rb +1 -1
- metadata +28 -3
- data/app/views/studio/emails/_recipient_repaint.html.erb +0 -122
- data/app/views/studio/surveys/_script.html.erb +0 -309
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// studio/email_banner: the two Alpine factories behind the email manager.
|
|
2
|
+
//
|
|
3
|
+
// emailBannerEditor(config) studio/emails/show: the live banner editor.
|
|
4
|
+
// Typing repaints the banner, and ONE Save button
|
|
5
|
+
// reports whether there is anything to save.
|
|
6
|
+
// emailRecipients(config) studio/emails/index: repaints EVERY row when
|
|
7
|
+
// the example recipient changes.
|
|
8
|
+
//
|
|
9
|
+
// studio/alpine_shims publishes both before Alpine starts, because
|
|
10
|
+
// x-data="emailBannerEditor(...)" is evaluated the moment Alpine does.
|
|
11
|
+
//
|
|
12
|
+
// WHY THE PREVIEW IS PAINTED IN THE BROWSER AND NOT RE-RENDERED. The banner is
|
|
13
|
+
// already server-rendered by the mailer's own partial, which is what makes it
|
|
14
|
+
// trustworthy. The editor does not replace it: it edits the text nodes, the
|
|
15
|
+
// logo and the tint of that same rendered banner in place. The shape, the
|
|
16
|
+
// typography and the scaling stay the server's. A second banner rendered in
|
|
17
|
+
// JavaScript would be a second implementation of the email, and it would drift.
|
|
18
|
+
//
|
|
19
|
+
// WHY THE PLACEHOLDER RULE IS REPEATED HERE. Studio::Banner fills {name} and
|
|
20
|
+
// {app} server-side. Repeating that in script is a real duplication: the
|
|
21
|
+
// alternative is a round trip per keystroke. The rule is small and pinned on
|
|
22
|
+
// both sides: banner_copy_test.rb for Ruby, test/javascript/email_banner.test.mjs
|
|
23
|
+
// and e2e/email_banner_editor.spec.js for the browser. If they ever disagree,
|
|
24
|
+
// the server is right.
|
|
25
|
+
//
|
|
26
|
+
// It imports nothing; the Node test loads it as a data: module.
|
|
27
|
+
|
|
28
|
+
// The first word of a recipient's name, or "" when there is none.
|
|
29
|
+
export function firstNameOf(name) {
|
|
30
|
+
return (name || "").trim().split(/\s+/)[0] || ""
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A banner header for a recipient. {app} is substituted on both paths because
|
|
34
|
+
// it does not depend on the recipient: a header reading "Welcome to {app}" must
|
|
35
|
+
// not show a raw brace here while the inbox shows the app's name. With no first
|
|
36
|
+
// name, a template that greets by name gives way to its fallback.
|
|
37
|
+
export function resolveBannerText(template, fallback, first, app) {
|
|
38
|
+
const text = (template || "").replace(/\{app\}/g, app || "")
|
|
39
|
+
if (first) return text.replace(/\{name\}/g, first)
|
|
40
|
+
if (text.indexOf("{name}") !== -1) return (fallback || "").replace(/\{app\}/g, app || "")
|
|
41
|
+
return text
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A subject line for a recipient. A subject has no second field for the
|
|
45
|
+
// nameless case, so an unresolved placeholder is removed along with the
|
|
46
|
+
// punctuation holding it: the same two passes Studio::Banner.interpolate makes,
|
|
47
|
+
// so "Sign in, {name}" reads "Sign in" here exactly as it would in an inbox.
|
|
48
|
+
export function resolveSubjectText(template, first, app) {
|
|
49
|
+
const text = (template || "").replace(/\{app\}/g, app || "")
|
|
50
|
+
if (first) return text.replace(/\{name\}/g, first)
|
|
51
|
+
return text.replace(/[,;:—-]?\s*\{name\}/g, "")
|
|
52
|
+
.replace(/^\s*[,;:—-]\s*/, "")
|
|
53
|
+
.replace(/\s+/g, " ")
|
|
54
|
+
.trim()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The tint as a whole percentage from 0 to 100; an unreadable value is the
|
|
58
|
+
// default.
|
|
59
|
+
export function scrimPercent(value, fallback) {
|
|
60
|
+
let percent = parseInt(value, 10)
|
|
61
|
+
if (isNaN(percent)) percent = fallback
|
|
62
|
+
return Math.min(Math.max(percent, 0), 100)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Whether any field of `form` differs from `initial`. Field by field and not
|
|
66
|
+
// JSON.stringify, whose output depends on key order: two equal objects can
|
|
67
|
+
// stringify differently.
|
|
68
|
+
//
|
|
69
|
+
// No type coercion, deliberately. The payload sends real booleans for the two
|
|
70
|
+
// checkboxes and strings for everything else, and a mutation run showed that
|
|
71
|
+
// normalising them changed nothing. Unproven defensive code in a comparison
|
|
72
|
+
// that decides whether a Save button appears is worse than none.
|
|
73
|
+
export function formDirty(form, initial) {
|
|
74
|
+
return Object.keys(initial).some((key) => (form[key] ?? "") !== (initial[key] ?? ""))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The logo has THREE states, and a URL field alone can only express two.
|
|
78
|
+
// "standard" inherits, "custom" uses what this app uploaded, "hidden" is the
|
|
79
|
+
// deliberate no-logo answer that blank cannot say.
|
|
80
|
+
export function logoMode(hideLogo, uploadedLogo) {
|
|
81
|
+
if (hideLogo === true || hideLogo === "1") return "hidden"
|
|
82
|
+
if (uploadedLogo) return "custom"
|
|
83
|
+
return "standard"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const FRAMED_PREVIEW = "iframe[data-email-banner-preview]"
|
|
87
|
+
|
|
88
|
+
// A node of the banner rendered inside `scope`. The banner lives in an IFRAME
|
|
89
|
+
// wherever studio/emails/_banner_preview isolates it (its default): the email's
|
|
90
|
+
// table is its own document, so it cannot nest rows inside the list. The frame
|
|
91
|
+
// is a same-origin srcdoc, so its nodes are reachable; contentDocument is null
|
|
92
|
+
// until the frame has parsed, which is why whenFrameLoads exists.
|
|
93
|
+
export function bannerNodeIn(scope, selector) {
|
|
94
|
+
const frame = scope.querySelector(FRAMED_PREVIEW)
|
|
95
|
+
if (frame) {
|
|
96
|
+
try { return frame.contentDocument && frame.contentDocument.querySelector(selector) }
|
|
97
|
+
catch (_) { return null }
|
|
98
|
+
}
|
|
99
|
+
return scope.querySelector(selector)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Runs `repaint` each time the banner's frame in `scope` loads. Bound once per
|
|
103
|
+
// frame, however often it is asked.
|
|
104
|
+
export function whenFrameLoads(scope, repaint) {
|
|
105
|
+
const frame = scope.querySelector(FRAMED_PREVIEW)
|
|
106
|
+
if (!frame || frame.dataset.repaintBound) return
|
|
107
|
+
frame.dataset.repaintBound = "1"
|
|
108
|
+
frame.addEventListener("load", repaint)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// x-data="emailBannerEditor({...})".
|
|
112
|
+
//
|
|
113
|
+
// TOUCHED VS DIRTY: different questions, and the button answers both. `touched`
|
|
114
|
+
// is "has anyone typed here", and it decides whether the button is SHOWN.
|
|
115
|
+
// `dirty` is "is anything actually different from what is saved", and it
|
|
116
|
+
// decides whether the button is ENABLED. Typing "A" into the subject and
|
|
117
|
+
// deleting it again leaves touched true and dirty false: the button stays
|
|
118
|
+
// visible and goes back to disabled, which tells the operator their edit was
|
|
119
|
+
// undone and not silently swallowed.
|
|
120
|
+
export function emailBannerEditor(config) {
|
|
121
|
+
return {
|
|
122
|
+
form: Object.assign({}, config.values),
|
|
123
|
+
initial: Object.assign({}, config.values),
|
|
124
|
+
targets: config.targets || [],
|
|
125
|
+
targetId: config.targetId,
|
|
126
|
+
touched: false,
|
|
127
|
+
pickerOpen: false,
|
|
128
|
+
|
|
129
|
+
init() {
|
|
130
|
+
this.paint()
|
|
131
|
+
this.$watch("form", () => { this.touched = true; this.paint() })
|
|
132
|
+
this.$watch("targetId", () => this.paint())
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
target() {
|
|
136
|
+
return this.targets.find((t) => t.id === this.targetId) || this.targets[0] || {}
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
firstName() {
|
|
140
|
+
return firstNameOf(this.target().name)
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
resolve(template, fallback) {
|
|
144
|
+
return resolveBannerText(template, fallback, this.firstName(), config.appName)
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
headerText() { return this.resolve(this.form.header, this.form.header_fallback) },
|
|
148
|
+
subjectText() { return this.resolve(this.form.subject, this.form.subject) },
|
|
149
|
+
|
|
150
|
+
dirty() {
|
|
151
|
+
return formDirty(this.form, this.initial)
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
logoMode() {
|
|
155
|
+
return logoMode(this.form.hide_logo, config.uploadedLogo)
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
setLogoMode(mode) {
|
|
159
|
+
this.form.hide_logo = mode === "hidden"
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
currentLogo() {
|
|
163
|
+
if (this.logoMode() === "hidden") return ""
|
|
164
|
+
return config.uploadedLogo || config.inheritedLogo || ""
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
// The slider's own fill, as a percentage string for --range-fill.
|
|
168
|
+
scrimFill() {
|
|
169
|
+
return scrimPercent(this.form.scrim_percent, config.defaultScrim) + "%"
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
scrimCss() {
|
|
173
|
+
return "rgba(24,16,64," + (scrimPercent(this.form.scrim_percent, config.defaultScrim) / 100) + ")"
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
paint() {
|
|
177
|
+
const root = this.$root
|
|
178
|
+
const header = bannerNodeIn(root, "[data-banner-header]")
|
|
179
|
+
const subtext = bannerNodeIn(root, "[data-banner-subtext]")
|
|
180
|
+
const logo = bannerNodeIn(root, "[data-banner-logo]")
|
|
181
|
+
const scrim = bannerNodeIn(root, "[data-banner-scrim]")
|
|
182
|
+
whenFrameLoads(root, () => this.paint())
|
|
183
|
+
|
|
184
|
+
if (header) header.textContent = this.headerText()
|
|
185
|
+
if (subtext) subtext.textContent = this.form.subtext || ""
|
|
186
|
+
if (scrim) scrim.style.backgroundColor = this.scrimCss()
|
|
187
|
+
if (logo) {
|
|
188
|
+
const url = this.currentLogo()
|
|
189
|
+
if (url) {
|
|
190
|
+
if (logo.getAttribute("src") !== url) logo.setAttribute("src", url)
|
|
191
|
+
logo.style.display = "block"
|
|
192
|
+
} else {
|
|
193
|
+
logo.style.display = "none"
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
// Every visible field is x-model bound and carries no name; the hidden
|
|
199
|
+
// form mirrors them. One submit writes the whole page.
|
|
200
|
+
save() {
|
|
201
|
+
if (!this.dirty()) return
|
|
202
|
+
this.$refs.saveForm.requestSubmit()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// x-data="emailRecipients({...})".
|
|
208
|
+
//
|
|
209
|
+
// The list shows each email's banner and subject as they would arrive. Both are
|
|
210
|
+
// per-recipient (the banner greets by first name and the subject may too), so a
|
|
211
|
+
// list rendered for one person is a list of half-truths about everyone else.
|
|
212
|
+
//
|
|
213
|
+
// WHY THE TEMPLATES RIDE ON THE ROW. Each row carries its own header, fallback
|
|
214
|
+
// and subject templates as data attributes, read from the DOM. A second JSON
|
|
215
|
+
// payload listing every email would have to be kept in step with the rows, and
|
|
216
|
+
// a row whose payload entry went missing would silently stop repainting while
|
|
217
|
+
// still looking correct for whoever was selected at page load.
|
|
218
|
+
export function emailRecipients(config) {
|
|
219
|
+
return {
|
|
220
|
+
targets: config.targets || [],
|
|
221
|
+
targetId: config.targetId,
|
|
222
|
+
pickerOpen: false,
|
|
223
|
+
|
|
224
|
+
init() {
|
|
225
|
+
this.paint()
|
|
226
|
+
this.$watch("targetId", () => this.paint())
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
target() {
|
|
230
|
+
return this.targets.find((t) => t.id === this.targetId) || this.targets[0] || {}
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
firstName() {
|
|
234
|
+
return firstNameOf(this.target().name)
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
resolve(template, fallback) {
|
|
238
|
+
return resolveBannerText(template, fallback, this.firstName(), config.appName)
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
resolveSubject(template) {
|
|
242
|
+
return resolveSubjectText(template, this.firstName(), config.appName)
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
bannerNode(row, selector) {
|
|
246
|
+
return bannerNodeIn(row, selector)
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
paintRow(row) {
|
|
250
|
+
const header = this.bannerNode(row, "[data-banner-header]")
|
|
251
|
+
const subject = row.querySelector("[data-row-subject]")
|
|
252
|
+
if (header) {
|
|
253
|
+
header.textContent = this.resolve(row.dataset.header, row.dataset.headerFallback)
|
|
254
|
+
}
|
|
255
|
+
if (subject) subject.textContent = this.resolveSubject(row.dataset.subject)
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
paint() {
|
|
259
|
+
this.$root.querySelectorAll("[data-email-row]").forEach((row) => {
|
|
260
|
+
this.paintRow(row)
|
|
261
|
+
whenFrameLoads(row, () => { this.paintRow(row) })
|
|
262
|
+
})
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
// Anything interactive keeps its own behaviour; everything else opens the
|
|
266
|
+
// email. Without the closest() guard, clicking Upload would both open the
|
|
267
|
+
// cropper and navigate away from it.
|
|
268
|
+
openRow(event, row) {
|
|
269
|
+
// The listener sits on the tbody, so a click can land between rows and
|
|
270
|
+
// arrive with no row at all.
|
|
271
|
+
if (!row) return
|
|
272
|
+
if (event.target.closest("a, button, input, label, form")) return
|
|
273
|
+
const path = row.dataset.emailPath
|
|
274
|
+
if (path) window.location = path
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// studio/email_banner_scale: scales the 600px email banner down to whatever
|
|
2
|
+
// width its frame has.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS NEEDS SCRIPT AT ALL. The banner is the EMAIL's own markup, a fixed
|
|
5
|
+
// 600px table, and it must stay that way: an inbox has no responsive layout, so
|
|
6
|
+
// making it fluid would mean previewing markup no one receives. The frame
|
|
7
|
+
// beside it is fluid. Nothing in CSS converts a fixed-width box into a fluid
|
|
8
|
+
// one without knowing the container's width, so the width is read and a
|
|
9
|
+
// transform applied.
|
|
10
|
+
//
|
|
11
|
+
// WHAT IT DOES NOT DO: move anything. It sets a scale on a box whose height is
|
|
12
|
+
// already reserved by `aspect-ratio`, so no reflow follows and there is no
|
|
13
|
+
// paint-time jump.
|
|
14
|
+
//
|
|
15
|
+
// Without it the preview is unscaled and clipped, so a failure here costs the
|
|
16
|
+
// improvement and not the page.
|
|
17
|
+
//
|
|
18
|
+
// It imports nothing; test/javascript/email_banner_scale.test.mjs loads it as a
|
|
19
|
+
// data: module. Started by the email-banner-scale controller.
|
|
20
|
+
|
|
21
|
+
export const PREVIEWS = "[data-email-banner-preview]"
|
|
22
|
+
export const FRAMES = "[data-email-banner-frame]"
|
|
23
|
+
export const NATURAL_WIDTH = 600
|
|
24
|
+
|
|
25
|
+
// The scale that fits a `natural`-wide banner into `available` pixels. Never
|
|
26
|
+
// above 1: at 1:1 the preview is exactly the 600px the recipient sees, and past
|
|
27
|
+
// that it would show the operator a banner larger than any inbox renders.
|
|
28
|
+
// Answers null when the frame has no width to fit.
|
|
29
|
+
export function bannerScale(natural, available) {
|
|
30
|
+
if (!available) return null
|
|
31
|
+
return Math.min(available / (natural || NATURAL_WIDTH), 1)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Scales one preview to its frame (its parent). Answers the scale it set, or
|
|
35
|
+
// null when it set none.
|
|
36
|
+
export function fitBanner(preview) {
|
|
37
|
+
const frame = preview.parentElement
|
|
38
|
+
if (!frame) return null
|
|
39
|
+
const scale = bannerScale(parseFloat(preview.dataset.bannerWidth), frame.clientWidth)
|
|
40
|
+
if (scale === null) return null
|
|
41
|
+
preview.style.transform = "scale(" + scale + ")"
|
|
42
|
+
return scale
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function fitAllBanners(doc) {
|
|
46
|
+
doc.querySelectorAll(PREVIEWS).forEach(fitBanner)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Fits every preview in `doc` now and whenever a frame changes width. A
|
|
50
|
+
// ResizeObserver and not a resize listener: a frame changes width when a
|
|
51
|
+
// sidebar opens or the grid reflows, and neither resizes the window. Answers
|
|
52
|
+
// the function that stops watching.
|
|
53
|
+
export function startBannerScale(doc, Observer) {
|
|
54
|
+
fitAllBanners(doc)
|
|
55
|
+
if (typeof Observer !== "function") return () => {}
|
|
56
|
+
|
|
57
|
+
const observer = new Observer(() => fitAllBanners(doc))
|
|
58
|
+
doc.querySelectorAll(FRAMES).forEach((frame) => observer.observe(frame))
|
|
59
|
+
return () => observer.disconnect()
|
|
60
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// studio/footer_map: mounts a Leaflet map on every [data-footer-map]
|
|
2
|
+
// (docs/SITE_FOOTER.md). Leaflet (script and stylesheet) is fetched once, on
|
|
3
|
+
// demand, from the URLs the element carries, so a page with no map pays nothing
|
|
4
|
+
// for it. The basemap follows the site theme in CSS (studio/site_footer/
|
|
5
|
+
// _map_assets). Tiles are OpenStreetMap's keyless ones; a basemap that needs an
|
|
6
|
+
// API key does not belong in a gem.
|
|
7
|
+
//
|
|
8
|
+
// NOTHING STARTS BEFORE THE WINDOW'S `load`, AND NOTHING UNTIL THE MAP IS NEAR
|
|
9
|
+
// THE VIEWPORT. The tiles are a third party's, and a request that starts before
|
|
10
|
+
// `load` holds that event open for as long as the third party takes to answer.
|
|
11
|
+
// The map is also at the bottom of every public page, where most visits never
|
|
12
|
+
// reach: those pay for neither Leaflet nor a single tile.
|
|
13
|
+
//
|
|
14
|
+
// THE GUARD IS THE ENGINE'S OWN NAME, AND THE ELEMENTS ARE THE ENGINE'S OWN. An
|
|
15
|
+
// app that still renders a local footer script guards it with the unprefixed
|
|
16
|
+
// name and marks its map [data-footer-map] too. Sharing either would let
|
|
17
|
+
// whichever script ran first in a Turbo session stand the other down and then
|
|
18
|
+
// fail on its elements: that app's map carries no data-leaflet-js, so there is
|
|
19
|
+
// nothing here to fetch.
|
|
20
|
+
//
|
|
21
|
+
// It imports nothing; test/javascript/footer_map.test.mjs loads it as a data:
|
|
22
|
+
// module. Started by the footer-map controller.
|
|
23
|
+
|
|
24
|
+
export const MAPS = "[data-footer-map][data-leaflet-js]"
|
|
25
|
+
export const TILES = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
26
|
+
export const ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
27
|
+
|
|
28
|
+
// What a map element asks for: where it is centred, how far in, and whether it
|
|
29
|
+
// shows zoom buttons. Answers null when the element carries no usable point.
|
|
30
|
+
export function mapOptions(dataset) {
|
|
31
|
+
const at = [parseFloat(dataset.lat), parseFloat(dataset.lng)]
|
|
32
|
+
if (isNaN(at[0]) || isNaN(at[1])) return null
|
|
33
|
+
return { at, zoom: parseInt(dataset.zoom, 10) || 15, zoomControl: dataset.controls !== "false" }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Installs the footer maps, once per document. Answers the function that arms
|
|
37
|
+
// the maps now on the page (after `load`), whether this call installed it or an
|
|
38
|
+
// earlier one did.
|
|
39
|
+
export function installFooterMaps(win, doc) {
|
|
40
|
+
if (win.__studioFooterMapsArmed) return win.__studioFooterMapsArm || (() => {})
|
|
41
|
+
win.__studioFooterMapsArmed = true
|
|
42
|
+
|
|
43
|
+
let maps = []
|
|
44
|
+
let loading = null
|
|
45
|
+
|
|
46
|
+
// Checked on every mount, not once: a page visit that rebuilds the head must
|
|
47
|
+
// not leave a mounted map without its stylesheet.
|
|
48
|
+
function ensureStylesheet(href) {
|
|
49
|
+
if (!href || doc.querySelector("link[data-studio-leaflet]")) return
|
|
50
|
+
const link = doc.createElement("link")
|
|
51
|
+
link.rel = "stylesheet"
|
|
52
|
+
link.href = href
|
|
53
|
+
link.setAttribute("data-studio-leaflet", "")
|
|
54
|
+
doc.head.appendChild(link)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadLeaflet(src) {
|
|
58
|
+
if (win.L) return Promise.resolve()
|
|
59
|
+
if (loading) return loading
|
|
60
|
+
loading = new Promise((resolve, reject) => {
|
|
61
|
+
const s = doc.createElement("script")
|
|
62
|
+
s.src = src
|
|
63
|
+
s.onload = resolve
|
|
64
|
+
// A failed fetch must not be remembered: the next visit tries again.
|
|
65
|
+
s.onerror = () => { loading = null; s.remove(); reject(new Error("leaflet failed to load")) }
|
|
66
|
+
doc.head.appendChild(s)
|
|
67
|
+
})
|
|
68
|
+
return loading
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mount(el) {
|
|
72
|
+
if (el.__footerMap) return
|
|
73
|
+
const options = mapOptions(el.dataset)
|
|
74
|
+
if (!options) return
|
|
75
|
+
const L = win.L
|
|
76
|
+
// NO ONE-FINGER DRAG ON A TOUCH DEVICE. The map runs edge to edge, so on a
|
|
77
|
+
// phone a swipe that starts on it would pan the map instead of scrolling
|
|
78
|
+
// the page, and there is no gutter to scroll by. With dragging off Leaflet
|
|
79
|
+
// leaves touch-action at pan-x pan-y: one finger scrolls the page, and a
|
|
80
|
+
// pinch still zooms the map (touchZoom stays on, and moves it under the
|
|
81
|
+
// fingers). The zoom buttons and the directions link do the rest.
|
|
82
|
+
const map = L.map(el, { center: options.at, zoom: options.zoom,
|
|
83
|
+
scrollWheelZoom: false, attributionControl: true,
|
|
84
|
+
dragging: !L.Browser.mobile, touchZoom: true,
|
|
85
|
+
zoomControl: options.zoomControl })
|
|
86
|
+
map.attributionControl.setPrefix(false)
|
|
87
|
+
L.tileLayer(TILES, { attribution: ATTRIBUTION, maxZoom: 19 }).addTo(map)
|
|
88
|
+
L.marker(options.at, { icon: L.divIcon({ className: "", html: '<div class="ftr-pin"></div>', iconSize: [18, 18], iconAnchor: [9, 9] }),
|
|
89
|
+
keyboard: false }).addTo(map)
|
|
90
|
+
// Page scroll stays page scroll until the visitor commits to the map.
|
|
91
|
+
map.on("click", () => { map.scrollWheelZoom.enable() })
|
|
92
|
+
map.on("mouseout", () => { map.scrollWheelZoom.disable() })
|
|
93
|
+
const fallback = el.querySelector(".ftr-map-fallback")
|
|
94
|
+
if (fallback) { el.__footerFallback = fallback; fallback.remove() }
|
|
95
|
+
el.__footerMap = map
|
|
96
|
+
maps.push(el)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function fetchAndMount(el) {
|
|
100
|
+
ensureStylesheet(el.dataset.leafletCss)
|
|
101
|
+
loadLeaflet(el.dataset.leafletJs)
|
|
102
|
+
.then(() => { if (el.isConnected) mount(el) })
|
|
103
|
+
.catch(() => { /* the fallback link stays */ })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// One observer per map: it fires once, when the map comes within 400px of
|
|
107
|
+
// the viewport, and is then dropped.
|
|
108
|
+
function watch(el) {
|
|
109
|
+
if (el.__footerMap || el.__footerWatch) return
|
|
110
|
+
if (typeof win.IntersectionObserver !== "function") { fetchAndMount(el); return }
|
|
111
|
+
const watcher = new win.IntersectionObserver((entries) => {
|
|
112
|
+
if (!entries.some((entry) => entry.isIntersecting)) return
|
|
113
|
+
watcher.disconnect()
|
|
114
|
+
el.__footerWatch = null
|
|
115
|
+
fetchAndMount(el)
|
|
116
|
+
}, { rootMargin: "400px" })
|
|
117
|
+
el.__footerWatch = watcher
|
|
118
|
+
watcher.observe(el)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function armAll() { doc.querySelectorAll(MAPS).forEach(watch) }
|
|
122
|
+
|
|
123
|
+
function armAfterLoad() {
|
|
124
|
+
if (doc.readyState === "complete") armAll()
|
|
125
|
+
else win.addEventListener("load", armAll, { once: true })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A cached Turbo snapshot would restore Leaflet's DOM without its state, so
|
|
129
|
+
// the map is taken down and its fallback link put back before the snapshot.
|
|
130
|
+
doc.addEventListener("turbo:before-cache", () => {
|
|
131
|
+
maps.forEach((el) => {
|
|
132
|
+
const fallback = el.__footerFallback
|
|
133
|
+
el.__footerMap.remove()
|
|
134
|
+
el.__footerMap = null
|
|
135
|
+
if (fallback && !el.querySelector(".ftr-map-fallback")) el.appendChild(fallback)
|
|
136
|
+
})
|
|
137
|
+
maps = []
|
|
138
|
+
})
|
|
139
|
+
doc.addEventListener("turbo:load", armAfterLoad)
|
|
140
|
+
win.__studioFooterMapsArm = armAfterLoad
|
|
141
|
+
armAfterLoad()
|
|
142
|
+
return armAfterLoad
|
|
143
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// studio/geo_settings: the live preview on the geo manager (/admin/geo). Tick
|
|
2
|
+
// your own region and the navbar badge (and the Blocked? tile) answer at once,
|
|
3
|
+
// before anything is saved, so an operator sees what a rule does to a real
|
|
4
|
+
// visitor without saving to find out.
|
|
5
|
+
//
|
|
6
|
+
// geoBlocked mirrors Studio::Geo.blocked? on the client, deliberately and
|
|
7
|
+
// narrowly: the same three rules, in the same order, with the same fail-closed
|
|
8
|
+
// narrowing. It decides nothing: the server decides again on save and on every
|
|
9
|
+
// request afterwards. A mirror can still drift, so turf-monster's browser lane
|
|
10
|
+
// asserts the painted result against a policy the server also enforces.
|
|
11
|
+
//
|
|
12
|
+
// It imports nothing; test/javascript/geo_settings.test.mjs loads it as a
|
|
13
|
+
// data: module. Bound to the page by the geo-settings controller.
|
|
14
|
+
|
|
15
|
+
export const COUNTRY_BOXES = 'input[name="geo_setting[banned_countries][]"]'
|
|
16
|
+
export const SUBDIVISION_BOXES = 'input[name="geo_setting[banned_subdivisions][]"]'
|
|
17
|
+
export const ENABLED_BOX = 'input[type="checkbox"][name="geo_setting[enabled]"]'
|
|
18
|
+
|
|
19
|
+
// Whether the policy in `rules` blocks `visitor`.
|
|
20
|
+
// rules: { enabled, countries: [], subdivisions: [] }
|
|
21
|
+
// visitor: { country, subdivision, home, failClosed }
|
|
22
|
+
export function geoBlocked(rules, visitor) {
|
|
23
|
+
if (!rules.enabled) return false
|
|
24
|
+
|
|
25
|
+
const { country, subdivision, home, failClosed } = visitor
|
|
26
|
+
if (country && rules.countries.indexOf(country) !== -1) return true
|
|
27
|
+
if (subdivision && country === home && rules.subdivisions.indexOf(subdivision) !== -1) return true
|
|
28
|
+
|
|
29
|
+
// Fail closed, narrowed exactly as the server narrows it: only a home-country
|
|
30
|
+
// visitor with NO region, and only when region rules exist.
|
|
31
|
+
if (!failClosed) return false
|
|
32
|
+
if (subdivision) return false
|
|
33
|
+
if (country !== home) return false
|
|
34
|
+
return rules.subdivisions.length > 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The visitor the page reports, from its data-geo-* attributes.
|
|
38
|
+
export function visitorOf(page) {
|
|
39
|
+
return {
|
|
40
|
+
home: page.dataset.geoHome,
|
|
41
|
+
country: page.dataset.geoCountry,
|
|
42
|
+
subdivision: page.dataset.geoSubdivision,
|
|
43
|
+
failClosed: page.dataset.geoFailClosed === "true"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function checkedValues(page, selector) {
|
|
48
|
+
return Array.from(page.querySelectorAll(selector + ":checked"), (el) => el.value)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The policy the editor's own boxes describe right now.
|
|
52
|
+
export function rulesOf(page) {
|
|
53
|
+
const enabledBox = page.querySelector(ENABLED_BOX)
|
|
54
|
+
return {
|
|
55
|
+
enabled: !!(enabledBox && enabledBox.checked),
|
|
56
|
+
countries: checkedValues(page, COUNTRY_BOXES),
|
|
57
|
+
subdivisions: checkedValues(page, SUBDIVISION_BOXES)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The summary chips are rebuilt from the editor's own squares, so they describe
|
|
62
|
+
// the policy about to be saved and not the one on disk. Each chip is built from
|
|
63
|
+
// its square's own flag, so there is no second copy of the artwork to keep in
|
|
64
|
+
// step.
|
|
65
|
+
export function refreshSummary(page, doc, kind, selector) {
|
|
66
|
+
const row = page.querySelector('[data-geo-summary="' + kind + '"]')
|
|
67
|
+
const counts = page.querySelectorAll('[data-geo-summary-count="' + kind + '"]')
|
|
68
|
+
if (!row) return
|
|
69
|
+
|
|
70
|
+
row.querySelectorAll(".geo-chip").forEach((chip) => chip.remove())
|
|
71
|
+
|
|
72
|
+
const boxes = Array.from(page.querySelectorAll(selector)).filter((box) => box.checked)
|
|
73
|
+
boxes.sort((a, b) => a.value.localeCompare(b.value))
|
|
74
|
+
|
|
75
|
+
boxes.forEach((box) => {
|
|
76
|
+
const square = box.closest("label")
|
|
77
|
+
const chip = doc.createElement("span")
|
|
78
|
+
chip.className = "geo-chip inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-mono"
|
|
79
|
+
const art = square && square.querySelector("img, span[role='img']")
|
|
80
|
+
if (art) chip.appendChild(art.cloneNode(true))
|
|
81
|
+
chip.appendChild(doc.createTextNode(box.value))
|
|
82
|
+
row.insertBefore(chip, row.querySelector(".geo-summary-empty"))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// The tab label and the summary heading carry the same number.
|
|
86
|
+
counts.forEach((el) => { el.textContent = boxes.length })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Repaints the summaries, the root's data-geo-preview and the badge that shows
|
|
90
|
+
// THIS visitor's location. A specimen badge rendered with other locals keeps
|
|
91
|
+
// whatever it was given. Answers whether the visitor is blocked.
|
|
92
|
+
export function paintGeo(page, doc) {
|
|
93
|
+
refreshSummary(page, doc, "states", SUBDIVISION_BOXES)
|
|
94
|
+
refreshSummary(page, doc, "countries", COUNTRY_BOXES)
|
|
95
|
+
|
|
96
|
+
const visitor = visitorOf(page)
|
|
97
|
+
const blocked = geoBlocked(rulesOf(page), visitor)
|
|
98
|
+
doc.documentElement.setAttribute("data-geo-preview", blocked ? "blocked" : "allowed")
|
|
99
|
+
doc.querySelectorAll("[data-geo-badge]").forEach((badge) => {
|
|
100
|
+
if (badge.dataset.country === visitor.country && (badge.dataset.subdivision || "") === (visitor.subdivision || "")) {
|
|
101
|
+
badge.setAttribute("data-blocked", blocked ? "true" : "false")
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
return blocked
|
|
105
|
+
}
|