studio-engine 0.33.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,7 +21,10 @@ class UserMailer < ApplicationMailer
21
21
  @app_name = Studio.app_name
22
22
  @email = email
23
23
  @magic_url = magic_link_url_for(token)
24
- @banner_url = Studio::EmailImage.url(:magic_link) # admin-managed; nil renders bannerless
24
+ # resolved_url, not url: this app's own upload if it has one, otherwise the
25
+ # engine's default banner — which is what makes a brand-new app's sign-in
26
+ # email branded on day one. nil renders bannerless.
27
+ @banner_url = Studio::EmailImage.resolved_url(:magic_link)
25
28
  @banner_alt = "Your #{@app_name} sign-in link"
26
29
  mail(to: email, subject: "Your #{@app_name} sign-in link")
27
30
  end
@@ -1,64 +1,263 @@
1
1
  module Studio
2
- # Admin-managed banner images for transactional emails. One image per "variant"
3
- # (the email type), uploaded to S3 with a stable PUBLIC url via Studio::S3 and
4
- # tracked by an owner-less ImageCache row (purpose "email_banner"). The branded
5
- # mailer resolves the current banner with .url; the admin email-image page
6
- # writes it with .store.
2
+ # The transactional-email registry, and the banner image each registered email
3
+ # ships with.
7
4
  #
8
- # VARIANTS is the registry magic_link now; adding an entry is all it takes to
9
- # admin-manage another email's header image (the "extensible" part of the
10
- # magic-link-now-extensible scope).
5
+ # A registered email is MOSTLY SYMBOLIC of a workflow a key, a human label,
6
+ # a line of description. The only real asset is its banner image, which is why
7
+ # the registry lives here rather than in a model. The branded mailer resolves
8
+ # the live banner with .url; /admin/emails lists the registry and writes an
9
+ # override with .store.
10
+ #
11
+ # ## Two layers: inherited default, app-owned override
12
+ #
13
+ # .resolved_url(key) => app's own ImageCache row (its S3 bucket) # app-owned
14
+ # -> the engine's default gem asset # inherited
15
+ # -> nil # no image
16
+ #
17
+ # `.url(key)` stays the PRE-REGISTRY contract — this app's own image or nil —
18
+ # so every caller written before the registry keeps its behavior until its app
19
+ # adopts. See the note on #url; getting this wrong swaps a host's committed
20
+ # artwork for the engine placeholder in live email.
21
+ #
22
+ # Defaults RIDE THE GEM (app/assets/images/emails/*), so a brand-new app with
23
+ # an empty bucket sends good-looking email on day one and needs no cross-app S3
24
+ # permission. Uploading on an app's /admin/emails writes to THAT app's bucket
25
+ # and THAT app's ImageCache row — which is exactly "the asset now belongs to
26
+ # this app". Every app has its own bucket and its own image_caches table, so an
27
+ # override never leaks between apps.
28
+ #
29
+ # ## Registering
30
+ #
31
+ # The engine pre-registers the two every Studio app sends (STANDARD below), so
32
+ # hosts inherit them without declaring anything. A host adds its own workflows
33
+ # from an initializer, mirroring Studio::ModelPage.register:
34
+ #
35
+ # # config/initializers/studio_emails.rb
36
+ # Rails.application.config.to_prepare do
37
+ # Studio::EmailImage.register("winnings", label: "Contest winnings",
38
+ # description: "Sent when a player wins a contest.")
39
+ # end
40
+ #
41
+ # Re-registering a key updates it in place and keeps its position, so a host
42
+ # can relabel an inherited email without reordering the page.
11
43
  module EmailImage
12
44
  PURPOSE = "email_banner".freeze
13
45
 
14
- # variant => human label, in admin display order.
15
- VARIANTS = {
16
- "magic_link" => "Magic-link sign-in"
17
- }.freeze
46
+ # A registered email. `default_asset` is a logical asset path inside the gem
47
+ # (resolved through the host's asset pipeline); nil means the email has no
48
+ # inherited artwork and renders bannerless until someone uploads one.
49
+ Entry = Struct.new(:key, :label, :description, :default_asset, keyword_init: true) do
50
+ def to_s = key
51
+ end
52
+
53
+ # The emails EVERY Studio app sends. Pre-registered, so a host inherits both
54
+ # without declaring anything.
55
+ STANDARD = [
56
+ {
57
+ key: "magic_link",
58
+ label: "Magic-link sign-in",
59
+ description: "Passwordless sign-in link. Sent whenever someone asks to sign in by email.",
60
+ default_asset: "emails/magic-link.png"
61
+ },
62
+ {
63
+ key: "email_change_confirmation",
64
+ label: "Email change confirmation",
65
+ description: "Confirms a new address before the change takes effect.",
66
+ default_asset: "emails/email-change-confirmation.png"
67
+ }
68
+ ].freeze
69
+
70
+ # Banners render full-bleed at 600px in a 600px card. 1200x600 is the
71
+ # right cut: 2:1, retina-sharp at render width, and small enough to stay
72
+ # out of an inbox clipping limit.
73
+ ASPECT_RATIO = 2.0
74
+ MAX_WIDTH = 1200
18
75
 
19
76
  module_function
20
77
 
78
+ # --- Registry ----------------------------------------------------------
79
+
80
+ # Register (or update) an email workflow. Returns the key.
81
+ def register(key, label: nil, description: nil, default_asset: nil)
82
+ key = key.to_s
83
+ existing = registry[key]
84
+ registry[key] = Entry.new(
85
+ key: key,
86
+ label: label || existing&.label || key.humanize,
87
+ description: description || existing&.description,
88
+ default_asset: default_asset.nil? ? existing&.default_asset : default_asset.presence
89
+ )
90
+ key
91
+ end
92
+
93
+ # Every registered email, in display order: the standard two first, then the
94
+ # host's own in declaration order.
95
+ def entries
96
+ registry.values
97
+ end
98
+
99
+ def entry(key)
100
+ registry[key.to_s]
101
+ end
102
+
103
+ def keys
104
+ registry.keys
105
+ end
106
+
107
+ def known?(key)
108
+ registry.key?(key.to_s)
109
+ end
110
+ def registered?(key) = known?(key)
111
+
112
+ def label(key)
113
+ entry(key)&.label || key.to_s.humanize
114
+ end
115
+
116
+ # Legacy shape — key => label. Kept because it is the API the pre-registry
117
+ # admin page and any host that read VARIANTS were written against.
21
118
  def variants
22
- VARIANTS
119
+ registry.transform_values(&:label)
23
120
  end
24
121
 
25
- def label(variant)
26
- VARIANTS[variant.to_s] || variant.to_s.humanize
122
+ # Drops host registrations back to the standard two. For tests and for
123
+ # to_prepare re-registration.
124
+ def reset!
125
+ @registry = nil
126
+ registry
127
+ nil
128
+ end
129
+
130
+ def registry
131
+ @registry ||= STANDARD.each_with_object({}) do |attrs, out|
132
+ out[attrs[:key]] = Entry.new(**attrs)
133
+ end
27
134
  end
28
135
 
29
- def known?(variant)
30
- VARIANTS.key?(variant.to_s)
136
+ # --- Resolution --------------------------------------------------------
137
+
138
+ # Where the live banner for this email comes from:
139
+ # :app — this app uploaded its own (ImageCache row in its bucket)
140
+ # :default — the inherited engine default (gem asset)
141
+ # :none — no image at all; the email sends bannerless
142
+ def source(key)
143
+ return :app if record(key)
144
+ return :default if default_asset_path(key)
145
+
146
+ :none
31
147
  end
32
148
 
33
- # The ImageCache row for this variant, or nil (no banner uploaded / table not
34
- # installed yet). Nil-safe so the mailer renders bannerless before any upload.
35
- def record(variant)
149
+ def app_owned?(key) = source(key) == :app
150
+
151
+ # THIS APP'S OWN image only — nil when nothing has been uploaded here.
152
+ #
153
+ # This is the PRE-REGISTRY contract, kept EXACTLY: `url` has always meant
154
+ # "the admin-managed override, or nil", and callers were written to fall back
155
+ # themselves. turf-monster's mailer is the live example:
156
+ #
157
+ # @banner_url = Studio::EmailImage.url(:magic_link) || email_banner_url("magic-link-banner.jpg")
158
+ #
159
+ # Making `url` resolve to the engine default would make that `||` dead code
160
+ # and silently replace turf-monster's own branded 1200x600 banner with the
161
+ # engine's PLACEHOLDER in real sign-in email. A method whose signature is
162
+ # unchanged but whose return value flips from nil to a value is not additive.
163
+ # So the new two-layer resolution lives in resolved_url, and every existing
164
+ # caller keeps the behavior it was written against until its app adopts.
165
+ def url(key)
166
+ record(key)&.url
167
+ end
168
+
169
+ # What ACTUALLY SHIPS on this email — the two-layer resolution. Absolute, so
170
+ # it resolves from an inbox. App-owned override first, then the inherited
171
+ # engine default, then nil (the mailer renders bannerless).
172
+ #
173
+ # This is what a mailer should call once its app has adopted the registry.
174
+ # The engine's own UserMailer already does, which is what gives an app with
175
+ # an empty bucket branded email on day one.
176
+ def resolved_url(key)
177
+ url(key) || default_url(key)
178
+ end
179
+
180
+ # What the ADMIN PAGE previews. Same two layers as resolved_url, but a
181
+ # default stays a root-relative asset path so it renders correctly on
182
+ # whatever host and port this app is being viewed on (an absolute mailer
183
+ # asset_host is set for the inbox, not for a browser on localhost:3042).
184
+ def preview_url(key)
185
+ url(key) || default_asset_path(key)
186
+ end
187
+
188
+ # The ImageCache row holding this app's override, or nil (nothing uploaded /
189
+ # table not installed yet). Nil-safe so the mailer renders before any upload.
190
+ def record(key)
36
191
  return nil unless table_ready?
37
192
 
38
- ::ImageCache.find_by(owner: nil, purpose: PURPOSE, variant: variant.to_s)
193
+ ::ImageCache.find_by(owner: nil, purpose: PURPOSE, variant: key.to_s)
194
+ end
195
+
196
+ # Root-relative path to the inherited default asset, or nil when the email
197
+ # has no default registered or the host's pipeline cannot resolve it.
198
+ def default_asset_path(key)
199
+ asset = entry(key)&.default_asset
200
+ return nil if asset.nil? || asset.empty?
201
+
202
+ path = ActionController::Base.helpers.asset_path(asset)
203
+ path.presence
204
+ rescue StandardError
205
+ nil
206
+ end
207
+
208
+ # Absolute URL to the inherited default asset — what a mailer needs. Uses
209
+ # action_mailer.asset_host (set per env), falling back to the mailer's
210
+ # default_url_options host. Returns the bare path if neither is configured,
211
+ # which still renders in the local inbox preview.
212
+ def default_url(key)
213
+ path = default_asset_path(key)
214
+ return nil if path.nil?
215
+ return path if path.start_with?("http")
216
+
217
+ host = mailer_asset_host
218
+ host ? "#{host}#{path}" : path
39
219
  end
40
220
 
41
- # Permanent public S3 url for the current banner, or nil.
42
- def url(variant)
43
- record(variant)&.url
221
+ # --- Upload ------------------------------------------------------------
222
+
223
+ # Whether THIS app can accept an upload. False when the host never set
224
+ # Studio.s3_bucket_prefix — /admin/emails then shows inherited defaults
225
+ # read-only rather than 500ing on the first upload.
226
+ def uploads_available?
227
+ Studio::S3.configured? && table_ready?
44
228
  end
45
229
 
46
- # Upload bytes to S3 + upsert the ImageCache row (replacing any prior object).
47
- # Returns the ::ImageCache. Raises on failure after cleaning up the new object.
48
- def store(variant, io:, content_type: nil)
49
- key = "email_banners/#{variant}-#{SecureRandom.hex(4)}#{ext_for(content_type)}"
50
- Studio::S3.upload(key: key, body: io.read, content_type: content_type,
230
+ # Upload bytes to this app's bucket + upsert its ImageCache row (replacing
231
+ # any prior object). Returns the ::ImageCache. Raises on failure after
232
+ # cleaning up the new object.
233
+ def store(key, io:, content_type: nil)
234
+ s3_key = "email_banners/#{key}-#{SecureRandom.hex(4)}#{ext_for(content_type)}"
235
+ Studio::S3.upload(key: s3_key, body: io.read, content_type: content_type,
51
236
  cache_control: "public, max-age=300")
52
- record = ::ImageCache.find_or_initialize_by(owner: nil, purpose: PURPOSE, variant: variant.to_s)
237
+ record = ::ImageCache.find_or_initialize_by(owner: nil, purpose: PURPOSE, variant: key.to_s)
53
238
  previous = record.s3_key
54
- record.update!(s3_key: key)
55
- delete_object(previous) if previous.present? && previous != key
239
+ record.update!(s3_key: s3_key)
240
+ delete_object(previous) if previous.present? && previous != s3_key
56
241
  record
57
242
  rescue StandardError
58
- delete_object(key)
243
+ delete_object(s3_key)
59
244
  raise
60
245
  end
61
246
 
247
+ # Drop this app's override and fall back to the inherited default. Returns
248
+ # true when a row was removed.
249
+ def revert(key)
250
+ row = record(key)
251
+ return false if row.nil?
252
+
253
+ previous = row.s3_key
254
+ row.destroy!
255
+ delete_object(previous) if previous.present?
256
+ true
257
+ end
258
+
259
+ # --- Internals ---------------------------------------------------------
260
+
62
261
  # Reference ImageCache directly so Zeitwerk autoloads it — defined?() does NOT
63
262
  # trigger autoload, so it would read "undefined" for a not-yet-loaded const.
64
263
  def table_ready?
@@ -67,6 +266,51 @@ module Studio
67
266
  false
68
267
  end
69
268
 
269
+ # The origin an email's banner URL hangs off. action_mailer.asset_host when
270
+ # the host sets one (turf-monster does, per env); otherwise built from the
271
+ # mailer's default_url_options.
272
+ #
273
+ # That fallback has to reconstruct a real origin, not just the hostname.
274
+ # default_url_options is routinely {host: "localhost", port: 3001} — taking
275
+ # :host alone and prefixing "https://" yields https://localhost, which is the
276
+ # wrong scheme AND the wrong port, and the banner comes back
277
+ # ERR_CONNECTION_REFUSED. Caught by opening the preview page on a worktree
278
+ # stack; every dev/QA preview took that path.
279
+ def mailer_asset_host
280
+ configured = Rails.application.config.action_mailer.asset_host.presence
281
+ return configured if configured
282
+
283
+ options = ActionMailer::Base.default_url_options || {}
284
+ host = options[:host].presence
285
+ return nil if host.nil?
286
+ return host if host.start_with?("http")
287
+
288
+ "#{mailer_protocol(options, host)}://#{host}#{mailer_port_suffix(options)}"
289
+ rescue StandardError
290
+ nil
291
+ end
292
+
293
+ # Honor an explicit :protocol. Otherwise https — EXCEPT on loopback, which is
294
+ # a dev stack with no TLS. Defaulting the other way would downgrade every
295
+ # production app that sets only {host: "mcritchie.studio"}.
296
+ def mailer_protocol(options, host)
297
+ explicit = options[:protocol].presence
298
+ return explicit.to_s.sub(%r{://\z}, "") if explicit
299
+
300
+ LOOPBACK_HOSTS.include?(host.downcase) ? "http" : "https"
301
+ end
302
+
303
+ # Ports are part of the origin, and omitting one sends the reader to :443.
304
+ # The scheme defaults are left off so a normal URL stays normal.
305
+ def mailer_port_suffix(options)
306
+ port = options[:port]
307
+ return "" if port.blank? || [80, 443].include?(port.to_i)
308
+
309
+ ":#{port}"
310
+ end
311
+
312
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 0.0.0.0 ::1].freeze
313
+
70
314
  def ext_for(content_type)
71
315
  case content_type.to_s
72
316
  when %r{png} then ".png"
@@ -1,3 +1,6 @@
1
+ <%# DEPRECATED page — superseded by /admin/emails (studio/emails/index).
2
+ Kept for one release so consumer suites on their default branch keep passing;
3
+ see Studio::EmailImagesController for the full staging note. %>
1
4
  <% content_for(:title) { "Email images" } %>
2
5
  <div class="max-w-2xl mx-auto px-4 py-8">
3
6
  <header class="mb-6">
@@ -8,6 +11,20 @@
8
11
  </p>
9
12
  </header>
10
13
 
14
+ <%# Only when this app actually DREW /admin/emails — it is opt-in, and pointing
15
+ at a route that does not exist raises NameError and 500s this page. %>
16
+ <% if successor_path %>
17
+ <div class="mb-6 rounded-xl border border-subtle bg-inset px-4 py-3">
18
+ <p class="text-sm font-semibold text-heading">There's a better page now</p>
19
+ <p class="text-sm text-body mt-1">
20
+ <%= link_to "Emails", successor_path, class: "underline underline-offset-2" %>
21
+ lists every email this app sends with its live banner, says whether that
22
+ banner is the shared default or this app's own, and crops on upload.
23
+ This page is going away.
24
+ </p>
25
+ </div>
26
+ <% end %>
27
+
11
28
  <% flash.each do |type, message| %>
12
29
  <div class="mb-4 rounded-lg px-4 py-3 text-sm <%= type.to_s == "alert" ? "bg-danger/10 text-danger" : "bg-success/10 text-success" %>">
13
30
  <%= message %>
@@ -16,7 +33,12 @@
16
33
 
17
34
  <div class="space-y-6">
18
35
  <% @variants.each do |variant, label| %>
19
- <% current_url = Studio::EmailImage.url(variant) %>
36
+ <%# preview_url, not url: this page's ORIGINAL BUG was reading only the S3
37
+ override, so it announced "No image yet" for an email that was visibly
38
+ sending a banner from a committed repo asset. Even on its way out it
39
+ should tell the truth about what ships. %>
40
+ <% current_url = Studio::EmailImage.preview_url(variant) %>
41
+ <% inherited = current_url.present? && Studio::EmailImage.source(variant) == :default %>
20
42
  <section class="rounded-xl border border-subtle p-5">
21
43
  <h2 class="font-semibold mb-3"><%= label %></h2>
22
44
 
@@ -30,6 +52,13 @@
30
52
  <% end %>
31
53
  </div>
32
54
 
55
+ <% if inherited %>
56
+ <p class="text-xs text-muted mb-3">
57
+ This is the shared default that ships with the engine. Uploading here
58
+ makes the image this app's own.
59
+ </p>
60
+ <% end %>
61
+
33
62
  <%= form_with url: admin_email_image_path(variant), method: :patch, html: { multipart: true }, class: "flex flex-wrap items-center gap-3" do %>
34
63
  <%= file_field_tag :image, accept: "image/png,image/jpeg,image/webp",
35
64
  class: "text-sm file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-white file:cursor-pointer" %>
@@ -0,0 +1,102 @@
1
+ <%# locals: (entry:, uploads_available:, aspect:, max_width:)
2
+ One registered email: its live banner, its name, and where that banner comes
3
+ from. The whole row is the imageUploadHost x-data so the thumbnail, the Edit
4
+ button, and the hidden multipart form the cropper submits all sit together —
5
+ the same unit turf-monster's og:image uploader uses, on the page-scoped
6
+ `emailModals` store. %>
7
+ <%
8
+ source = Studio::EmailImage.source(entry.key)
9
+ banner_url = Studio::EmailImage.preview_url(entry.key)
10
+ form_id = "email-banner-form-#{entry.key.dasherize}"
11
+
12
+ # `badge` is a shape-only utility in engine.css — the state color comes from
13
+ # theme-token utilities so it follows each app's palette in light and dark.
14
+ badge_class, badge_label, badge_note =
15
+ case source
16
+ when :app
17
+ ["badge bg-success/10 text-success border-success/30", "#{Studio.app_name}'s own",
18
+ "Uploaded here — this app owns it."]
19
+ when :default
20
+ ["badge bg-inset text-muted border-subtle", "Inherited default",
21
+ "Shared Studio artwork, shipped with the engine."]
22
+ else
23
+ ["badge bg-warning/10 text-warning border-warning/30", "No image",
24
+ "This email sends without a banner."]
25
+ end
26
+ %>
27
+ <%# The uploader host is attached ONLY when this app can actually store an
28
+ override. On a read-only app the cropper assets are not loaded either, so an
29
+ unconditional x-data would reference an undefined factory and Alpine would
30
+ throw on every row. %>
31
+ <tr class="border-b border-subtle last:border-0"
32
+ <% if uploads_available %>
33
+ x-data="imageUploadHost({
34
+ store: 'emailModals',
35
+ aspectRatio: <%= aspect %>,
36
+ maxWidth: <%= max_width %>,
37
+ transparent: false,
38
+ filename: '<%= entry.key %>.png',
39
+ saving: 'Saving banner…',
40
+ success: 'Banner updated',
41
+ successMessage: '<%= j entry.label %> now uses this app\'s own image.',
42
+ failure: 'Couldn\'t save the banner'
43
+ })"
44
+ @crop-photo-confirmed.window="onCropConfirmed($event.detail)"
45
+ <% end %>>
46
+
47
+ <td class="px-4 py-4">
48
+ <div class="rounded-lg overflow-hidden border border-subtle w-40"
49
+ style="aspect-ratio: <%= aspect %>; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
50
+ <% if banner_url %>
51
+ <%= image_tag banner_url, class: "w-full h-full object-cover",
52
+ alt: "#{entry.label} email banner", loading: "lazy" %>
53
+ <% else %>
54
+ <div class="w-full h-full flex items-center justify-center text-2xs text-white/80 text-center px-3">
55
+ No banner
56
+ </div>
57
+ <% end %>
58
+ </div>
59
+ </td>
60
+
61
+ <td class="px-4 py-4">
62
+ <p class="font-semibold text-heading"><%= entry.label %></p>
63
+ <% if entry.description.present? %>
64
+ <p class="text-sm text-body mt-0.5 max-w-md"><%= entry.description %></p>
65
+ <% end %>
66
+ <p class="font-mono text-2xs text-muted mt-1"><%= entry.key %></p>
67
+ </td>
68
+
69
+ <%# whitespace-nowrap keeps the pill a pill — "<App>'s own" is long enough to
70
+ wrap inside a bordered badge, which reads as a broken box next to the
71
+ short "Inherited default". %>
72
+ <td class="px-4 py-4">
73
+ <span class="<%= badge_class %> whitespace-nowrap"><%= badge_label %></span>
74
+ <p class="text-2xs text-muted mt-1.5 max-w-[12rem]"><%= badge_note %></p>
75
+ </td>
76
+
77
+ <% if uploads_available %>
78
+ <td class="px-4 py-4 text-right whitespace-nowrap">
79
+ <button type="button" @click="open()" class="btn btn-outline btn-sm inline-flex items-center gap-1.5">
80
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
81
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
82
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
83
+ </svg>
84
+ <%= source == :app ? "Replace" : "Upload" %>
85
+ </button>
86
+
87
+ <%# Only an APP-OWNED image can be reverted — there is nothing to drop when
88
+ the row is already showing the inherited default. %>
89
+ <% if source == :app %>
90
+ <%= button_to "Revert", admin_email_path(entry.key), method: :delete,
91
+ class: "btn btn-neutral btn-sm mt-1.5 w-full",
92
+ form: { data: { turbo_confirm: "Drop #{Studio.app_name}'s own #{entry.label} banner and go back to the shared default?" } } %>
93
+ <% end %>
94
+
95
+ <%# The cropper drops its Blob into this hidden input and submits. %>
96
+ <%= form_with url: admin_email_path(entry.key), method: :patch,
97
+ html: { multipart: true, id: form_id, "x-ref": "form" } do %>
98
+ <%= file_field_tag :image, class: "hidden", "x-ref": "fileInput" %>
99
+ <% end %>
100
+ </td>
101
+ <% end %>
102
+ </tr>
@@ -0,0 +1,116 @@
1
+ <%# admin/emails — the standard transactional-email page.
2
+
3
+ A bare content wrapper (no `layout` call), like the living style guide at
4
+ /admin/style: it renders inside each host's application.html.erb and
5
+ inherits that app's navbar and theme, so one shared engine page looks
6
+ native in every app.
7
+
8
+ The table is the primary view — one row per registered email, each carrying
9
+ the NAME and the LIVE IMAGE so an email is identifiable at a glance, plus
10
+ whether that image is the INHERITED engine default or an APP-OWNED
11
+ override. Editing goes through the shared crop modal (studio/cropper_assets
12
+ + the page-scoped host at the bottom), never a bare file input.
13
+
14
+ Page-scoped modal host: the crop + saving modals mount on `emailModals`
15
+ rather than the app's shared `modals` store, so this page works identically
16
+ in an app that renders a shared modal host (mcritchie-studio, turf-monster)
17
+ and one that renders none at all (mcritchie-industries, moms-app). It uses
18
+ studio/modals/scoped_host, NOT studio/modals/host — the latter is forked by
19
+ mcritchie-studio and turf-monster, and an app fork shadows the engine's
20
+ partial in this non-isolated engine. %>
21
+ <% content_for(:title) { "Emails" } %>
22
+ <%
23
+ aspect = Studio::EmailImage::ASPECT_RATIO
24
+ max_width = Studio::EmailImage::MAX_WIDTH
25
+ app_owned = @entries.count { |entry| Studio::EmailImage.source(entry.key) == :app }
26
+ %>
27
+ <div class="max-w-5xl mx-auto px-4 pb-16">
28
+ <header class="space-y-2 pt-8 pb-6">
29
+ <p class="label-upper">studio-engine &middot; v<%= Studio::VERSION %></p>
30
+ <h1 class="text-3xl font-bold text-heading">Emails</h1>
31
+ <p class="text-body max-w-2xl">
32
+ Every transactional email <%= Studio.app_name %> sends, and the banner that
33
+ rides at the top of it. Each one starts on the shared Studio artwork; upload
34
+ your own and it belongs to this app from then on.
35
+ </p>
36
+ <p class="text-sm text-muted">
37
+ <%= pluralize(@entries.size, "email") %> registered &middot;
38
+ <%= app_owned.zero? ? "all inheriting the default artwork" : "#{app_owned} with #{Studio.app_name}'s own artwork" %>
39
+ </p>
40
+ </header>
41
+
42
+ <%# No flash block here on purpose — every host layout already renders the
43
+ shared layouts/studio/flash partial, so the controller's notice/alert
44
+ surfaces once rather than twice. %>
45
+
46
+ <%# Honest degradation: an app whose host never set Studio.s3_bucket_prefix can
47
+ SHOW what each email is sending but cannot store an override. Say so plainly
48
+ instead of offering an Edit button that 500s. %>
49
+ <% unless @uploads_available %>
50
+ <div class="mb-6 rounded-xl border border-subtle bg-inset px-4 py-3">
51
+ <p class="text-sm font-semibold text-heading">Read-only on this app</p>
52
+ <p class="text-sm text-body mt-1">
53
+ <%= Studio.app_name %> has no object storage configured, so the images below
54
+ can be viewed but not replaced yet. They are the shared defaults that ship
55
+ with the engine, and they are what this app is sending right now. Set
56
+ <code class="font-mono text-2xs bg-surface px-1.5 py-0.5 rounded">config.s3_bucket_prefix</code>
57
+ in <code class="font-mono text-2xs bg-surface px-1.5 py-0.5 rounded">config/initializers/studio.rb</code>
58
+ to turn on uploads.
59
+ </p>
60
+ </div>
61
+ <% end %>
62
+
63
+ <div class="card overflow-hidden">
64
+ <div class="overflow-x-auto">
65
+ <table class="w-full text-left align-middle">
66
+ <thead>
67
+ <tr class="border-b border-subtle">
68
+ <th class="label-upper px-4 py-3 w-48">Banner</th>
69
+ <th class="label-upper px-4 py-3">Email</th>
70
+ <th class="label-upper px-4 py-3 w-52">Image</th>
71
+ <% if @uploads_available %>
72
+ <th class="label-upper px-4 py-3 w-40 text-right">Actions</th>
73
+ <% end %>
74
+ </tr>
75
+ </thead>
76
+ <tbody>
77
+ <% @entries.each do |entry| %>
78
+ <%= render "studio/emails/row", entry: entry,
79
+ uploads_available: @uploads_available,
80
+ aspect: aspect, max_width: max_width %>
81
+ <% end %>
82
+ </tbody>
83
+ </table>
84
+ </div>
85
+ </div>
86
+
87
+ <p class="text-xs text-muted mt-4">
88
+ Banners render full-bleed at 600px wide inside the email card. The crop is
89
+ fixed at <%= aspect.to_i %>:1 and saved at up to <%= max_width %>px, which is
90
+ retina-sharp in an inbox without tripping a clipping limit.
91
+ <% if @entries.any? { |entry| Studio::EmailImage.source(entry.key) == :none } %>
92
+ An email with no image sends bannerless — the card still renders.
93
+ <% end %>
94
+ </p>
95
+ </div>
96
+
97
+ <% if @uploads_available %>
98
+ <%# Cropper.js + the imageUploadHost / cropPhotoModal / submitFormWithProgress
99
+ factories, loaded only on this page. %>
100
+ <%= render "studio/cropper_assets" %>
101
+
102
+ <%# Page-scoped modal host — see the header note. Brings its own crop-photo +
103
+ saving modals so no host app has to register them. Deliberately NOT
104
+ studio/modals/host: mcritchie-studio and turf-monster ship app copies of
105
+ that path which shadow the engine's, so the page would get their fork. %>
106
+ <%# Optional chaining on current() is load-bearing: the outer template unmounts
107
+ one tick AFTER the stack empties, so a bare .id throws on every close. %>
108
+ <%= render "studio/modals/scoped_host", store: "emailModals" do %>
109
+ <template x-if="$store.emailModals.current()?.id === 'crop-photo'">
110
+ <div><%= render "studio/modals/crop_photo", store: "emailModals" %></div>
111
+ </template>
112
+ <template x-if="$store.emailModals.current()?.id === 'saving'">
113
+ <div><%= render "studio/modals/saving", store: "emailModals" %></div>
114
+ </template>
115
+ <% end %>
116
+ <% end %>