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.
- 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
|
@@ -22,13 +22,13 @@
|
|
|
22
22
|
THE SRC IS DEFERRED, and not with loading="lazy". The frame is a third
|
|
23
23
|
party, and a frame that starts loading before the window's `load` event
|
|
24
24
|
holds that event open for as long as Google takes to answer. So the URL
|
|
25
|
-
waits in data-src and
|
|
26
|
-
AFTER `load`, once the frame is near the viewport.
|
|
25
|
+
waits in data-src and studio/booking (the booking controller on the
|
|
26
|
+
wrapper) assigns it only AFTER `load`, once the frame is near the viewport.
|
|
27
27
|
|
|
28
28
|
WITH SCRIPTS OFF nothing ever assigns the src, so the <noscript> hides the
|
|
29
29
|
empty frame and puts a plain link to Google's booking page in its place. %>
|
|
30
30
|
<%= studio_booking_assets %>
|
|
31
|
-
<div class="booking-frame<%= " booking-frame-cropped" if crop %>" data-booking-wrap data-studio-booking<% if crop %> data-booking-crop style="<%= Studio::Booking.crop_style(crop) %>"<% end %>>
|
|
31
|
+
<div class="booking-frame<%= " booking-frame-cropped" if crop %>" data-booking-wrap data-studio-booking data-studio-controller="booking"<% if crop %> data-booking-crop style="<%= Studio::Booking.crop_style(crop) %>"<% end %>>
|
|
32
32
|
<iframe data-booking-frame data-studio-booking
|
|
33
33
|
data-src="<%= studio_booking_embed_url %>"
|
|
34
34
|
title="<%= title %>"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<%# The booking popup: Google's appointment page in a dialog, opened by any
|
|
2
|
-
a[data-booking-popup] link on the page (studio/booking
|
|
3
|
-
|
|
4
|
-
calls it.
|
|
2
|
+
a[data-booking-popup] link on the page (studio/booking, started by the
|
|
3
|
+
booking controller on the dialog). Rendered once per page by
|
|
4
|
+
`studio_booking_popup`; the site footer calls it.
|
|
5
5
|
|
|
6
6
|
It does what Google's own scheduling-button script does, without loading
|
|
7
7
|
that script, its stylesheet and its icon font on every public page: the
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
reaches the dialog. The Close button and a click on the backdrop always
|
|
24
24
|
work, which is why the button is a solid one. %>
|
|
25
25
|
<%= studio_booking_assets %>
|
|
26
|
-
<dialog class="booking-popup" data-booking-dialog data-studio-booking aria-label="<%= label %>">
|
|
26
|
+
<dialog class="booking-popup" data-booking-dialog data-studio-booking data-studio-controller="booking" aria-label="<%= label %>">
|
|
27
27
|
<form method="dialog" class="booking-popup-bar">
|
|
28
28
|
<span class="booking-popup-title"><%= label %></span>
|
|
29
29
|
<button type="submit" class="booking-popup-close" data-booking-close>Close ✕</button>
|
|
@@ -1,28 +1,7 @@
|
|
|
1
|
-
<%# The live banner editor
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
The banner is already server-rendered by the mailer's own partial, which is
|
|
6
|
-
what makes it trustworthy. This does not replace it — it edits the text
|
|
7
|
-
nodes, the logo and the tint of that same rendered banner in place. The
|
|
8
|
-
shape, the typography and the scaling stay the server's. Re-rendering a
|
|
9
|
-
second banner in JavaScript would be a second implementation of the email,
|
|
10
|
-
and it would drift.
|
|
11
|
-
|
|
12
|
-
WHY THE PLACEHOLDER RULE IS REPEATED HERE
|
|
13
|
-
Studio::Banner fills {name} and {app} server-side. Repeating that in script
|
|
14
|
-
is a real duplication and worth naming: the alternative is a round trip per
|
|
15
|
-
keystroke. The rule is small and pinned on both sides — banner_copy_test.rb
|
|
16
|
-
for Ruby, e2e/email_banner_editor.spec.js for the browser. If they ever
|
|
17
|
-
disagree, the server is right.
|
|
18
|
-
|
|
19
|
-
TOUCHED VS DIRTY — they are different questions and the button answers both.
|
|
20
|
-
`touched` is "has anyone typed here", and it decides whether the button is
|
|
21
|
-
SHOWN. `dirty` is "is anything actually different from what is saved", and it
|
|
22
|
-
decides whether the button is ENABLED. Typing "A" into the subject and
|
|
23
|
-
deleting it again leaves touched true and dirty false: the button stays
|
|
24
|
-
visible and goes back to disabled, which is what tells the operator their
|
|
25
|
-
edit was undone rather than silently swallowed.
|
|
1
|
+
<%# The live banner editor's styles. The editor itself is the
|
|
2
|
+
emailBannerEditor factory in studio/email_banner, which studio/alpine_shims
|
|
3
|
+
publishes on every page; this partial carries the tint slider's rules, and
|
|
4
|
+
studio/emails/show renders it once.
|
|
26
5
|
%>
|
|
27
6
|
<style>
|
|
28
7
|
/* The tint slider, in the app's own palette.
|
|
@@ -70,130 +49,3 @@
|
|
|
70
49
|
.studio-range::-moz-range-track { height: 6px; border-radius: 999px; background: transparent; }
|
|
71
50
|
.studio-range:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary, #8E82FE) 35%, transparent); }
|
|
72
51
|
</style>
|
|
73
|
-
<script>
|
|
74
|
-
(function () {
|
|
75
|
-
function editor(config) {
|
|
76
|
-
return {
|
|
77
|
-
form: Object.assign({}, config.values),
|
|
78
|
-
initial: Object.assign({}, config.values),
|
|
79
|
-
targets: config.targets || [],
|
|
80
|
-
targetId: config.targetId,
|
|
81
|
-
touched: false,
|
|
82
|
-
pickerOpen: false,
|
|
83
|
-
|
|
84
|
-
init() {
|
|
85
|
-
this.paint();
|
|
86
|
-
this.$watch("form", () => { this.touched = true; this.paint(); });
|
|
87
|
-
this.$watch("targetId", () => this.paint());
|
|
88
|
-
},
|
|
89
|
-
|
|
90
|
-
target() {
|
|
91
|
-
return this.targets.find((t) => t.id === this.targetId) || this.targets[0] || {};
|
|
92
|
-
},
|
|
93
|
-
|
|
94
|
-
firstName() {
|
|
95
|
-
return (this.target().name || "").trim().split(/\s+/)[0] || "";
|
|
96
|
-
},
|
|
97
|
-
|
|
98
|
-
// {app} is substituted on both paths because it does not depend on the
|
|
99
|
-
// recipient — a header reading "Welcome to {app}" must not show a raw
|
|
100
|
-
// brace here while the inbox shows the app's name.
|
|
101
|
-
resolve(template, fallback) {
|
|
102
|
-
var first = this.firstName();
|
|
103
|
-
var app = config.appName || "";
|
|
104
|
-
var text = (template || "").replace(/\{app\}/g, app);
|
|
105
|
-
if (first) return text.replace(/\{name\}/g, first);
|
|
106
|
-
if (text.indexOf("{name}") !== -1) return (fallback || "").replace(/\{app\}/g, app);
|
|
107
|
-
return text;
|
|
108
|
-
},
|
|
109
|
-
|
|
110
|
-
headerText() { return this.resolve(this.form.header, this.form.header_fallback); },
|
|
111
|
-
subjectText() { return this.resolve(this.form.subject, this.form.subject); },
|
|
112
|
-
|
|
113
|
-
// Field by field rather than JSON.stringify, whose output depends on key
|
|
114
|
-
// order — two equal objects can stringify differently.
|
|
115
|
-
//
|
|
116
|
-
// No type coercion here, deliberately. An earlier version normalised
|
|
117
|
-
// booleans on the theory that a checkbox holds true while the server
|
|
118
|
-
// echoes "1"; a mutation run showed removing it changed nothing, because
|
|
119
|
-
// the payload sends real booleans for the two checkboxes and strings for
|
|
120
|
-
// everything else. Unproven defensive code in a comparison that decides
|
|
121
|
-
// whether a Save button appears is worse than none.
|
|
122
|
-
dirty() {
|
|
123
|
-
return Object.keys(this.initial).some(function (k) {
|
|
124
|
-
return (this.form[k] ?? "") !== (this.initial[k] ?? "");
|
|
125
|
-
}, this);
|
|
126
|
-
},
|
|
127
|
-
|
|
128
|
-
// The logo has THREE states, and a URL field alone can only express two.
|
|
129
|
-
// "standard" inherits, "custom" uses what this app uploaded, "hidden" is
|
|
130
|
-
// the deliberate no-logo answer that blank cannot say.
|
|
131
|
-
logoMode() {
|
|
132
|
-
if (this.form.hide_logo === true || this.form.hide_logo === "1") return "hidden";
|
|
133
|
-
if (config.uploadedLogo) return "custom";
|
|
134
|
-
return "standard";
|
|
135
|
-
},
|
|
136
|
-
|
|
137
|
-
setLogoMode(mode) {
|
|
138
|
-
this.form.hide_logo = mode === "hidden";
|
|
139
|
-
},
|
|
140
|
-
|
|
141
|
-
currentLogo() {
|
|
142
|
-
if (this.logoMode() === "hidden") return "";
|
|
143
|
-
return config.uploadedLogo || config.inheritedLogo || "";
|
|
144
|
-
},
|
|
145
|
-
|
|
146
|
-
// The slider's own fill, as a percentage string for --range-fill.
|
|
147
|
-
scrimFill() {
|
|
148
|
-
var percent = parseInt(this.form.scrim_percent, 10);
|
|
149
|
-
if (isNaN(percent)) percent = config.defaultScrim;
|
|
150
|
-
return Math.min(Math.max(percent, 0), 100) + "%";
|
|
151
|
-
},
|
|
152
|
-
|
|
153
|
-
scrimCss() {
|
|
154
|
-
var percent = parseInt(this.form.scrim_percent, 10);
|
|
155
|
-
if (isNaN(percent)) percent = config.defaultScrim;
|
|
156
|
-
percent = Math.min(Math.max(percent, 0), 100);
|
|
157
|
-
return "rgba(24,16,64," + (percent / 100) + ")";
|
|
158
|
-
},
|
|
159
|
-
|
|
160
|
-
paint() {
|
|
161
|
-
var root = this.$root;
|
|
162
|
-
var header = root.querySelector("[data-banner-header]");
|
|
163
|
-
var subtext = root.querySelector("[data-banner-subtext]");
|
|
164
|
-
var logo = root.querySelector("[data-banner-logo]");
|
|
165
|
-
var scrim = root.querySelector("[data-banner-scrim]");
|
|
166
|
-
|
|
167
|
-
if (header) header.textContent = this.headerText();
|
|
168
|
-
if (subtext) subtext.textContent = this.form.subtext || "";
|
|
169
|
-
if (scrim) scrim.style.backgroundColor = this.scrimCss();
|
|
170
|
-
if (logo) {
|
|
171
|
-
var url = this.currentLogo();
|
|
172
|
-
if (url) {
|
|
173
|
-
if (logo.getAttribute("src") !== url) logo.setAttribute("src", url);
|
|
174
|
-
logo.style.display = "block";
|
|
175
|
-
} else {
|
|
176
|
-
logo.style.display = "none";
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
},
|
|
180
|
-
|
|
181
|
-
// Every visible field is x-model bound and carries no name; the hidden
|
|
182
|
-
// form below mirrors them. One submit writes the whole page.
|
|
183
|
-
save() {
|
|
184
|
-
if (!this.dirty()) return;
|
|
185
|
-
this.$refs.saveForm.requestSubmit();
|
|
186
|
-
}
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
window.emailBannerEditor = editor;
|
|
191
|
-
if (typeof Alpine !== "undefined") {
|
|
192
|
-
Alpine.data("emailBannerEditor", editor);
|
|
193
|
-
} else {
|
|
194
|
-
document.addEventListener("alpine:init", function () {
|
|
195
|
-
Alpine.data("emailBannerEditor", editor);
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
})();
|
|
199
|
-
</script>
|
|
@@ -1,56 +1,5 @@
|
|
|
1
|
-
<%# Scales the 600px email banner down to whatever width its frame has.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
frame beside it is fluid. Nothing in CSS converts a fixed-width box into a
|
|
7
|
-
fluid one without knowing the container's width, so the width is read and a
|
|
8
|
-
transform applied.
|
|
9
|
-
|
|
10
|
-
What it does NOT do: move anything. It sets a scale on a box whose height is
|
|
11
|
-
already reserved by `aspect-ratio`, so no reflow follows and there is no
|
|
12
|
-
paint-time jump — the failure mode of measuring layout in script.
|
|
13
|
-
|
|
14
|
-
Degrades to an unscaled, clipped preview if script never runs. That is the
|
|
15
|
-
behaviour this replaced, so a failure here costs the improvement, not the page.
|
|
16
|
-
%>
|
|
17
|
-
<script>
|
|
18
|
-
(function () {
|
|
19
|
-
function fit(preview) {
|
|
20
|
-
var frame = preview.parentElement;
|
|
21
|
-
if (!frame) return;
|
|
22
|
-
var natural = parseFloat(preview.dataset.bannerWidth) || 600;
|
|
23
|
-
var available = frame.clientWidth;
|
|
24
|
-
if (!available) return;
|
|
25
|
-
// Never scale UP past the real size: at 1:1 this preview is exactly the
|
|
26
|
-
// 600px the recipient sees, and blowing it past that would show the
|
|
27
|
-
// operator a banner larger than any inbox renders.
|
|
28
|
-
var scale = Math.min(available / natural, 1);
|
|
29
|
-
preview.style.transform = "scale(" + scale + ")";
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function fitAll() {
|
|
33
|
-
document.querySelectorAll("[data-email-banner-preview]").forEach(fit);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// ResizeObserver rather than a resize listener: the frame changes width when
|
|
37
|
-
// a sidebar opens or the grid reflows, neither of which resizes the window.
|
|
38
|
-
var observer = typeof ResizeObserver === "function" ? new ResizeObserver(fitAll) : null;
|
|
39
|
-
|
|
40
|
-
function start() {
|
|
41
|
-
fitAll();
|
|
42
|
-
if (!observer) return;
|
|
43
|
-
observer.disconnect();
|
|
44
|
-
document.querySelectorAll("[data-email-banner-frame]").forEach(function (frame) {
|
|
45
|
-
observer.observe(frame);
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// turbo:load as well as DOMContentLoaded — a Turbo visit swaps the body
|
|
50
|
-
// without firing DOMContentLoaded, which would leave the preview at its
|
|
51
|
-
// unscaled 600px until a full reload.
|
|
52
|
-
document.addEventListener("DOMContentLoaded", start);
|
|
53
|
-
document.addEventListener("turbo:load", start);
|
|
54
|
-
if (document.readyState !== "loading") start();
|
|
55
|
-
})();
|
|
56
|
-
</script>
|
|
1
|
+
<%# Scales the 600px email banner down to whatever width its frame has. The
|
|
2
|
+
fit is studio/email_banner_scale, started by the email-banner-scale
|
|
3
|
+
controller on this anchor. Render it once per page that shows a banner
|
|
4
|
+
preview. Without script the preview is unscaled and clipped. %>
|
|
5
|
+
<template data-studio-controller="email-banner-scale"></template>
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
</style>
|
|
53
53
|
|
|
54
54
|
<div class="max-w-3xl mx-auto"
|
|
55
|
-
data-geo-page
|
|
55
|
+
data-geo-page data-studio-controller="geo-settings"
|
|
56
56
|
data-geo-home="<%= home_country %>"
|
|
57
57
|
data-geo-country="<%= geo_country %>"
|
|
58
58
|
data-geo-subdivision="<%= geo_state %>"
|
|
@@ -264,102 +264,6 @@
|
|
|
264
264
|
<% end %>
|
|
265
265
|
</div>
|
|
266
266
|
|
|
267
|
-
<script>
|
|
268
|
-
// LIVE PREVIEW. Tick your own region and the navbar badge (and the Blocked?
|
|
269
|
-
// tile) answer immediately, before anything is saved — so an operator can see
|
|
270
|
-
// what a rule will do to a real visitor rather than saving to find out.
|
|
271
|
-
//
|
|
272
|
-
// This mirrors Studio::Geo.blocked? on the client, deliberately and narrowly:
|
|
273
|
-
// same three rules, same order, same fail-closed narrowing. It decides nothing
|
|
274
|
-
// — the server re-decides on save and on every request afterwards — but a
|
|
275
|
-
// mirror can still drift, so turf-monster's browser lane asserts the painted
|
|
276
|
-
// result against a policy the server also enforces.
|
|
277
|
-
(function () {
|
|
278
|
-
var page = document.querySelector("[data-geo-page]");
|
|
279
|
-
if (!page) return;
|
|
280
|
-
|
|
281
|
-
var home = page.dataset.geoHome;
|
|
282
|
-
var country = page.dataset.geoCountry;
|
|
283
|
-
var subdivision = page.dataset.geoSubdivision;
|
|
284
|
-
var failClosed = page.dataset.geoFailClosed === "true";
|
|
285
|
-
var enabledBox = page.querySelector('input[type="checkbox"][name="geo_setting[enabled]"]');
|
|
286
|
-
|
|
287
|
-
function checkedValues(selector) {
|
|
288
|
-
return Array.prototype.map.call(page.querySelectorAll(selector + ":checked"), function (el) {
|
|
289
|
-
return el.value;
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function blocked() {
|
|
294
|
-
if (!enabledBox || !enabledBox.checked) return false;
|
|
295
|
-
|
|
296
|
-
var countries = checkedValues('input[name="geo_setting[banned_countries][]"]');
|
|
297
|
-
if (country && countries.indexOf(country) !== -1) return true;
|
|
298
|
-
|
|
299
|
-
var subdivisions = checkedValues('input[name="geo_setting[banned_subdivisions][]"]');
|
|
300
|
-
if (subdivision && country === home && subdivisions.indexOf(subdivision) !== -1) return true;
|
|
301
|
-
|
|
302
|
-
// Fail closed, narrowed exactly as the server narrows it: only a
|
|
303
|
-
// home-country visitor with NO region, and only when region rules exist.
|
|
304
|
-
if (!failClosed) return false;
|
|
305
|
-
if (subdivision) return false;
|
|
306
|
-
if (country !== home) return false;
|
|
307
|
-
return subdivisions.length > 0;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// The summary chips are rebuilt from the editor's own squares, so they
|
|
311
|
-
// describe the policy you are ABOUT to save rather than the one on disk —
|
|
312
|
-
// and they are built from the square's own flag, so there is no second copy
|
|
313
|
-
// of the artwork to keep in step.
|
|
314
|
-
function refreshSummary(kind, selector) {
|
|
315
|
-
var row = page.querySelector('[data-geo-summary="' + kind + '"]');
|
|
316
|
-
var counts = page.querySelectorAll('[data-geo-summary-count="' + kind + '"]');
|
|
317
|
-
if (!row) return;
|
|
318
|
-
|
|
319
|
-
Array.prototype.forEach.call(row.querySelectorAll(".geo-chip"), function (chip) {
|
|
320
|
-
chip.remove();
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
var boxes = Array.prototype.filter.call(page.querySelectorAll(selector), function (box) {
|
|
324
|
-
return box.checked;
|
|
325
|
-
});
|
|
326
|
-
boxes.sort(function (a, b) { return a.value.localeCompare(b.value); });
|
|
327
|
-
|
|
328
|
-
boxes.forEach(function (box) {
|
|
329
|
-
var square = box.closest("label");
|
|
330
|
-
var chip = document.createElement("span");
|
|
331
|
-
chip.className = "geo-chip inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-mono";
|
|
332
|
-
var art = square && square.querySelector("img, span[role='img']");
|
|
333
|
-
if (art) chip.appendChild(art.cloneNode(true));
|
|
334
|
-
chip.appendChild(document.createTextNode(box.value));
|
|
335
|
-
row.insertBefore(chip, row.querySelector(".geo-summary-empty"));
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
// The tab label and the summary heading carry the same number; a page that
|
|
339
|
-
// showed two different counts for one list would be its own bug report.
|
|
340
|
-
Array.prototype.forEach.call(counts, function (el) { el.textContent = boxes.length; });
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function paint() {
|
|
344
|
-
refreshSummary("states", 'input[name="geo_setting[banned_subdivisions][]"]');
|
|
345
|
-
refreshSummary("countries", 'input[name="geo_setting[banned_countries][]"]');
|
|
346
|
-
|
|
347
|
-
var value = blocked() ? "blocked" : "allowed";
|
|
348
|
-
document.documentElement.setAttribute("data-geo-preview", value);
|
|
349
|
-
Array.prototype.forEach.call(document.querySelectorAll("[data-geo-badge]"), function (badge) {
|
|
350
|
-
// Only the badge showing THIS visitor's location previews; a specimen
|
|
351
|
-
// badge rendered with other locals keeps whatever it was given.
|
|
352
|
-
if (badge.dataset.country === country && (badge.dataset.subdivision || "") === (subdivision || "")) {
|
|
353
|
-
badge.setAttribute("data-blocked", blocked() ? "true" : "false");
|
|
354
|
-
}
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
page.addEventListener("change", paint);
|
|
359
|
-
paint();
|
|
360
|
-
})();
|
|
361
|
-
</script>
|
|
362
|
-
|
|
363
267
|
<style>
|
|
364
268
|
/* The verdict tile follows the same one attribute the badge does. */
|
|
365
269
|
.geo-when-blocked, .geo-when-allowed { display: none; }
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
<%# One live map of an address. Leaflet mounts on this element (
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
<%# One live map of an address. Leaflet mounts on this element (studio/footer_map,
|
|
2
|
+
started by the footer-map controller it names); until it does, and if it
|
|
3
|
+
never does, the link inside is the whole map: an address that opens
|
|
4
|
+
directions. `classes` and `style` size and shape it. The styles are
|
|
5
|
+
studio/site_footer/_map_assets, which this partial and nothing else pulls in.
|
|
5
6
|
|
|
6
7
|
Leaflet's two files ride the engine's asset pipeline (studio/leaflet.js,
|
|
7
|
-
studio/leaflet.css). Their URLs travel on the element, so the
|
|
8
|
+
studio/leaflet.css). Their URLs travel on the element, so the module fetches
|
|
8
9
|
them only on a page that has a map. %>
|
|
9
10
|
<%= studio_footer_map_assets %>
|
|
10
11
|
<div class="ftr-map <%= local_assigns[:classes] %>"
|
|
11
12
|
<% if local_assigns[:style].present? %>style="<%= style %>"<% end %>
|
|
12
13
|
data-footer-map
|
|
14
|
+
data-studio-controller="footer-map"
|
|
13
15
|
data-lat="<%= address[:lat] %>" data-lng="<%= address[:lng] %>"
|
|
14
16
|
data-zoom="<%= local_assigns[:zoom] || address[:zoom] %>"
|
|
15
17
|
data-controls="<%= local_assigns.fetch(:controls, true) %>"
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
<%# The map's styles
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
<%# The map's styles. Rendered once per page, by `studio_footer_map_assets`,
|
|
2
|
+
which only studio/site_footer/_map calls: a page with no map (a footer with
|
|
3
|
+
no address) carries none of this and names no Leaflet. The mount is
|
|
4
|
+
studio/footer_map, started by the footer-map controller on the map element.
|
|
5
|
+
The footer's own styles are studio/site_footer/_assets. %>
|
|
5
6
|
<style>
|
|
6
7
|
/* The map. isolation keeps Leaflet's internal z-indexes (up to 1000) under
|
|
7
8
|
the pinned navbar and every modal. It reads the same properties outside
|
|
@@ -30,128 +31,3 @@
|
|
|
30
31
|
@keyframes ftr-ping { 75%, 100% { transform: scale(3.2); opacity: 0; } }
|
|
31
32
|
@media (prefers-reduced-motion: reduce) { .ftr-pin::before { animation: none; } }
|
|
32
33
|
</style>
|
|
33
|
-
|
|
34
|
-
<script>
|
|
35
|
-
// Mounts a Leaflet map on every [data-footer-map]. Leaflet (script and
|
|
36
|
-
// stylesheet) is fetched once, on demand, from the URLs the element carries,
|
|
37
|
-
// so a page with no map pays nothing for it. The basemap follows the site
|
|
38
|
-
// theme in CSS (see .dark .leaflet-tile-pane above). Tiles are OpenStreetMap's
|
|
39
|
-
// keyless ones; a basemap that needs an API key does not belong in a gem.
|
|
40
|
-
//
|
|
41
|
-
// NOTHING STARTS BEFORE THE WINDOW'S `load`, AND NOTHING UNTIL THE MAP IS NEAR
|
|
42
|
-
// THE VIEWPORT. The tiles are a third party's, and a request that starts
|
|
43
|
-
// before `load` holds that event open for as long as the third party takes to
|
|
44
|
-
// answer. The map is also at the bottom of every public page, where most
|
|
45
|
-
// visits never reach: those pay for neither Leaflet nor a single tile.
|
|
46
|
-
//
|
|
47
|
-
// THE GUARD IS THE ENGINE'S OWN NAME, AND THE ELEMENTS ARE THE ENGINE'S OWN. An
|
|
48
|
-
// app that still renders a local footer script (mcritchie-studio, until it
|
|
49
|
-
// adopts this one) guards it with the unprefixed name and marks its map
|
|
50
|
-
// [data-footer-map] too. Sharing either would let whichever script ran first
|
|
51
|
-
// in a Turbo session stand the other down and then fail on its elements: that
|
|
52
|
-
// app's map carries no data-leaflet-js, so there is nothing here to fetch.
|
|
53
|
-
(function () {
|
|
54
|
-
if (window.__studioFooterMapsArmed) return;
|
|
55
|
-
window.__studioFooterMapsArmed = true;
|
|
56
|
-
|
|
57
|
-
var TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|
58
|
-
var ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
|
|
59
|
-
var maps = [];
|
|
60
|
-
var loading = null;
|
|
61
|
-
|
|
62
|
-
// Checked on every mount, not once: a page visit that rebuilds the head
|
|
63
|
-
// must not leave a mounted map without its stylesheet.
|
|
64
|
-
function ensureStylesheet(href) {
|
|
65
|
-
if (!href || document.querySelector('link[data-studio-leaflet]')) return;
|
|
66
|
-
var link = document.createElement('link');
|
|
67
|
-
link.rel = 'stylesheet';
|
|
68
|
-
link.href = href;
|
|
69
|
-
link.setAttribute('data-studio-leaflet', '');
|
|
70
|
-
document.head.appendChild(link);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function loadLeaflet(src) {
|
|
74
|
-
if (window.L) return Promise.resolve();
|
|
75
|
-
if (loading) return loading;
|
|
76
|
-
loading = new Promise(function (resolve, reject) {
|
|
77
|
-
var s = document.createElement('script');
|
|
78
|
-
s.src = src;
|
|
79
|
-
s.onload = resolve;
|
|
80
|
-
// A failed fetch must not be remembered: the next visit tries again.
|
|
81
|
-
s.onerror = function () { loading = null; s.remove(); reject(new Error('leaflet failed to load')); };
|
|
82
|
-
document.head.appendChild(s);
|
|
83
|
-
});
|
|
84
|
-
return loading;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function mount(el) {
|
|
88
|
-
if (el.__footerMap) return;
|
|
89
|
-
var at = [parseFloat(el.dataset.lat), parseFloat(el.dataset.lng)];
|
|
90
|
-
if (isNaN(at[0]) || isNaN(at[1])) return;
|
|
91
|
-
// NO ONE-FINGER DRAG ON A TOUCH DEVICE. The map runs edge to edge, so on a
|
|
92
|
-
// phone a swipe that starts on it would pan the map instead of scrolling
|
|
93
|
-
// the page, and there is no gutter to scroll by. With dragging off Leaflet
|
|
94
|
-
// leaves touch-action at pan-x pan-y: one finger scrolls the page, and a
|
|
95
|
-
// pinch still zooms the map (touchZoom stays on, and moves it under the
|
|
96
|
-
// fingers). The zoom buttons and the directions link do the rest.
|
|
97
|
-
var map = L.map(el, { center: at, zoom: parseInt(el.dataset.zoom, 10) || 15,
|
|
98
|
-
scrollWheelZoom: false, attributionControl: true,
|
|
99
|
-
dragging: !L.Browser.mobile, touchZoom: true,
|
|
100
|
-
zoomControl: el.dataset.controls !== 'false' });
|
|
101
|
-
map.attributionControl.setPrefix(false);
|
|
102
|
-
L.tileLayer(TILES, { attribution: ATTRIBUTION, maxZoom: 19 }).addTo(map);
|
|
103
|
-
L.marker(at, { icon: L.divIcon({ className: '', html: '<div class="ftr-pin"></div>', iconSize: [18, 18], iconAnchor: [9, 9] }),
|
|
104
|
-
keyboard: false }).addTo(map);
|
|
105
|
-
// Page scroll stays page scroll until the visitor commits to the map.
|
|
106
|
-
map.on('click', function () { map.scrollWheelZoom.enable(); });
|
|
107
|
-
map.on('mouseout', function () { map.scrollWheelZoom.disable(); });
|
|
108
|
-
var fallback = el.querySelector('.ftr-map-fallback');
|
|
109
|
-
if (fallback) { el.__footerFallback = fallback; fallback.remove(); }
|
|
110
|
-
el.__footerMap = map;
|
|
111
|
-
maps.push(el);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function fetchAndMount(el) {
|
|
115
|
-
ensureStylesheet(el.dataset.leafletCss);
|
|
116
|
-
loadLeaflet(el.dataset.leafletJs)
|
|
117
|
-
.then(function () { if (el.isConnected) mount(el); })
|
|
118
|
-
.catch(function () { /* the fallback link stays */ });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// One observer per map: it fires once, when the map comes within 400px of
|
|
122
|
-
// the viewport, and is then dropped.
|
|
123
|
-
function watch(el) {
|
|
124
|
-
if (el.__footerMap || el.__footerWatch) return;
|
|
125
|
-
if (typeof IntersectionObserver !== 'function') { fetchAndMount(el); return; }
|
|
126
|
-
var watcher = new IntersectionObserver(function (entries) {
|
|
127
|
-
if (!entries.some(function (entry) { return entry.isIntersecting; })) return;
|
|
128
|
-
watcher.disconnect();
|
|
129
|
-
el.__footerWatch = null;
|
|
130
|
-
fetchAndMount(el);
|
|
131
|
-
}, { rootMargin: '400px' });
|
|
132
|
-
el.__footerWatch = watcher;
|
|
133
|
-
watcher.observe(el);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function armAll() { document.querySelectorAll('[data-footer-map][data-leaflet-js]').forEach(watch); }
|
|
137
|
-
|
|
138
|
-
function armAfterLoad() {
|
|
139
|
-
if (document.readyState === 'complete') armAll();
|
|
140
|
-
else window.addEventListener('load', armAll, { once: true });
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// A cached Turbo snapshot would restore Leaflet's DOM without its state, so
|
|
144
|
-
// the map is taken down and its fallback link put back before the snapshot.
|
|
145
|
-
document.addEventListener('turbo:before-cache', function () {
|
|
146
|
-
maps.forEach(function (el) {
|
|
147
|
-
var fallback = el.__footerFallback;
|
|
148
|
-
el.__footerMap.remove();
|
|
149
|
-
el.__footerMap = null;
|
|
150
|
-
if (fallback && !el.querySelector('.ftr-map-fallback')) el.appendChild(fallback);
|
|
151
|
-
});
|
|
152
|
-
maps = [];
|
|
153
|
-
});
|
|
154
|
-
document.addEventListener('turbo:load', armAfterLoad);
|
|
155
|
-
armAfterLoad();
|
|
156
|
-
})();
|
|
157
|
-
</script>
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
Alpine scope of its own, so without one here the crop modal's store opens
|
|
60
60
|
and its dialog never appears in a host whose body carries no x-data
|
|
61
61
|
(e2e/site_identity.spec.js). %>
|
|
62
|
-
<div class="max-w-3xl mx-auto" x-data data-link-preview-page
|
|
62
|
+
<div class="max-w-3xl mx-auto" x-data data-link-preview-page data-studio-controller="link-preview-card"
|
|
63
63
|
data-fallback-title="<%= fallback_title %>"
|
|
64
64
|
data-fallback-description="<%= fallback_desc %>">
|
|
65
65
|
<h1 class="text-2xl font-extrabold text-heading mb-2">Link Preview</h1>
|
|
@@ -208,26 +208,3 @@
|
|
|
208
208
|
<% end %>
|
|
209
209
|
<% end %>
|
|
210
210
|
</div>
|
|
211
|
-
|
|
212
|
-
<script>
|
|
213
|
-
// LIVE CARD. Typing in the title or description repaints the card before
|
|
214
|
-
// anything is saved, falling back to what a blank field would really send.
|
|
215
|
-
(function () {
|
|
216
|
-
var page = document.querySelector("[data-link-preview-page]");
|
|
217
|
-
if (!page) return;
|
|
218
|
-
var fallbacks = {
|
|
219
|
-
title: page.dataset.fallbackTitle || "",
|
|
220
|
-
description: page.dataset.fallbackDescription || ""
|
|
221
|
-
};
|
|
222
|
-
function paint(input) {
|
|
223
|
-
var key = input.dataset.linkPreviewInput;
|
|
224
|
-
var target = page.querySelector("[data-link-preview-card-" + key + "]");
|
|
225
|
-
if (!target) return;
|
|
226
|
-
var value = input.value.trim();
|
|
227
|
-
target.textContent = value || fallbacks[key];
|
|
228
|
-
}
|
|
229
|
-
page.querySelectorAll("[data-link-preview-input]").forEach(function (input) {
|
|
230
|
-
input.addEventListener("input", function () { paint(input); });
|
|
231
|
-
});
|
|
232
|
-
})();
|
|
233
|
-
</script>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<%# /surveys/:slug — the survey (Studio::SurveysController#show, docs/SURVEYS.md).
|
|
2
2
|
|
|
3
3
|
ONE FORM, TWO PRESENTATIONS. Without JavaScript every question renders in
|
|
4
|
-
order under the title and intro, and the Submit button posts the lot.
|
|
5
|
-
|
|
4
|
+
order under the title and intro, and the Submit button posts the lot.
|
|
5
|
+
studio/survey (the survey controller on the root) adds .is-enhanced and
|
|
6
|
+
turns the same markup into one question
|
|
6
7
|
per screen, opening on question 1 with no intro screen, with a progress
|
|
7
8
|
bar, Back (from question 2 on) and Next, number-key and Enter shortcuts, and an
|
|
8
9
|
autosave on each answer. Submit is still the same POST either way, so the
|
|
@@ -12,7 +13,7 @@
|
|
|
12
13
|
<% content_for(:title, @survey.title) %>
|
|
13
14
|
<%= render "studio/surveys/styles" %>
|
|
14
15
|
|
|
15
|
-
<main class="studio-survey" data-studio-survey
|
|
16
|
+
<main class="studio-survey" data-studio-survey data-studio-controller="survey"
|
|
16
17
|
data-answer-base="<%= studio_survey_path(@survey.slug) %>/answers/"
|
|
17
18
|
data-csrf="<%= form_authenticity_token %>"
|
|
18
19
|
data-resume-index="<%= @resume_index %>"
|
|
@@ -61,5 +62,3 @@
|
|
|
61
62
|
<% end %>
|
|
62
63
|
</div>
|
|
63
64
|
</main>
|
|
64
|
-
|
|
65
|
-
<%= render "studio/surveys/script" %>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
The shared board primitive, LIVE. Phase D lifted the columns / cards / drag +
|
|
4
4
|
optimistic move / reorder that the three near-identical McRitchie Studio boards
|
|
5
5
|
(tasks / news / content) each hand-rolled into one engine primitive
|
|
6
|
-
(studio/board/_board + the
|
|
6
|
+
(studio/board/_board + the studio/board module + vendored SortableJS).
|
|
7
7
|
The first block below renders that real primitive with `demo: true` — you can
|
|
8
8
|
DRAG a card between columns and the move animates + toasts, but nothing POSTs.
|
|
9
9
|
The blocks under it stay a static reference: the stage-* badge palette
|
|
@@ -32,10 +32,7 @@
|
|
|
32
32
|
# the last expression of an ERB scriptlet trips Erubi, and literal ERB tags in the
|
|
33
33
|
# body would close the tag. Plain render() calls read clearly for an agent.
|
|
34
34
|
board_usage_snippet = [
|
|
35
|
-
"#
|
|
36
|
-
'render "studio/board_assets"',
|
|
37
|
-
"",
|
|
38
|
-
"# then the board itself:",
|
|
35
|
+
"# the board; it needs nothing else on the page:",
|
|
39
36
|
'render "studio/board/board",',
|
|
40
37
|
" columns: @columns, # [{ key:, label:, cards:, badge_class: }]",
|
|
41
38
|
' card_partial: "tasks/task_card",',
|
|
@@ -80,9 +77,7 @@
|
|
|
80
77
|
</div>
|
|
81
78
|
|
|
82
79
|
<%# ---- The LIVE primitive (demo mode) --------------------------------- %>
|
|
83
|
-
<%#
|
|
84
|
-
the board. demo: true → drag animates + toasts locally, nothing POSTs. %>
|
|
85
|
-
<%= render "studio/board_assets" %>
|
|
80
|
+
<%# demo: true → drag animates + toasts locally, nothing POSTs. %>
|
|
86
81
|
<div class="space-y-3">
|
|
87
82
|
<div class="flex items-center gap-2">
|
|
88
83
|
<span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Live</span>
|