studio-engine 0.37.0 → 0.38.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.
@@ -1,329 +1,68 @@
1
1
  module Studio
2
- # The transactional-email registry, and the banner image each registered email
3
- # ships with.
2
+ # DEPRECATED NAME this is Studio::EmailCatalog now. Every method here just
3
+ # forwards; there is no behavior in this file.
4
4
  #
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.
5
+ # The METHOD surface is kept complete, for two reasons:
10
6
  #
11
- # ## Two layers: inherited default, app-owned override
7
+ # 1. Engine 0.33 shipped this module as the registry's public name, so any
8
+ # app already on 0.33 is calling it.
9
+ # 2. consumer-ci.yml runs each consumer's DEFAULT BRANCH suite against an
10
+ # engine PR, and mcritchie-studio's test/integration/studio_email_image_test.rb
11
+ # on `main` calls .url, .store and .record directly. Dropping any of them
12
+ # reddens that lane from the moment the PR opens, and nothing inside the
13
+ # engine PR can reach the consumer's main to fix it.
12
14
  #
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
15
+ # ONE thing did not survive: the `VARIANTS` CONSTANT. It was a frozen literal
16
+ # hash, and the registry it stood for is now built at runtime, so a constant
17
+ # cannot express it. The `variants` METHOD returns the same key => label shape
18
+ # and is the supported replacement. Checked before dropping it: no consumer's
19
+ # `main` names the constant — only .url, .store and .record — so consumer CI
20
+ # stays green. A host that did reference it gets a NameError, not a silent
21
+ # wrong answer.
16
22
  #
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.
23
+ # Delete it once no consumer's main names it the same staged retirement
24
+ # /admin/email_images is on.
21
25
  #
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.
26
+ # Deliberately explicit rather than method_missing: a typo should still raise
27
+ # NoMethodError here, and the delegated surface should be readable as a list.
43
28
  module EmailImage
44
- PURPOSE = "email_banner".freeze
45
-
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
29
+ PURPOSE = EmailCatalog::PURPOSE
30
+ ASPECT_RATIO = EmailCatalog::ASPECT_RATIO
31
+ MAX_WIDTH = EmailCatalog::MAX_WIDTH
75
32
 
76
33
  module_function
77
34
 
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.
118
- def variants
119
- registry.transform_values(&:label)
120
- end
121
-
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
134
- end
135
-
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
147
- end
148
-
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)
191
- return nil unless table_ready?
192
-
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
219
- end
220
-
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?
228
- end
229
-
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,
236
- cache_control: "public, max-age=300")
237
- record = ::ImageCache.find_or_initialize_by(owner: nil, purpose: PURPOSE, variant: key.to_s)
238
- previous = record.s3_key
239
- record.update!(s3_key: s3_key)
240
- delete_object(previous) if previous.present? && previous != s3_key
241
- record
242
- rescue StandardError
243
- delete_object(s3_key)
244
- raise
245
- end
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
-
261
- # Reference ImageCache directly so Zeitwerk autoloads it — defined?() does NOT
262
- # trigger autoload, so it would read "undefined" for a not-yet-loaded const.
263
- def table_ready?
264
- ::ImageCache.table_exists?
265
- rescue NameError, ActiveRecord::ActiveRecordError
266
- false
267
- end
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
-
314
- def ext_for(content_type)
315
- case content_type.to_s
316
- when %r{png} then ".png"
317
- when %r{jpe?g} then ".jpg"
318
- when %r{webp} then ".webp"
319
- else ".png"
320
- end
321
- end
322
-
323
- def delete_object(key)
324
- Studio::S3.delete(key: key)
325
- rescue StandardError
326
- nil
327
- end
35
+ # Registry
36
+ def register(...) = EmailCatalog.register(...)
37
+ def entries = EmailCatalog.entries
38
+ def entry(key) = EmailCatalog.entry(key)
39
+ def keys = EmailCatalog.keys
40
+ def known?(key) = EmailCatalog.known?(key)
41
+ def registered?(key) = EmailCatalog.registered?(key)
42
+ def label(key) = EmailCatalog.label(key)
43
+ def variants = EmailCatalog.variants
44
+ def reset! = EmailCatalog.reset!
45
+
46
+ # Image resolution. `url` is the pre-registry contract — this app's own
47
+ # image or nil — and must stay that way; see the note on EmailCatalog#url.
48
+ def url(key) = EmailCatalog.url(key)
49
+ def resolved_url(key) = EmailCatalog.resolved_url(key)
50
+ def preview_url(key) = EmailCatalog.preview_url(key)
51
+ def source(key) = EmailCatalog.source(key)
52
+ def app_owned?(key) = EmailCatalog.app_owned?(key)
53
+ def record(key) = EmailCatalog.record(key)
54
+ def default_url(key) = EmailCatalog.default_url(key)
55
+ def default_asset_path(key) = EmailCatalog.default_asset_path(key)
56
+
57
+ # Writes
58
+ def store(key, io:, content_type: nil) = EmailCatalog.store(key, io: io, content_type: content_type)
59
+ def revert(key) = EmailCatalog.revert(key)
60
+ def uploads_available? = EmailCatalog.uploads_available?
61
+ def table_ready? = EmailCatalog.table_ready?
62
+
63
+ # Origin resolution. Delegated because 0.34's suite asserted these directly.
64
+ def mailer_asset_host = EmailCatalog.mailer_asset_host
65
+ def mailer_protocol(options, host) = EmailCatalog.mailer_protocol(options, host)
66
+ def mailer_port_suffix(options) = EmailCatalog.mailer_port_suffix(options)
328
67
  end
329
68
  end
@@ -1,6 +1,6 @@
1
1
  <%#
2
2
  Shared branded transactional email shell. A full-bleed banner (set @banner_url,
3
- e.g. from Studio::EmailImage.url(:magic_link)) sits flush at the top and sets
3
+ e.g. from Studio::EmailCatalog.resolved_url(:magic_link)) sits flush at the top and sets
4
4
  the 600px width; each email view supplies the body via yield. Bannerless is
5
5
  fine — the card still renders. Lifted from turf-monster so every Studio app
6
6
  shares one branded look. An app can override by defining its own
@@ -37,8 +37,8 @@
37
37
  override, so it announced "No image yet" for an email that was visibly
38
38
  sending a banner from a committed repo asset. Even on its way out it
39
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 %>
40
+ <% current_url = Studio::EmailCatalog.preview_url(variant) %>
41
+ <% inherited = current_url.present? && Studio::EmailCatalog.source(variant) == :default %>
42
42
  <section class="rounded-xl border border-subtle p-5">
43
43
  <h2 class="font-semibold mb-3"><%= label %></h2>
44
44
 
@@ -5,8 +5,8 @@
5
5
  the same unit turf-monster's og:image uploader uses, on the page-scoped
6
6
  `emailModals` store. %>
7
7
  <%
8
- source = Studio::EmailImage.source(entry.key)
9
- banner_url = Studio::EmailImage.preview_url(entry.key)
8
+ source = Studio::EmailCatalog.source(entry.key)
9
+ banner_url = Studio::EmailCatalog.preview_url(entry.key)
10
10
  form_id = "email-banner-form-#{entry.key.dasherize}"
11
11
 
12
12
  # `badge` is a shape-only utility in engine.css — the state color comes from
@@ -59,7 +59,12 @@
59
59
  </td>
60
60
 
61
61
  <td class="px-4 py-4">
62
- <p class="font-semibold text-heading"><%= entry.label %></p>
62
+ <%# The name is the link to the email's own page — banner + live preview. %>
63
+ <%= link_to entry.label, admin_email_path(entry.key),
64
+ class: "font-semibold text-heading hover:text-primary underline-offset-2 hover:underline" %>
65
+ <% if entry.marketing? %>
66
+ <span class="badge bg-primary/10 text-primary border-primary/30 ml-1.5 whitespace-nowrap">Marketing</span>
67
+ <% end %>
63
68
  <% if entry.description.present? %>
64
69
  <p class="text-sm text-body mt-0.5 max-w-md"><%= entry.description %></p>
65
70
  <% end %>
@@ -20,9 +20,9 @@
20
20
  partial in this non-isolated engine. %>
21
21
  <% content_for(:title) { "Emails" } %>
22
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 }
23
+ aspect = Studio::EmailCatalog::ASPECT_RATIO
24
+ max_width = Studio::EmailCatalog::MAX_WIDTH
25
+ app_owned = @entries.count { |entry| Studio::EmailCatalog.source(entry.key) == :app }
26
26
  %>
27
27
  <div class="max-w-5xl mx-auto px-4 pb-16">
28
28
  <header class="space-y-2 pt-8 pb-6">
@@ -88,7 +88,7 @@
88
88
  Banners render full-bleed at 600px wide inside the email card. The crop is
89
89
  fixed at <%= aspect.to_i %>:1 and saved at up to <%= max_width %>px, which is
90
90
  retina-sharp in an inbox without tripping a clipping limit.
91
- <% if @entries.any? { |entry| Studio::EmailImage.source(entry.key) == :none } %>
91
+ <% if @entries.any? { |entry| Studio::EmailCatalog.source(entry.key) == :none } %>
92
92
  An email with no image sends bannerless — the card still renders.
93
93
  <% end %>
94
94
  </p>
@@ -0,0 +1,91 @@
1
+ <%# admin/emails/:key — one email, previewed live.
2
+
3
+ A bare content wrapper like the index, so it renders inside each host's
4
+ layout. Lifted from turf-monster's admin/emails/show (the prior art this
5
+ work folds into the engine) and given the banner half: the same iframe over
6
+ the real rendered email, plus what artwork is riding on top of it and where
7
+ that artwork came from. %>
8
+ <% content_for(:title) { @entry.label } %>
9
+ <%
10
+ source = Studio::EmailCatalog.source(@entry.key)
11
+ banner_url = Studio::EmailCatalog.preview_url(@entry.key)
12
+ aspect = Studio::EmailCatalog::ASPECT_RATIO
13
+
14
+ badge_class, badge_label =
15
+ case source
16
+ when :app then ["badge bg-success/10 text-success border-success/30", "#{Studio.app_name}'s own"]
17
+ when :default then ["badge bg-inset text-muted border-subtle", "Inherited default"]
18
+ else ["badge bg-warning/10 text-warning border-warning/30", "No image"]
19
+ end
20
+ %>
21
+ <div class="max-w-5xl mx-auto px-4 pb-16">
22
+ <header class="pt-8 pb-5">
23
+ <%= link_to "← All emails", admin_emails_path,
24
+ class: "text-sm text-muted hover:text-heading underline underline-offset-2" %>
25
+ <h1 class="text-3xl font-bold text-heading mt-2"><%= @entry.label %></h1>
26
+
27
+ <div class="flex flex-wrap items-center gap-2 mt-3">
28
+ <span class="badge <%= @entry.marketing? ? "bg-primary/10 text-primary border-primary/30" : "bg-inset text-muted border-subtle" %> whitespace-nowrap">
29
+ <%= @entry.type.to_s.titleize %>
30
+ </span>
31
+ <span class="<%= badge_class %> whitespace-nowrap"><%= badge_label %></span>
32
+ <span class="font-mono text-2xs text-muted"><%= @entry.key %></span>
33
+ </div>
34
+
35
+ <% if @entry.description.present? %>
36
+ <p class="text-body max-w-2xl mt-3"><%= @entry.description %></p>
37
+ <% end %>
38
+
39
+ <% if @subject.present? %>
40
+ <p class="text-sm text-muted mt-2">
41
+ Subject: <strong class="text-heading"><%= @subject %></strong>
42
+ </p>
43
+ <% end %>
44
+ </header>
45
+
46
+ <%# The banner riding on top of this email, and where it came from. %>
47
+ <section class="card p-4 mb-6">
48
+ <p class="label-upper mb-3">Banner</p>
49
+ <div class="rounded-lg overflow-hidden border border-subtle max-w-md"
50
+ style="aspect-ratio: <%= aspect %>; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
51
+ <% if banner_url %>
52
+ <%= image_tag banner_url, class: "w-full h-full object-cover",
53
+ alt: "#{@entry.label} email banner" %>
54
+ <% else %>
55
+ <div class="w-full h-full flex items-center justify-center text-xs text-white/80 text-center px-4">
56
+ This email sends without a banner.
57
+ </div>
58
+ <% end %>
59
+ </div>
60
+ <p class="text-sm text-muted mt-3">
61
+ <% if @uploads_available %>
62
+ Change it on the <%= link_to "emails list", admin_emails_path, class: "underline underline-offset-2" %>.
63
+ <% else %>
64
+ <%= Studio.app_name %> has no object storage configured, so this image can be
65
+ viewed but not replaced yet.
66
+ <% end %>
67
+ </p>
68
+ </section>
69
+
70
+ <%# The email itself. An iframe because the response IS an email document —
71
+ its own <html>, its own table layout, and no business inheriting the admin
72
+ page's stylesheet. %>
73
+ <section class="card p-3 bg-inset">
74
+ <p class="label-upper mb-3 px-1">Preview</p>
75
+ <% if @entry.previewable? %>
76
+ <iframe src="<%= admin_email_raw_path(@entry.key) %>"
77
+ style="width:100%;height:780px;border:0;border-radius:8px;background:#ffffff;"
78
+ title="<%= @entry.label %> preview"></iframe>
79
+ <% else %>
80
+ <div class="px-3 pb-4">
81
+ <p class="text-sm text-body">
82
+ No preview is registered for this email. Pass a <code class="font-mono text-2xs bg-surface px-1.5 py-0.5 rounded">preview:</code>
83
+ callable when registering it — anything that returns a Mail — and it renders here.
84
+ </p>
85
+ </div>
86
+ <% end %>
87
+ <% if @preview_error.present? %>
88
+ <p class="text-2xs text-danger mt-2 px-1 font-mono break-all"><%= @preview_error %></p>
89
+ <% end %>
90
+ </section>
91
+ </div>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.37.0"
2
+ VERSION = "0.38.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -558,10 +558,17 @@ module Studio
558
558
  # Studio::EmailImage.resolved_url — so an app is branded on day one whether
559
559
  # or not it draws the page.
560
560
  if Studio.draw_admin_emails_routes
561
- get "admin/emails", to: "studio/emails#index", as: :admin_emails
562
- patch "admin/emails/:key", to: "studio/emails#update", as: :admin_email,
561
+ get "admin/emails", to: "studio/emails#index", as: :admin_emails
562
+ # /raw is drawn BEFORE /:key so "raw" is never captured as a key.
563
+ get "admin/emails/:key/raw", to: "studio/emails#raw", as: :admin_email_raw,
563
564
  constraints: { key: /[a-z0-9_]+/ }
564
- delete "admin/emails/:key", to: "studio/emails#destroy",
565
+ get "admin/emails/:key", to: "studio/emails#show", as: :admin_email,
566
+ constraints: { key: /[a-z0-9_]+/ }
567
+ # Same path, same helper (admin_email_path) — a named route only needs
568
+ # to be declared once per name, and these share the show route's URL.
569
+ patch "admin/emails/:key", to: "studio/emails#update",
570
+ constraints: { key: /[a-z0-9_]+/ }
571
+ delete "admin/emails/:key", to: "studio/emails#destroy",
565
572
  constraints: { key: /[a-z0-9_]+/ }
566
573
  end
567
574
 
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.37.0
4
+ version: 0.38.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -223,6 +223,7 @@ files:
223
223
  - app/models/studio/model_page.rb
224
224
  - app/models/theme_setting.rb
225
225
  - app/services/google_oauth_validator.rb
226
+ - app/services/studio/email_catalog.rb
226
227
  - app/services/studio/email_image.rb
227
228
  - app/views/components/_admin_dropdown.html.erb
228
229
  - app/views/components/_avatar.html.erb
@@ -276,6 +277,7 @@ files:
276
277
  - app/views/studio/email_images/index.html.erb
277
278
  - app/views/studio/emails/_row.html.erb
278
279
  - app/views/studio/emails/index.html.erb
280
+ - app/views/studio/emails/show.html.erb
279
281
  - app/views/studio/links/confirm.html.erb
280
282
  - app/views/studio/local_emails/index.html.erb
281
283
  - app/views/studio/modals/_crop_photo.html.erb