studio-engine 0.96.2 → 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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +183 -0
  3. data/app/assets/tailwind/studio_engine/engine-motion.css +57 -0
  4. data/app/assets/tailwind/studio_engine/engine.css +8 -0
  5. data/app/javascript/studio/alpine_shims.js +23 -0
  6. data/app/javascript/studio/application.js +9 -21
  7. data/app/javascript/studio/board.js +593 -0
  8. data/app/javascript/studio/booking.js +176 -0
  9. data/app/javascript/studio/controllers/board_controller.js +41 -0
  10. data/app/javascript/studio/controllers/booking_controller.js +24 -0
  11. data/app/javascript/studio/controllers/email_banner_scale_controller.js +22 -0
  12. data/app/javascript/studio/controllers/footer_map_controller.js +22 -0
  13. data/app/javascript/studio/controllers/geo_settings_controller.js +24 -0
  14. data/app/javascript/studio/controllers/hold_button_controller.js +64 -0
  15. data/app/javascript/studio/controllers/link_preview_card_controller.js +25 -0
  16. data/app/javascript/studio/controllers/link_sidebar_controller.js +18 -0
  17. data/app/javascript/studio/controllers/survey_controller.js +18 -0
  18. data/app/javascript/studio/controllers/toast_controller.js +26 -0
  19. data/app/javascript/studio/email_banner.js +277 -0
  20. data/app/javascript/studio/email_banner_scale.js +60 -0
  21. data/app/javascript/studio/footer_map.js +143 -0
  22. data/app/javascript/studio/geo_settings.js +105 -0
  23. data/app/javascript/studio/hold_button.js +416 -0
  24. data/app/javascript/studio/hold_button_hooks.js +162 -0
  25. data/app/javascript/studio/lazy_controllers.js +138 -0
  26. data/app/javascript/studio/leveling_activity.js +252 -0
  27. data/app/javascript/studio/link_preview_card.js +34 -0
  28. data/app/javascript/studio/link_sidebar.js +157 -0
  29. data/app/javascript/studio/stimulus.js +72 -0
  30. data/app/javascript/studio/survey.js +316 -0
  31. data/app/javascript/studio/theme_editor.js +197 -0
  32. data/app/javascript/studio/toast.js +270 -0
  33. data/app/views/components/_link_sidebar.html.erb +5 -100
  34. data/app/views/components/_link_sidebar_trigger.html.erb +2 -2
  35. data/app/views/layouts/studio/_flash.html.erb +13 -118
  36. data/app/views/layouts/studio/_head.html.erb +30 -14
  37. data/app/views/studio/_board_assets.html.erb +9 -452
  38. data/app/views/studio/_hold_button.html.erb +38 -445
  39. data/app/views/studio/_hold_button_guard.html.erb +172 -0
  40. data/app/views/studio/_leveling_activity_assets.html.erb +9 -283
  41. data/app/views/studio/board/_board.html.erb +16 -5
  42. data/app/views/studio/booking/_assets.html.erb +5 -139
  43. data/app/views/studio/booking/_frame.html.erb +3 -3
  44. data/app/views/studio/booking/_popup.html.erb +4 -4
  45. data/app/views/studio/emails/_banner_editor.html.erb +4 -152
  46. data/app/views/studio/emails/_banner_scale.html.erb +5 -56
  47. data/app/views/studio/emails/index.html.erb +0 -1
  48. data/app/views/studio/geo_settings/edit.html.erb +1 -97
  49. data/app/views/studio/site_footer/_map.html.erb +7 -5
  50. data/app/views/studio/site_footer/_map_assets.html.erb +5 -129
  51. data/app/views/studio/site_identities/edit.html.erb +1 -24
  52. data/app/views/studio/surveys/show.html.erb +4 -5
  53. data/app/views/style/_tasks.html.erb +3 -8
  54. data/app/views/style/_theme.html.erb +2 -52
  55. data/app/views/theme_settings/edit.html.erb +3 -82
  56. data/config/importmap.rb +6 -0
  57. data/docs/BOOKING.md +6 -5
  58. data/docs/SITE_FOOTER.md +16 -12
  59. data/lib/studio/version.rb +1 -1
  60. metadata +28 -3
  61. data/app/views/studio/emails/_recipient_repaint.html.erb +0 -122
  62. 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 = '&copy; <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
+ }