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
@@ -7,6 +7,8 @@
7
7
  itself on save), THIS section intercepts Save + Regenerate and submits them
8
8
  via fetch — the page never navigates away, the live preview stays put, and
9
9
  the outcome is a toast. The standalone page's own redirect is unchanged.
10
+ The editor is studio/theme_editor's dsThemeEditor factory, which
11
+ studio/alpine_shims publishes.
10
12
 
11
13
  Rendered in two contexts:
12
14
  * live in the host app (StyleController sets @theme_setting + @theme_defaults)
@@ -168,56 +170,4 @@
168
170
  </div>
169
171
  </div>
170
172
  </div>
171
-
172
- <script>
173
- // Live-preview editor for the Style-guide Theme section. Seeded from the
174
- // server-resolved role colors; updates the themeable role vars on the
175
- // document as you type so every specimen on the page restyles instantly.
176
- // The <input name> fields still carry the real values.
177
- //
178
- // Save-in-place: Save + Regenerate submit via fetch and stay on THIS page —
179
- // preventDefault stops the browser navigating to /admin/theme (which is what
180
- // bounced the operator off this section), the live preview persists, and the
181
- // outcome is a toast (the engine's shared _flash toast primitive). The
182
- // <form> POST still degrades gracefully if Alpine never loads.
183
- function dsThemeEditor(seed) {
184
- return {
185
- colors: Object.assign({ primary: '#000', dark: '#0f172a', light: '#ffffff',
186
- success: '#367E3A', warning: '#FF7C47', danger: '#EF4444',
187
- accent: '#6366F1' }, seed || {}),
188
- saving: false,
189
- updatePreview() {
190
- var d = document.documentElement.style;
191
- d.setProperty('--color-cta', this.colors.primary);
192
- d.setProperty('--color-cta-fill', this.colors.primary);
193
- if (this.colors.success) d.setProperty('--color-success', this.colors.success);
194
- if (this.colors.warning) { d.setProperty('--color-warning', this.colors.warning); d.setProperty('--color-warning-fill', this.colors.warning); }
195
- if (this.colors.danger) { d.setProperty('--color-danger', this.colors.danger); d.setProperty('--color-danger-fill', this.colors.danger); }
196
- },
197
- persist(form, label) {
198
- if (this.saving) return;
199
- this.saving = true;
200
- // Send the form's REAL method (the editor carries _method=patch) so we
201
- // never depend on Rack::MethodOverride parsing a multipart fetch body.
202
- var data = new FormData(form);
203
- var override = data.get('_method');
204
- var method = override ? String(override).toUpperCase() : 'POST';
205
- if (override) data.delete('_method');
206
- fetch(form.action, { method: method, body: data })
207
- .then(function (res) {
208
- if (!res.ok) throw new Error('HTTP ' + res.status);
209
- return res;
210
- })
211
- .then((function () { this.toast('notice', label, 'Saved in place — the page stayed put.'); }).bind(this))
212
- .catch((function (err) { this.toast('alert', 'Save failed', String((err && err.message) || err)); }).bind(this))
213
- .finally((function () { this.saving = false; }).bind(this));
214
- },
215
- toast(type, title, message) {
216
- window.dispatchEvent(new CustomEvent('toast', { detail: { type: type, title: title, message: message } }));
217
- },
218
- saveTheme(e) { this.persist(e.target, 'Theme saved'); },
219
- regenerate(e) { this.persist(e.target, 'Theme cache cleared'); }
220
- };
221
- }
222
- </script>
223
173
  </section>
@@ -1,4 +1,6 @@
1
- <div class="max-w-5xl mx-auto px-4 py-8" x-data="themeEditor()">
1
+ <%# The editor is studio/theme_editor's themeEditor factory, seeded with the
2
+ server-resolved role colours. studio/alpine_shims publishes it. %>
3
+ <div class="max-w-5xl mx-auto px-4 py-8" x-data="themeEditor(<%= @theme_setting.resolved_colors.to_json %>)">
2
4
  <div class="flex items-center justify-between mb-8">
3
5
  <h1 class="text-2xl font-bold text-heading">Theme</h1>
4
6
  <div class="flex gap-3">
@@ -300,84 +302,3 @@
300
302
 
301
303
  </div>
302
304
  </div>
303
-
304
- <script>
305
- function themeEditor() {
306
- return {
307
- colors: {
308
- primary: '<%= @theme_setting.resolved_colors[:primary] %>',
309
- success: '<%= @theme_setting.resolved_colors[:success] %>',
310
- accent: '<%= @theme_setting.resolved_colors[:accent] %>',
311
- warning: '<%= @theme_setting.resolved_colors[:warning] %>',
312
- danger: '<%= @theme_setting.resolved_colors[:danger] %>',
313
- dark: '<%= @theme_setting.resolved_colors[:dark] %>',
314
- light: '<%= @theme_setting.resolved_colors[:light] %>'
315
- },
316
-
317
- resetColor(role, defaultVal) {
318
- this.colors[role] = defaultVal;
319
- this.updatePreview();
320
- },
321
-
322
- normalizeHex(role) {
323
- let val = this.colors[role].trim();
324
- if (!val.startsWith('#')) val = '#' + val;
325
- if (/^#[0-9a-fA-F]{3}$/.test(val)) {
326
- val = '#' + val[1] + val[1] + val[2] + val[2] + val[3] + val[3];
327
- }
328
- if (/^#[0-9a-fA-F]{6}$/.test(val)) {
329
- this.colors[role] = val;
330
- }
331
- this.updatePreview();
332
- },
333
-
334
- updatePreview() {
335
- // Update live CSS vars on the document for immediate feedback
336
- document.documentElement.style.setProperty('--color-cta', this.colors.primary);
337
- document.documentElement.style.setProperty('--color-success', this.colors.success);
338
- document.documentElement.style.setProperty('--color-warning', this.colors.warning || '#FF7C47');
339
- document.documentElement.style.setProperty('--color-danger', this.colors.danger || '#EF4444');
340
- // btn-warning/btn-danger paint --color-<role>-fill first (the resolver
341
- // emits it for the engine defaults), so a typed colour must set it too
342
- // or the specimens keep the default fill. Cleared: drop the override.
343
- ['warning', 'danger'].forEach((role) => {
344
- if (this.colors[role]) document.documentElement.style.setProperty(`--color-${role}-fill`, this.colors[role]);
345
- else document.documentElement.style.removeProperty(`--color-${role}-fill`);
346
- });
347
- },
348
-
349
- darkPreviewStyle() {
350
- return { backgroundColor: this.colors.dark, borderRadius: '0' };
351
- },
352
-
353
- darkSurfaceStyle() {
354
- return {
355
- backgroundColor: this.lightenColor(this.colors.dark, 0.3),
356
- border: '1px solid rgba(255,255,255,0.1)'
357
- };
358
- },
359
-
360
- lightPreviewStyle() {
361
- return { backgroundColor: this.colors.light, borderRadius: '0' };
362
- },
363
-
364
- lightSurfaceStyle() {
365
- return {
366
- backgroundColor: '#ffffff',
367
- border: '1px solid #e2e8f0'
368
- };
369
- },
370
-
371
- lightenColor(hex, amount) {
372
- hex = hex.replace('#', '');
373
- const r = parseInt(hex.substring(0, 2), 16);
374
- const g = parseInt(hex.substring(2, 4), 16);
375
- const b = parseInt(hex.substring(4, 6), 16);
376
- const nr = Math.round(r + (255 - r) * amount);
377
- const ng = Math.round(g + (255 - g) * amount);
378
- const nb = Math.round(b + (255 - b) * amount);
379
- return '#' + [nr, ng, nb].map(c => c.toString(16).padStart(2, '0')).join('');
380
- }
381
- };
382
- }
383
- </script>
data/config/importmap.rb CHANGED
@@ -10,6 +10,11 @@
10
10
  # "@hotwired/stimulus" is the engine's Stimulus. A host that pins its own
11
11
  # (stimulus-rails) wins, and the engine's controllers run on that copy.
12
12
  #
13
+ # The vendored SortableJS is pinned the same way, as "sortablejs", and never
14
+ # preloaded: studio/board imports it dynamically, on a page that drags. The file
15
+ # is the classic build (app/assets/javascripts/studio/sortable.js), which assigns
16
+ # window.Sortable when it is imported.
17
+ #
13
18
  # `pin`, not `pin_all_from`, so a host can override one: importmap-rails expands
14
19
  # a pin_all_from directory AFTER every plain pin, so a directory pin would beat
15
20
  # the host's own `pin` of the same name. Plain pins are last-drawn-wins, and the
@@ -22,3 +27,4 @@ Studio::Engine.javascript_module_logical_paths.each do |logical_path|
22
27
  pin name, to: logical_path, preload: boot.include?(name)
23
28
  end
24
29
  pin "@hotwired/stimulus", to: "studio/vendor/stimulus.js", preload: true
30
+ pin "sortablejs", to: "studio/sortable.js", preload: false
data/docs/BOOKING.md CHANGED
@@ -297,11 +297,12 @@ An app that enforces a policy must allow the frame:
297
297
  |-----------|--------|-----|
298
298
  | `frame-src` | `https://calendar.google.com` | The booking frame and the popup |
299
299
 
300
- The primitives' own `<style>` and `<script>` blocks are inline and carry no
301
- nonce, like the rest of the engine's partials, and a cropped wrapper carries its
302
- numbers in an inline `style` attribute. A policy that forbids inline script or
303
- style needs `'unsafe-inline'` for them (or hashes), exactly as it does for the
304
- engine's head partial today.
300
+ The primitives carry no inline script: their behaviour is the same-origin
301
+ module `studio/booking`, started by the `booking` controller on the frame's
302
+ wrapper and the popup's dialog. Their own `<style>` block is inline and carries
303
+ no nonce, and a cropped wrapper carries its numbers in an inline `style`
304
+ attribute. A policy that forbids inline style needs `'unsafe-inline'` in
305
+ `style-src` for them (or hashes).
305
306
 
306
307
  ## Limits
307
308
 
data/docs/SITE_FOOTER.md CHANGED
@@ -319,14 +319,19 @@ The frame, its crop, the popup, the `/schedule` page, an app's own booking page
319
319
 
320
320
  ## What it costs a page
321
321
 
322
- - The footer's CSS and scripts are inline, rendered once per page, and scoped
323
- (`.ftr-*`, `.booking-*`). They read the theme's custom properties with a
322
+ - The footer's CSS is inline, rendered once per page, and scoped (`.ftr-*`,
323
+ `.booking-*`). They read the theme's custom properties with a
324
324
  fallback beside each, so the footer lays out the same in an app whose Tailwind
325
325
  build has never seen these views. Do not restyle it with utilities that your
326
326
  build may not emit.
327
- - A page with no address never names Leaflet: the map's styles and its mount
328
- script are rendered only with a map (`studio/site_footer/_map_assets`). A page
329
- with no `booking_url` carries no booking script.
327
+ - The map and the booking primitives run from ES modules (`studio/footer_map`,
328
+ `studio/booking`), each behind a controller the engine registers lazily: the
329
+ module is fetched only by a page that renders the element naming it
330
+ (`data-studio-controller="footer-map"` on the map, `"booking"` on the frame's
331
+ wrapper and the popup's dialog).
332
+ - A page with no address never names Leaflet: the map's styles are rendered
333
+ only with a map (`studio/site_footer/_map_assets`), and nothing fetches its
334
+ module. A page with no `booking_url` fetches no booking module.
330
335
  - A facts callable that raises costs the footer, not the page: in production the
331
336
  error is logged once and the page renders without a footer. In development and
332
337
  test it raises.
@@ -370,18 +375,17 @@ origins (the booking frame's `frame-src` is in [`BOOKING.md`](BOOKING.md#content
370
375
  Leaflet itself is same-origin (`script-src 'self'`, `style-src 'self'`), since it
371
376
  is served from the app's own assets.
372
377
 
373
- The footer's own `<style>` and `<script>` blocks are
374
- inline and carry no nonce, like the rest of the engine's partials, and Leaflet
375
- positions its tiles with inline `style` attributes. A policy that forbids inline
376
- script or style needs `'unsafe-inline'` for them (or hashes), exactly as it does
377
- for the engine's head partial today.
378
+ The footer carries no inline script: its behaviour is same-origin modules.
379
+ Its own `<style>` blocks are inline and carry no nonce, and Leaflet positions
380
+ its tiles with inline `style` attributes. A policy that forbids inline style
381
+ needs `'unsafe-inline'` in `style-src` for them (or hashes).
378
382
 
379
383
  ## Coexisting with an app's own copy
380
384
 
381
385
  An app that still renders a local footer partial uses the same
382
- `data-footer-map` attribute. The engine's script keeps out of its way: its guard
386
+ `data-footer-map` attribute. The engine's module keeps out of its way: its guard
383
387
  is an engine name (`__studioFooterMapsArmed`), and it acts only on
384
- engine-rendered maps, which carry `data-leaflet-js`. The booking scripts follow
388
+ engine-rendered maps, which carry `data-leaflet-js`. The booking module follows
385
389
  the same rule ([`BOOKING.md`](BOOKING.md#coexisting-with-an-apps-own-copy)).
386
390
 
387
391
  ## Markup hooks
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.96.2"
2
+ VERSION = "0.97.0.rc1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.96.2
4
+ version: 0.97.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -412,13 +412,39 @@ files:
412
412
  - app/javascript/studio/alpine_shims.js
413
413
  - app/javascript/studio/alpine_stores.js
414
414
  - app/javascript/studio/application.js
415
+ - app/javascript/studio/board.js
416
+ - app/javascript/studio/booking.js
417
+ - app/javascript/studio/controllers/board_controller.js
418
+ - app/javascript/studio/controllers/booking_controller.js
419
+ - app/javascript/studio/controllers/email_banner_scale_controller.js
420
+ - app/javascript/studio/controllers/footer_map_controller.js
421
+ - app/javascript/studio/controllers/geo_settings_controller.js
422
+ - app/javascript/studio/controllers/hold_button_controller.js
423
+ - app/javascript/studio/controllers/link_preview_card_controller.js
424
+ - app/javascript/studio/controllers/link_sidebar_controller.js
415
425
  - app/javascript/studio/controllers/modal_host_controller.js
416
426
  - app/javascript/studio/controllers/nav_collapse_controller.js
427
+ - app/javascript/studio/controllers/survey_controller.js
428
+ - app/javascript/studio/controllers/toast_controller.js
429
+ - app/javascript/studio/email_banner.js
430
+ - app/javascript/studio/email_banner_scale.js
431
+ - app/javascript/studio/footer_map.js
432
+ - app/javascript/studio/geo_settings.js
417
433
  - app/javascript/studio/head_chrome.js
434
+ - app/javascript/studio/hold_button.js
435
+ - app/javascript/studio/hold_button_hooks.js
436
+ - app/javascript/studio/lazy_controllers.js
437
+ - app/javascript/studio/leveling_activity.js
438
+ - app/javascript/studio/link_preview_card.js
439
+ - app/javascript/studio/link_sidebar.js
418
440
  - app/javascript/studio/local_path.js
419
441
  - app/javascript/studio/modal_host.js
420
442
  - app/javascript/studio/nav_collapse.js
421
443
  - app/javascript/studio/pinned_stack.js
444
+ - app/javascript/studio/stimulus.js
445
+ - app/javascript/studio/survey.js
446
+ - app/javascript/studio/theme_editor.js
447
+ - app/javascript/studio/toast.js
422
448
  - app/javascript/studio/vendor/stimulus.js
423
449
  - app/jobs/error_log_cleanup_job.rb
424
450
  - app/jobs/studio/email_delivery_job.rb
@@ -488,6 +514,7 @@ files:
488
514
  - app/views/studio/_cropper_assets.html.erb
489
515
  - app/views/studio/_fizz_layer.html.erb
490
516
  - app/views/studio/_hold_button.html.erb
517
+ - app/views/studio/_hold_button_guard.html.erb
491
518
  - app/views/studio/_leveling_activity_assets.html.erb
492
519
  - app/views/studio/_session_stamp.html.erb
493
520
  - app/views/studio/admin_models/_arenas_table.html.erb
@@ -514,7 +541,6 @@ files:
514
541
  - app/views/studio/emails/_banner_preview.html.erb
515
542
  - app/views/studio/emails/_banner_scale.html.erb
516
543
  - app/views/studio/emails/_recipient_picker.html.erb
517
- - app/views/studio/emails/_recipient_repaint.html.erb
518
544
  - app/views/studio/emails/_row.html.erb
519
545
  - app/views/studio/emails/index.html.erb
520
546
  - app/views/studio/emails/orphan.html.erb
@@ -601,7 +627,6 @@ files:
601
627
  - app/views/studio/site_footer/_social_icon.html.erb
602
628
  - app/views/studio/site_identities/edit.html.erb
603
629
  - app/views/studio/surveys/_question.html.erb
604
- - app/views/studio/surveys/_script.html.erb
605
630
  - app/views/studio/surveys/_styles.html.erb
606
631
  - app/views/studio/surveys/not_found.html.erb
607
632
  - app/views/studio/surveys/show.html.erb
@@ -1,122 +0,0 @@
1
- <%# Repaints EVERY row on the emails list when the example recipient changes.
2
-
3
- The list shows each email's banner and subject as they would arrive. Both are
4
- per-recipient — the banner greets by first name and the subject may too — so
5
- a list rendered for one person is a list of half-truths about everyone else.
6
- Changing the selection has to repaint all of them.
7
-
8
- WHY THE TEMPLATES RIDE ON THE ROW
9
- Each row carries its own header / fallback / subject templates as data
10
- attributes, and this reads them from the DOM. The alternative was a second
11
- JSON payload listing every email, which would have to be kept in step with
12
- the rows themselves — and a row whose payload entry went missing would
13
- silently stop repainting while still looking correct for whoever was selected
14
- at page load.
15
-
16
- The placeholder rule is the same one Studio::Banner applies server-side, and
17
- the same one the detail page's editor applies. It is small, and it is pinned
18
- on both sides. If they disagree, the server is right. %>
19
- <script>
20
- (function () {
21
- function recipients(config) {
22
- return {
23
- targets: config.targets || [],
24
- targetId: config.targetId,
25
- pickerOpen: false,
26
-
27
- init() {
28
- this.paint();
29
- this.$watch("targetId", () => this.paint());
30
- },
31
-
32
- target() {
33
- return this.targets.find((t) => t.id === this.targetId) || this.targets[0] || {};
34
- },
35
-
36
- firstName() {
37
- return (this.target().name || "").trim().split(/\s+/)[0] || "";
38
- },
39
-
40
- resolve(template, fallback) {
41
- var first = this.firstName();
42
- var app = config.appName || "";
43
- var text = (template || "").replace(/\{app\}/g, app);
44
- if (first) return text.replace(/\{name\}/g, first);
45
- if (text.indexOf("{name}") !== -1) return (fallback || "").replace(/\{app\}/g, app);
46
- return text;
47
- },
48
-
49
- // A subject has no second field for the nameless case, so an unresolved
50
- // placeholder is removed along with the punctuation holding it — the
51
- // same two passes Studio::Banner.interpolate makes, so "Sign in, {name}"
52
- // reads "Sign in" here exactly as it would in an inbox.
53
- resolveSubject(template) {
54
- var app = config.appName || "";
55
- var text = (template || "").replace(/\{app\}/g, app);
56
- var first = this.firstName();
57
- if (first) return text.replace(/\{name\}/g, first);
58
- return text.replace(/[,;:—-]?\s*\{name\}/g, "")
59
- .replace(/^\s*[,;:—-]\s*/, "")
60
- .replace(/\s+/g, " ")
61
- .trim();
62
- },
63
-
64
- // A row's banner lives in an IFRAME — the email's table is its own
65
- // document so it cannot nest rows inside the list. Same-origin srcdoc,
66
- // so its nodes are reachable; contentDocument is null until the frame
67
- // has parsed, which is why the load listener is wired as well as the
68
- // immediate attempt.
69
- bannerNode(row, selector) {
70
- var frame = row.querySelector("iframe[data-email-banner-preview]");
71
- if (frame) {
72
- try { return frame.contentDocument && frame.contentDocument.querySelector(selector); }
73
- catch (_) { return null; }
74
- }
75
- return row.querySelector(selector);
76
- },
77
-
78
- paintRow(row) {
79
- var header = this.bannerNode(row, "[data-banner-header]");
80
- var subject = row.querySelector("[data-row-subject]");
81
- if (header) {
82
- header.textContent = this.resolve(row.dataset.header, row.dataset.headerFallback);
83
- }
84
- if (subject) subject.textContent = this.resolveSubject(row.dataset.subject);
85
- },
86
-
87
- paint() {
88
- var self = this;
89
- this.$root.querySelectorAll("[data-email-row]").forEach(function (row) {
90
- self.paintRow(row);
91
- var frame = row.querySelector("iframe[data-email-banner-preview]");
92
- if (frame && !frame.dataset.repaintBound) {
93
- frame.dataset.repaintBound = "1";
94
- frame.addEventListener("load", function () { self.paintRow(row); });
95
- }
96
- });
97
- },
98
-
99
- // Anything interactive keeps its own behaviour; everything else opens
100
- // the email. Without the closest() guard, clicking Upload would both
101
- // open the cropper and navigate away from it.
102
- openRow(event, row) {
103
- // The listener sits on the tbody, so a click can land between rows and
104
- // arrive with no row at all.
105
- if (!row) return;
106
- if (event.target.closest("a, button, input, label, form")) return;
107
- var path = row.dataset.emailPath;
108
- if (path) window.location = path;
109
- }
110
- };
111
- }
112
-
113
- window.emailRecipients = recipients;
114
- if (typeof Alpine !== "undefined") {
115
- Alpine.data("emailRecipients", recipients);
116
- } else {
117
- document.addEventListener("alpine:init", function () {
118
- Alpine.data("emailRecipients", recipients);
119
- });
120
- }
121
- })();
122
- </script>